Start integrating group clip clop settings (#10217)

This commit is contained in:
DS Boyce
2026-04-14 10:26:28 -07:00
committed by GitHub
parent 3d542ef3ad
commit 837c404070
5 changed files with 456 additions and 202 deletions
+2 -23
View File
@@ -35,13 +35,11 @@ import {
PersonCheck_Stroke2_Corner0_Rounded as PersonCheck, PersonCheck_Stroke2_Corner0_Rounded as PersonCheck,
PersonX_Stroke2_Corner0_Rounded as PersonX, PersonX_Stroke2_Corner0_Rounded as PersonX,
} from '#/components/icons/Person' } from '#/components/icons/Person'
import {SettingsGear2_Stroke2_Corner0_Rounded as GearIcon} from '#/components/icons/SettingsGear2'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
import {ReportDialog} from '#/components/moderation/ReportDialog' import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
let ConvoMenu = ({ let ConvoMenu = ({
@@ -183,7 +181,6 @@ function MenuContent({
reportControl: Prompt.PromptControlProps reportControl: Prompt.PromptControlProps
blockedByListControl: Prompt.PromptControlProps blockedByListControl: Prompt.PromptControlProps
}) { }) {
const ax = useAnalytics()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {_} = useLingui() const {_} = useLingui()
const {mutate: markAsRead} = useMarkAsReadMutation() const {mutate: markAsRead} = useMarkAsReadMutation()
@@ -195,12 +192,6 @@ function MenuContent({
const convoId = initialConvo.id const convoId = initialConvo.id
const {data: convo} = useConvoQuery(initialConvo) const {data: convo} = useConvoQuery(initialConvo)
const onNavigateToSettings = useCallback(() => {
navigation.navigate('MessagesConversationSettings', {
conversation: convoId,
})
}, [convoId, navigation])
const onNavigateToProfile = useCallback(() => { const onNavigateToProfile = useCallback(() => {
navigation.navigate('Profile', {name: profile.did}) navigation.navigate('Profile', {name: profile.did})
}, [navigation, profile.did]) }, [navigation, profile.did])
@@ -229,14 +220,12 @@ function MenuContent({
} }
if (userBlock) { if (userBlock) {
queueUnblock() void queueUnblock()
} else { } else {
queueBlock() void queueBlock()
} }
}, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock]) }, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock])
const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable)
return isDeletedAccount ? ( return isDeletedAccount ? (
<Menu.Item <Menu.Item
label={_(msg`Leave conversation`)} label={_(msg`Leave conversation`)}
@@ -248,16 +237,6 @@ function MenuContent({
</Menu.Item> </Menu.Item>
) : ( ) : (
<> <>
{isGroupChatEnabled ? (
<Menu.Group>
<Menu.Item label={_(msg`Settings`)} onPress={onNavigateToSettings}>
<Menu.ItemText>
<Trans>Settings</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={GearIcon} />
</Menu.Item>
</Menu.Group>
) : null}
<Menu.Group> <Menu.Group>
{showMarkAsRead && ( {showMarkAsRead && (
<Menu.Item <Menu.Item
+40 -9
View File
@@ -6,9 +6,12 @@ import {
type ModerationDecision, type ModerationDecision,
} from '@atproto/api' } from '@atproto/api'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/profile-shadow' import {type Shadow} from '#/state/cache/profile-shadow'
import {isConvoActive, useConvo} from '#/state/messages/convo' import {isConvoActive, useConvo} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types' import {type ConvoItem} from '#/state/messages/convo/types'
@@ -16,8 +19,10 @@ import {useSession} from '#/state/session'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles' import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, ButtonIcon} from '#/components/Button'
import {ConvoMenu} from '#/components/dms/ConvoMenu' import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {Bell2Off_Filled_Corner0_Rounded as BellOffIcon} from '#/components/icons/Bell2' import {Bell2Off_Filled_Corner0_Rounded as BellOffIcon} from '#/components/icons/Bell2'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
import {PostAlerts} from '#/components/moderation/PostAlerts' import {PostAlerts} from '#/components/moderation/PostAlerts'
@@ -117,6 +122,8 @@ function HeaderReady({
const convoState = useConvo() const convoState = useConvo()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const navigation = useNavigation<NavigationProp>()
const groupInfo = convoState.getGroupInfo?.() const groupInfo = convoState.getGroupInfo?.()
const isGroupChat = groupInfo != null const isGroupChat = groupInfo != null
@@ -141,6 +148,17 @@ function HeaderReady({
? latestMessageFromOther.message ? latestMessageFromOther.message
: undefined : undefined
const handleNavigateToSettings = () => {
const convoId = convoState.convo?.id
if (convoId) {
navigation.navigate('MessagesConversationSettings', {
conversation: convoId,
})
} else {
logger.error(`handleNavigateToSettings: missing convo ID`)
}
}
return ( return (
<View style={[a.flex_1]}> <View style={[a.flex_1]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}> <View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
@@ -199,15 +217,28 @@ function HeaderReady({
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}> <View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
<Layout.Header.Slot> <Layout.Header.Slot>
{isConvoActive(convoState) && ( {isConvoActive(convoState) ? (
<ConvoMenu isGroupChat ? (
convo={convoState.convo} <Button
profile={profile} label={l`Open group chat settings`}
currentScreen="conversation" size="small"
blockInfo={blockInfo} color="secondary"
latestReportableMessage={latestReportableMessage} shape="round"
/> variant="ghost"
)} style={[a.bg_transparent]}
onPress={handleNavigateToSettings}>
<ButtonIcon icon={DotsHorizontalIcon} size="md" />
</Button>
) : (
<ConvoMenu
convo={convoState.convo}
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
latestReportableMessage={latestReportableMessage}
/>
)
) : null}
</Layout.Header.Slot> </Layout.Header.Slot>
</View> </View>
</View> </View>
+411 -167
View File
@@ -1,6 +1,6 @@
import {useMemo, useState} from 'react' import {useMemo, useState} from 'react'
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
import {moderateProfile} from '@atproto/api' import {type ChatBskyConvoDefs, moderateProfile} from '@atproto/api'
import {plural} from '@lingui/core/macro' import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
@@ -8,18 +8,30 @@ import {useNavigation} from '@react-navigation/native'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {type NavigationProp} from '#/lib/routes/types' import {
type CommonNavigatorParams,
type NativeStackScreenProps,
type NavigationProp,
} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/types'
import {ConvoProvider, useConvo} from '#/state/messages/convo'
import {ConvoStatus} from '#/state/messages/convo/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {useMuteConvo} from '#/state/queries/messages/mute-conversation'
import {useProfileBlockMutationQueue} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {List} from '#/view/com/util/List' import {List} from '#/view/com/util/List'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles' import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, type ButtonColor, ButtonIcon} from '#/components/Button' import {Button, type ButtonColor, ButtonIcon} from '#/components/Button'
import type * as Dialog from '#/components/Dialog' import type * as Dialog from '#/components/Dialog'
import {Error} from '#/components/Error'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
@@ -32,6 +44,7 @@ import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/
import {type Props as SVGIconProps} from '#/components/icons/common' import {type Props as SVGIconProps} from '#/components/icons/common'
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' 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_Corner0_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 {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
import { import {
@@ -63,16 +76,23 @@ type Item =
} }
| { | {
type: 'CHAT_MEMBER' type: 'CHAT_MEMBER'
profile: bsky.profile.AnyProfileView profile: Shadow<bsky.profile.AnyProfileView>
status: 'admin' | 'member' | 'invited' status: 'owner' | 'member' | 'invited'
} }
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'MessagesConversationSettings'
>
/** /**
* TODO This is just layout for now. * TODO This is just layout for now.
*/ */
export function MessagesConversationSettingsScreen() { export function MessagesConversationSettingsScreen({route}: Props) {
const {gtTablet} = useBreakpoints() const {gtTablet} = useBreakpoints()
const convoId = route.params.conversation
return ( return (
<Layout.Screen> <Layout.Screen>
<Layout.Header.Outer> <Layout.Header.Outer>
@@ -84,7 +104,9 @@ export function MessagesConversationSettingsScreen() {
</Layout.Header.Content> </Layout.Header.Content>
<Layout.Header.Slot /> <Layout.Header.Slot />
</Layout.Header.Outer> </Layout.Header.Outer>
<SettingsInner /> <ConvoProvider key={convoId} convoId={convoId}>
<SettingsInner />
</ConvoProvider>
</Layout.Screen> </Layout.Screen>
) )
} }
@@ -94,10 +116,16 @@ function keyExtractor(item: Item) {
} }
function SettingsInner() { function SettingsInner() {
const {t: l} = useLingui()
const initialNumToRender = useInitialNumToRender({minItemHeight: 68}) const initialNumToRender = useInitialNumToRender({minItemHeight: 68})
const bottomBarOffset = useBottomBarOffset() const bottomBarOffset = useBottomBarOffset()
const data: bsky.profile.AnyProfileView[] = [] const convoState = useConvo()
const {currentAccount} = useSession()
const primaryMember = convoState?.getPrimaryMember?.()
const data: bsky.profile.AnyProfileView[] = convoState.convo?.members ?? []
const invites: string[] = [] const invites: string[] = []
const items = [ const items = [
@@ -107,16 +135,26 @@ function SettingsInner() {
{ {
type: 'ADD_MEMBERS_LINK', type: 'ADD_MEMBERS_LINK',
}, },
...data.map((profile, index) => ({ ...[...data]
type: 'CHAT_MEMBER', .sort((a, b) => {
profile, const aIsAdmin = a.did === primaryMember?.did
status: const bIsAdmin = b.did === primaryMember?.did
index === 0 const aIsSelf = a.did === currentAccount?.did
? 'admin' const bIsSelf = b.did === currentAccount?.did
: invites.includes(profile.did) if (aIsAdmin !== bIsAdmin) return aIsAdmin ? -1 : 1
? 'invited' if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1
: 'member', return 0
})), })
.map(profile => ({
type: 'CHAT_MEMBER',
profile,
status:
primaryMember?.did === profile.did
? 'owner'
: invites.includes(profile.did)
? 'invited'
: 'member',
})),
] ]
function renderItem({item}: {item: Item}) { function renderItem({item}: {item: Item}) {
@@ -132,6 +170,19 @@ function SettingsInner() {
} }
} }
if (convoState.status === ConvoStatus.Error) {
return (
<>
<Error
title={l`Something went wrong`}
message={l`We couldnt load this conversations settings`}
onRetry={() => convoState.error.retry()}
sideBorders={false}
/>
</>
)
}
return ( return (
<List <List
data={items} data={items}
@@ -141,7 +192,13 @@ function SettingsInner() {
desktopFixedHeight desktopFixedHeight
initialNumToRender={initialNumToRender} initialNumToRender={initialNumToRender}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
ListHeaderComponent={<SettingsHeader profiles={data} />} ListHeaderComponent={
convoState.convo ? (
<SettingsHeader convo={convoState.convo} profiles={data} />
) : (
<SettingsHeaderPlaceholder />
)
}
renderItem={renderItem} renderItem={renderItem}
sideBorders={false} sideBorders={false}
windowSize={11} windowSize={11}
@@ -173,15 +230,17 @@ function MembersAndRequests({
{color: t.palette.contrast_500}, {color: t.palette.contrast_500},
]}>{l`${memberCount}/${MEMBER_LIMIT}`}</Text> ]}>{l`${memberCount}/${MEMBER_LIMIT}`}</Text>
</View> </View>
<InlineLinkText {requestCount > 0 ? (
label={l`View incoming group chat requests`} <InlineLinkText
style={[a.text_sm, a.text_right, a.font_semi_bold]} label={l`View incoming group chat requests`}
to="#"> style={[a.text_sm, a.text_right, a.font_semi_bold]}
{l`${plural(requestCount, { to="#">
one: '# request', {l`${plural(requestCount, {
other: '# requests', one: '# request',
})}`} other: '# requests',
</InlineLinkText> })}`}
</InlineLinkText>
) : null}
</View> </View>
) )
} }
@@ -252,13 +311,14 @@ function Member({
profile, profile,
status, status,
}: { }: {
profile: bsky.profile.AnyProfileView profile: Shadow<bsky.profile.AnyProfileView>
status: 'admin' | 'member' | 'invited' status: 'owner' | 'member' | 'invited'
}) { }) {
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const {currentAccount} = useSession()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const moderation = useMemo( const moderation = useMemo(
() => () =>
@@ -268,8 +328,6 @@ function Member({
if (!moderation) return null if (!moderation) return null
const invitedByDisplayName = 'Darrin Loeliger'
const isDeletedAccount = profile.handle === 'missing.invalid' const isDeletedAccount = profile.handle === 'missing.invalid'
const displayName = isDeletedAccount const displayName = isDeletedAccount
? l`Deleted Account` ? l`Deleted Account`
@@ -278,27 +336,15 @@ function Member({
moderation.ui('displayName'), moderation.ui('displayName'),
) )
let invitedBy: React.ReactNode | null = ( let statusBadge: React.ReactNode | null = null
<Text style={[a.text_xs, {color: t.palette.contrast_500}]}> if (currentAccount?.did === profile.did) {
{l`Added by ${invitedByDisplayName}`} switch (status) {
</Text> case 'owner':
) statusBadge = <StatusBadge label={l`Admin`} />
let statusBadge: React.ReactNode | null = ( break
<MemberMenu profile={profile} type="member" /> }
) } else {
switch (status) { statusBadge = <MemberMenu profile={profile} type={status} />
case 'admin':
invitedBy = null
statusBadge = <StatusBadge label={l`Admin`} />
break
case 'invited':
invitedBy = (
<Text style={[a.text_xs, {color: t.palette.contrast_500}]}>
{l`Invited by ${invitedByDisplayName}`}
</Text>
)
statusBadge = <MemberMenu profile={profile} type="invited" />
break
} }
return ( return (
@@ -334,16 +380,6 @@ function Member({
]}> ]}>
{sanitizeHandle(profile.handle, '@')} {sanitizeHandle(profile.handle, '@')}
</Text> </Text>
{invitedBy ? (
<Text
style={[
a.text_xs,
{color: t.palette.contrast_500},
web(a.pt_2xs),
]}>
{invitedBy}
</Text>
) : null}
</View> </View>
</View> </View>
<View>{statusBadge}</View> <View>{statusBadge}</View>
@@ -353,7 +389,13 @@ function Member({
) )
} }
function StatusBadge({label}: {label: string}) { function StatusBadge({
label,
style,
}: {
label: string
style?: StyleProp<ViewStyle>
}) {
const t = useTheme() const t = useTheme()
return ( return (
@@ -367,6 +409,7 @@ function StatusBadge({label}: {label: string}) {
paddingLeft: 6, paddingLeft: 6,
paddingRight: 6, paddingRight: 6,
}, },
style,
]}> ]}>
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}> <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}>
{label} {label}
@@ -410,14 +453,24 @@ function MemberMenu({
profile, profile,
type, type,
}: { }: {
profile: bsky.profile.AnyProfileView profile: Shadow<bsky.profile.AnyProfileView>
type: 'member' | 'invited' type: 'owner' | 'member' | 'invited'
}) { }) {
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const requireEmailVerification = useRequireEmailVerification() const requireEmailVerification = useRequireEmailVerification()
const convoState = useConvo()
const {currentAccount} = useSession()
const blockMemberPrompt = Prompt.usePromptControl()
const isOwner =
currentAccount?.did == null
? false
: convoState.getPrimaryMember?.()?.did === currentAccount.did
const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did)
const {mutate: initiateConvo} = useGetConvoForMembers({ const {mutate: initiateConvo} = useGetConvoForMembers({
@@ -429,8 +482,9 @@ function MemberMenu({
Toast.show(l`Failed to create conversation`) Toast.show(l`Failed to create conversation`)
}, },
}) })
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
const onPress = () => { const messageMember = () => {
if (!convoAvailability?.canChat) { if (!convoAvailability?.canChat) {
return return
} }
@@ -446,7 +500,7 @@ function MemberMenu({
} }
} }
const wrappedOnPress = requireEmailVerification(onPress, { const handleMessageMember = requireEmailVerification(messageMember, {
instructions: [ instructions: [
<Trans key="message"> <Trans key="message">
Before you can message another user, you must first verify your email. Before you can message another user, you must first verify your email.
@@ -454,6 +508,36 @@ function MemberMenu({
], ],
}) })
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') {
ax.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') {
ax.logger.error('Failed to block account', {message: e})
Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error',
})
}
}
}
}
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const moderation = useMemo( const moderation = useMemo(
() => () =>
@@ -472,67 +556,80 @@ function MemberMenu({
) )
return ( return (
<Menu.Root> <>
<Menu.Trigger label={l`Open chat member options for ${displayName}`}> <Menu.Root>
{({props, state}) => <Menu.Trigger label={l`Open chat member options for ${displayName}`}>
type === 'invited' ? ( {({props, state, control: menuControl}) =>
<StatusButton type === 'owner' || type === 'invited' ? (
{...props} <StatusButton
label={l`Invited`} {...props}
style={[ label={type === 'owner' ? l`Admin` : l`Invited`}
state.hovered style={[
? { state.hovered || state.pressed || menuControl.isOpen
backgroundColor: t.palette.contrast_0, ? {
} backgroundColor: t.palette.contrast_0,
: null, }
]} : null,
/> ]}
) : ( />
<Pressable ) : (
{...props} <Pressable
style={[ {...props}
a.rounded_full, style={[
a.p_sm, a.rounded_full,
state.hovered a.p_sm,
? { state.hovered || state.pressed || menuControl.isOpen
backgroundColor: t.palette.contrast_0, ? {
} backgroundColor: t.palette.contrast_0,
: null, }
]}> : null,
<EllipsisIcon style={[t.atoms.text_contrast_medium]} size="md" /> ]}>
</Pressable> <EllipsisIcon
) style={[t.atoms.text_contrast_medium]}
} size="md"
</Menu.Trigger> />
<Menu.Outer> </Pressable>
<Menu.Group> )
<Menu.Item }
label={l`View ${displayName}s profile`} </Menu.Trigger>
onPress={() => { <Menu.Outer>
navigation.navigate('Profile', {name: profile.did}) <Menu.Group>
}}> <Menu.Item
<Menu.ItemText> label={l`View ${displayName}s profile`}
<Trans>Go to profile</Trans> onPress={() => {
</Menu.ItemText> navigation.navigate('Profile', {name: profile.did})
<Menu.ItemIcon icon={PersonIcon} /> }}>
</Menu.Item> <Menu.ItemText>
<Menu.Item label={l`Message ${displayName}`} onPress={wrappedOnPress}> <Trans>Go to profile</Trans>
<Menu.ItemText> </Menu.ItemText>
<Trans>Message</Trans> <Menu.ItemIcon icon={PersonIcon} />
</Menu.ItemText> </Menu.Item>
<Menu.ItemIcon icon={MessageIcon} /> <Menu.Item
</Menu.Item> label={l`Message ${displayName}`}
</Menu.Group> onPress={handleMessageMember}>
<Menu.Divider /> <Menu.ItemText>
<Menu.Group> <Trans>Message</Trans>
{type === 'member' ? ( </Menu.ItemText>
<> <Menu.ItemIcon icon={MessageIcon} />
<Menu.Item label={l`Block ${displayName}`} onPress={() => {}}> </Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
{type === 'owner' || type === 'member' ? (
<Menu.Item
label={
profile.viewer?.blocking
? l`Unblock ${displayName}`
: l`Block ${displayName}`
}
onPress={() => blockMemberPrompt.open()}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Block</Trans> <Trans>Block</Trans>
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon icon={PersonXIcon} /> <Menu.ItemIcon icon={PersonXIcon} />
</Menu.Item> </Menu.Item>
) : null}
{isOwner ? (
<Menu.Item <Menu.Item
label={l`Remove ${displayName} from this group chat`} label={l`Remove ${displayName} from this group chat`}
onPress={() => {}}> onPress={() => {}}>
@@ -541,39 +638,80 @@ function MemberMenu({
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeftIcon} /> <Menu.ItemIcon icon={ArrowBoxLeftIcon} />
</Menu.Item> </Menu.Item>
</> ) : null}
) : null} {isOwner && type === 'invited' ? (
{type === 'invited' ? ( <Menu.Item
<Menu.Item label={l`Uninvite ${displayName} from this group chat`}
label={l`Uninvite ${displayName} from this group chat`} onPress={() => {}}>
onPress={() => {}}> <Menu.ItemText>
<Menu.ItemText> <Trans>Uninvite</Trans>
<Trans>Uninvite</Trans> </Menu.ItemText>
</Menu.ItemText> <Menu.ItemIcon icon={ArrowBoxLeftIcon} />
<Menu.ItemIcon icon={ArrowBoxLeftIcon} /> </Menu.Item>
</Menu.Item> ) : null}
) : null} </Menu.Group>
</Menu.Group> </Menu.Outer>
</Menu.Outer> </Menu.Root>
</Menu.Root> <BlockMemberPrompt
control={blockMemberPrompt}
onConfirm={() => void handleBlockMember()}
/>
</>
) )
} }
function SettingsHeader({profiles}: {profiles: bsky.profile.AnyProfileView[]}) { function SettingsHeader({
convo,
profiles,
}: {
convo: ChatBskyConvoDefs.ConvoView
profiles: bsky.profile.AnyProfileView[]
}) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const convoState = useConvo()
const {currentAccount} = useSession()
const isOwner =
currentAccount?.did == null
? false
: convoState.getPrimaryMember?.()?.did === currentAccount.did
const {mutate: muteConvo} = useMuteConvo(convo.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: () => {
Toast.show(l`Could not mute group chat`, {
type: 'error',
})
},
})
const editNamePrompt = Prompt.usePromptControl() const editNamePrompt = Prompt.usePromptControl()
const inviteLinkPrompt = Prompt.usePromptControl() const inviteLinkPrompt = Prompt.usePromptControl()
const lockChatPrompt = Prompt.usePromptControl() const lockChatPrompt = Prompt.usePromptControl()
const [groupName, setGroupName] = useState('Work in Progress') const [groupName, setGroupName] = useState(
convoState.getGroupInfo?.()?.name ?? '',
)
const [newGroupName, setNewGroupName] = useState(groupName) const [newGroupName, setNewGroupName] = useState(groupName)
const [isMuted, setIsMuted] = useState(false)
const [isLocked, setIsLocked] = useState(false) const [isLocked, setIsLocked] = useState(false)
const handleToggleMute = () => { const handleToggleMute = () => {
setIsMuted(prev => !prev) try {
muteConvo({mute: !convo?.muted})
} catch (err) {
const e = err as Error
logger.error('Failed to mute group chat', {message: e})
Toast.show(l`There was an issue! ${e.toString()}`, {type: 'error'})
}
} }
const handlePromptName = () => { const handlePromptName = () => {
@@ -641,35 +779,57 @@ function SettingsHeader({profiles}: {profiles: bsky.profile.AnyProfileView[]}) {
a.pt_2xl, a.pt_2xl,
]}> ]}>
<SettingsButton <SettingsButton
color={isMuted ? 'negative_subtle' : 'secondary'} color={convo?.muted ? 'negative_subtle' : 'secondary'}
icon={isMuted ? BellOffIcon : BellIcon} icon={convo?.muted ? BellOffIcon : BellIcon}
label={ label={
isMuted ? l`Unmute this group chat` : l`Mute this group chat` convo?.muted ? l`Unmute this group chat` : l`Mute this group chat`
} }
text={isMuted ? l`Muted` : l`Mute`} text={convo?.muted ? l`Muted` : l`Mute`}
onPress={handleToggleMute} onPress={handleToggleMute}
/> />
<SettingsButton {isOwner ? (
icon={EditIcon} <SettingsButton
label={l`Edit this group chats name`} icon={EditIcon}
text={l`Edit name`} label={l`Edit this group chats name`}
onPress={handlePromptName} text={l`Edit name`}
/> onPress={handlePromptName}
/>
) : null}
<SettingsButton <SettingsButton
icon={ChainLinkIcon} icon={ChainLinkIcon}
label={l`Create an invite link for this group chat`} label={l`Create an invite link for this group chat`}
text={l`Invite link`} text={l`Invite link`}
onPress={handlePromptInviteLink} onPress={handlePromptInviteLink}
/> />
<SettingsButton {isOwner ? (
color={isLocked ? 'negative_subtle' : 'secondary'} <SettingsButton
icon={LockIcon} color={isLocked ? 'negative_subtle' : 'secondary'}
label={ icon={LockIcon}
isLocked ? l`Unlock this group chat` : l`Lock this group chat` label={
} isLocked ? l`Unlock this group chat` : l`Lock this group chat`
text={isLocked ? l`Locked` : l`Lock`} }
onPress={isLocked ? handleUnlock : handlePromptLock} text={isLocked ? l`Locked` : l`Lock`}
/> onPress={isLocked ? handleUnlock : handlePromptLock}
/>
) : null}
{isOwner ? null : (
<SettingsButton
color="secondary"
icon={FlagIcon}
label={l`Report this group chat`}
text={l`Report`}
onPress={() => {}}
/>
)}
{isOwner ? null : (
<SettingsButton
color="secondary"
icon={ArrowBoxLeftIcon}
label={l`Leave this group chat`}
text={l`Leave`}
onPress={() => {}}
/>
)}
</View> </View>
</View> </View>
<EditNamePrompt <EditNamePrompt
@@ -687,6 +847,46 @@ function SettingsHeader({profiles}: {profiles: bsky.profile.AnyProfileView[]}) {
) )
} }
function SettingsHeaderPlaceholder() {
const t = useTheme()
const {t: l} = useLingui()
return (
<View style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}>
<View style={[a.align_center, a.justify_center]}>
<AvatarBubbles profiles={[]} />
</View>
<Text
style={[a.text_2xl, a.font_bold, a.text_center, a.pt_lg, t.atoms.text]}>
{l``}
</Text>
<Text
style={[
a.text_sm,
a.text_center,
a.pt_xs,
a.px_xl,
t.atoms.text_contrast_high,
]}>
<Trans></Trans>
</Text>
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.gap_2xl,
a.pt_2xl,
]}>
<SettingsButtonPlaceholder />
<SettingsButtonPlaceholder />
<SettingsButtonPlaceholder />
<SettingsButtonPlaceholder />
</View>
</View>
)
}
function SettingsButton({ function SettingsButton({
color = 'secondary', color = 'secondary',
icon, icon,
@@ -713,6 +913,7 @@ function SettingsButton({
<ButtonIcon icon={icon} size="md" /> <ButtonIcon icon={icon} size="md" />
</Button> </Button>
<Text <Text
numberOfLines={1}
style={[ style={[
a.text_2xs, a.text_2xs,
a.font_medium, a.font_medium,
@@ -726,11 +927,28 @@ function SettingsButton({
) )
} }
type EditNamePromptProps = { function SettingsButtonPlaceholder() {
control: Dialog.DialogOuterProps['control'] const t = useTheme()
value: string const {t: l} = useLingui()
onChangeText: (value: string) => void
onConfirm: () => void return (
<View>
<Button color="secondary" size="large" shape="round" label={l`Loading…`}>
<ButtonIcon icon={EllipsisIcon} size="md" />
</Button>
<Text
numberOfLines={1}
style={[
a.text_2xs,
a.font_medium,
a.text_center,
a.pt_xs,
t.atoms.text,
]}>
{l``}
</Text>
</View>
)
} }
function EditNamePrompt({ function EditNamePrompt({
@@ -738,7 +956,12 @@ function EditNamePrompt({
value, value,
onChangeText, onChangeText,
onConfirm, onConfirm,
}: EditNamePromptProps) { }: {
control: Dialog.DialogOuterProps['control']
value: string
onChangeText: (value: string) => void
onConfirm: () => void
}) {
const {t: l} = useLingui() const {t: l} = useLingui()
return ( return (
@@ -819,6 +1042,27 @@ function LockChatPrompt({
) )
} }
function BlockMemberPrompt({
control,
onConfirm,
}: {
control: Dialog.DialogOuterProps['control']
onConfirm: () => void
}) {
const {t: l} = useLingui()
return (
<Prompt.Basic
control={control}
title={l`Block account?`}
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
onConfirm={onConfirm}
confirmButtonCta={l`Block`}
confirmButtonColor="negative"
/>
)
}
function SubtleHoverWrapper({children}: React.PropsWithChildren<unknown>) { function SubtleHoverWrapper({children}: React.PropsWithChildren<unknown>) {
const { const {
state: hover, state: hover,
@@ -365,7 +365,7 @@ export function MessagesList({
profile={convoState.convo.members.find( profile={convoState.convo.members.find(
member => member.did === item.message.sender.did, member => member.did === item.message.sender.did,
)} )}
isGroupChat={convoState.convo.kind === 'group'} isGroupChat={convoState.getGroupInfo?.() != null}
/> />
) )
} else if (item.type === 'deleted-message') { } else if (item.type === 'deleted-message') {
@@ -31,13 +31,13 @@ export function useMuteConvo(
mutationFn: async ({mute}: {mute: boolean}) => { mutationFn: async ({mute}: {mute: boolean}) => {
if (!convoId) throw new Error('No convoId provided') if (!convoId) throw new Error('No convoId provided')
if (mute) { if (mute) {
const {data} = await agent.api.chat.bsky.convo.muteConvo( const {data} = await agent.chat.bsky.convo.muteConvo(
{convoId}, {convoId},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, {headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
) )
return data return data
} else { } else {
const {data} = await agent.api.chat.bsky.convo.unmuteConvo( const {data} = await agent.chat.bsky.convo.unmuteConvo(
{convoId}, {convoId},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, {headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
) )