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,
PersonX_Stroke2_Corner0_Rounded as PersonX,
} 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 * as Menu from '#/components/Menu'
import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky'
let ConvoMenu = ({
@@ -183,7 +181,6 @@ function MenuContent({
reportControl: Prompt.PromptControlProps
blockedByListControl: Prompt.PromptControlProps
}) {
const ax = useAnalytics()
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const {mutate: markAsRead} = useMarkAsReadMutation()
@@ -195,12 +192,6 @@ function MenuContent({
const convoId = initialConvo.id
const {data: convo} = useConvoQuery(initialConvo)
const onNavigateToSettings = useCallback(() => {
navigation.navigate('MessagesConversationSettings', {
conversation: convoId,
})
}, [convoId, navigation])
const onNavigateToProfile = useCallback(() => {
navigation.navigate('Profile', {name: profile.did})
}, [navigation, profile.did])
@@ -229,14 +220,12 @@ function MenuContent({
}
if (userBlock) {
queueUnblock()
void queueUnblock()
} else {
queueBlock()
void queueBlock()
}
}, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock])
const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable)
return isDeletedAccount ? (
<Menu.Item
label={_(msg`Leave conversation`)}
@@ -248,16 +237,6 @@ function MenuContent({
</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>
{showMarkAsRead && (
<Menu.Item
+40 -9
View File
@@ -6,9 +6,12 @@ import {
type ModerationDecision,
} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/profile-shadow'
import {isConvoActive, useConvo} from '#/state/messages/convo'
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 {atoms as a, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, ButtonIcon} from '#/components/Button'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
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 {Link} from '#/components/Link'
import {PostAlerts} from '#/components/moderation/PostAlerts'
@@ -117,6 +122,8 @@ function HeaderReady({
const convoState = useConvo()
const {currentAccount} = useSession()
const navigation = useNavigation<NavigationProp>()
const groupInfo = convoState.getGroupInfo?.()
const isGroupChat = groupInfo != null
@@ -141,6 +148,17 @@ function HeaderReady({
? latestMessageFromOther.message
: undefined
const handleNavigateToSettings = () => {
const convoId = convoState.convo?.id
if (convoId) {
navigation.navigate('MessagesConversationSettings', {
conversation: convoId,
})
} else {
logger.error(`handleNavigateToSettings: missing convo ID`)
}
}
return (
<View style={[a.flex_1]}>
<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]}>
<Layout.Header.Slot>
{isConvoActive(convoState) && (
<ConvoMenu
convo={convoState.convo}
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
latestReportableMessage={latestReportableMessage}
/>
)}
{isConvoActive(convoState) ? (
isGroupChat ? (
<Button
label={l`Open group chat settings`}
size="small"
color="secondary"
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>
</View>
</View>
+411 -167
View File
@@ -1,6 +1,6 @@
import {useMemo, useState} from 'react'
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 {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
@@ -8,18 +8,30 @@ import {useNavigation} from '@react-navigation/native'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
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 {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 {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability'
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 {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, type ButtonColor, ButtonIcon} from '#/components/Button'
import type * as Dialog from '#/components/Dialog'
import {Error} from '#/components/Error'
import * as TextField from '#/components/forms/TextField'
import {useInteractionState} from '#/components/hooks/useInteractionState'
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 {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
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 {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
import {
@@ -63,16 +76,23 @@ type Item =
}
| {
type: 'CHAT_MEMBER'
profile: bsky.profile.AnyProfileView
status: 'admin' | 'member' | 'invited'
profile: Shadow<bsky.profile.AnyProfileView>
status: 'owner' | 'member' | 'invited'
}
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'MessagesConversationSettings'
>
/**
* TODO This is just layout for now.
*/
export function MessagesConversationSettingsScreen() {
export function MessagesConversationSettingsScreen({route}: Props) {
const {gtTablet} = useBreakpoints()
const convoId = route.params.conversation
return (
<Layout.Screen>
<Layout.Header.Outer>
@@ -84,7 +104,9 @@ export function MessagesConversationSettingsScreen() {
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<SettingsInner />
<ConvoProvider key={convoId} convoId={convoId}>
<SettingsInner />
</ConvoProvider>
</Layout.Screen>
)
}
@@ -94,10 +116,16 @@ function keyExtractor(item: Item) {
}
function SettingsInner() {
const {t: l} = useLingui()
const initialNumToRender = useInitialNumToRender({minItemHeight: 68})
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 items = [
@@ -107,16 +135,26 @@ function SettingsInner() {
{
type: 'ADD_MEMBERS_LINK',
},
...data.map((profile, index) => ({
type: 'CHAT_MEMBER',
profile,
status:
index === 0
? 'admin'
: invites.includes(profile.did)
? 'invited'
: 'member',
})),
...[...data]
.sort((a, b) => {
const aIsAdmin = a.did === primaryMember?.did
const bIsAdmin = b.did === primaryMember?.did
const aIsSelf = a.did === currentAccount?.did
const bIsSelf = b.did === currentAccount?.did
if (aIsAdmin !== bIsAdmin) return aIsAdmin ? -1 : 1
if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1
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}) {
@@ -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 (
<List
data={items}
@@ -141,7 +192,13 @@ function SettingsInner() {
desktopFixedHeight
initialNumToRender={initialNumToRender}
keyExtractor={keyExtractor}
ListHeaderComponent={<SettingsHeader profiles={data} />}
ListHeaderComponent={
convoState.convo ? (
<SettingsHeader convo={convoState.convo} profiles={data} />
) : (
<SettingsHeaderPlaceholder />
)
}
renderItem={renderItem}
sideBorders={false}
windowSize={11}
@@ -173,15 +230,17 @@ function MembersAndRequests({
{color: t.palette.contrast_500},
]}>{l`${memberCount}/${MEMBER_LIMIT}`}</Text>
</View>
<InlineLinkText
label={l`View incoming group chat requests`}
style={[a.text_sm, a.text_right, a.font_semi_bold]}
to="#">
{l`${plural(requestCount, {
one: '# request',
other: '# requests',
})}`}
</InlineLinkText>
{requestCount > 0 ? (
<InlineLinkText
label={l`View incoming group chat requests`}
style={[a.text_sm, a.text_right, a.font_semi_bold]}
to="#">
{l`${plural(requestCount, {
one: '# request',
other: '# requests',
})}`}
</InlineLinkText>
) : null}
</View>
)
}
@@ -252,13 +311,14 @@ function Member({
profile,
status,
}: {
profile: bsky.profile.AnyProfileView
status: 'admin' | 'member' | 'invited'
profile: Shadow<bsky.profile.AnyProfileView>
status: 'owner' | 'member' | 'invited'
}) {
const navigation = useNavigation<NavigationProp>()
const t = useTheme()
const {t: l} = useLingui()
const {currentAccount} = useSession()
const moderationOpts = useModerationOpts()
const moderation = useMemo(
() =>
@@ -268,8 +328,6 @@ function Member({
if (!moderation) return null
const invitedByDisplayName = 'Darrin Loeliger'
const isDeletedAccount = profile.handle === 'missing.invalid'
const displayName = isDeletedAccount
? l`Deleted Account`
@@ -278,27 +336,15 @@ function Member({
moderation.ui('displayName'),
)
let invitedBy: React.ReactNode | null = (
<Text style={[a.text_xs, {color: t.palette.contrast_500}]}>
{l`Added by ${invitedByDisplayName}`}
</Text>
)
let statusBadge: React.ReactNode | null = (
<MemberMenu profile={profile} type="member" />
)
switch (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
let statusBadge: React.ReactNode | null = null
if (currentAccount?.did === profile.did) {
switch (status) {
case 'owner':
statusBadge = <StatusBadge label={l`Admin`} />
break
}
} else {
statusBadge = <MemberMenu profile={profile} type={status} />
}
return (
@@ -334,16 +380,6 @@ function Member({
]}>
{sanitizeHandle(profile.handle, '@')}
</Text>
{invitedBy ? (
<Text
style={[
a.text_xs,
{color: t.palette.contrast_500},
web(a.pt_2xs),
]}>
{invitedBy}
</Text>
) : null}
</View>
</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()
return (
@@ -367,6 +409,7 @@ function StatusBadge({label}: {label: string}) {
paddingLeft: 6,
paddingRight: 6,
},
style,
]}>
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}>
{label}
@@ -410,14 +453,24 @@ function MemberMenu({
profile,
type,
}: {
profile: bsky.profile.AnyProfileView
type: 'member' | 'invited'
profile: Shadow<bsky.profile.AnyProfileView>
type: 'owner' | 'member' | 'invited'
}) {
const navigation = useNavigation<NavigationProp>()
const t = useTheme()
const {t: l} = useLingui()
const ax = useAnalytics()
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 {mutate: initiateConvo} = useGetConvoForMembers({
@@ -429,8 +482,9 @@ function MemberMenu({
Toast.show(l`Failed to create conversation`)
},
})
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
const onPress = () => {
const messageMember = () => {
if (!convoAvailability?.canChat) {
return
}
@@ -446,7 +500,7 @@ function MemberMenu({
}
}
const wrappedOnPress = requireEmailVerification(onPress, {
const handleMessageMember = requireEmailVerification(messageMember, {
instructions: [
<Trans key="message">
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 moderation = useMemo(
() =>
@@ -472,67 +556,80 @@ function MemberMenu({
)
return (
<Menu.Root>
<Menu.Trigger label={l`Open chat member options for ${displayName}`}>
{({props, state}) =>
type === 'invited' ? (
<StatusButton
{...props}
label={l`Invited`}
style={[
state.hovered
? {
backgroundColor: t.palette.contrast_0,
}
: null,
]}
/>
) : (
<Pressable
{...props}
style={[
a.rounded_full,
a.p_sm,
state.hovered
? {
backgroundColor: t.palette.contrast_0,
}
: null,
]}>
<EllipsisIcon style={[t.atoms.text_contrast_medium]} size="md" />
</Pressable>
)
}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={l`View ${displayName}s profile`}
onPress={() => {
navigation.navigate('Profile', {name: profile.did})
}}>
<Menu.ItemText>
<Trans>Go to profile</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={PersonIcon} />
</Menu.Item>
<Menu.Item label={l`Message ${displayName}`} onPress={wrappedOnPress}>
<Menu.ItemText>
<Trans>Message</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={MessageIcon} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
{type === 'member' ? (
<>
<Menu.Item label={l`Block ${displayName}`} onPress={() => {}}>
<>
<Menu.Root>
<Menu.Trigger label={l`Open chat member options for ${displayName}`}>
{({props, state, control: menuControl}) =>
type === 'owner' || type === 'invited' ? (
<StatusButton
{...props}
label={type === 'owner' ? l`Admin` : l`Invited`}
style={[
state.hovered || state.pressed || menuControl.isOpen
? {
backgroundColor: t.palette.contrast_0,
}
: null,
]}
/>
) : (
<Pressable
{...props}
style={[
a.rounded_full,
a.p_sm,
state.hovered || state.pressed || menuControl.isOpen
? {
backgroundColor: t.palette.contrast_0,
}
: null,
]}>
<EllipsisIcon
style={[t.atoms.text_contrast_medium]}
size="md"
/>
</Pressable>
)
}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={l`View ${displayName}s profile`}
onPress={() => {
navigation.navigate('Profile', {name: profile.did})
}}>
<Menu.ItemText>
<Trans>Go to profile</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={PersonIcon} />
</Menu.Item>
<Menu.Item
label={l`Message ${displayName}`}
onPress={handleMessageMember}>
<Menu.ItemText>
<Trans>Message</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={MessageIcon} />
</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>
<Trans>Block</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={PersonXIcon} />
</Menu.Item>
) : null}
{isOwner ? (
<Menu.Item
label={l`Remove ${displayName} from this group chat`}
onPress={() => {}}>
@@ -541,39 +638,80 @@ function MemberMenu({
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
</Menu.Item>
</>
) : null}
{type === 'invited' ? (
<Menu.Item
label={l`Uninvite ${displayName} from this group chat`}
onPress={() => {}}>
<Menu.ItemText>
<Trans>Uninvite</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
</Menu.Item>
) : null}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
) : null}
{isOwner && type === 'invited' ? (
<Menu.Item
label={l`Uninvite ${displayName} from this group chat`}
onPress={() => {}}>
<Menu.ItemText>
<Trans>Uninvite</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
</Menu.Item>
) : null}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<BlockMemberPrompt
control={blockMemberPrompt}
onConfirm={() => void handleBlockMember()}
/>
</>
)
}
function SettingsHeader({profiles}: {profiles: bsky.profile.AnyProfileView[]}) {
function SettingsHeader({
convo,
profiles,
}: {
convo: ChatBskyConvoDefs.ConvoView
profiles: bsky.profile.AnyProfileView[]
}) {
const t = useTheme()
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 inviteLinkPrompt = 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 [isMuted, setIsMuted] = useState(false)
const [isLocked, setIsLocked] = useState(false)
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 = () => {
@@ -641,35 +779,57 @@ function SettingsHeader({profiles}: {profiles: bsky.profile.AnyProfileView[]}) {
a.pt_2xl,
]}>
<SettingsButton
color={isMuted ? 'negative_subtle' : 'secondary'}
icon={isMuted ? BellOffIcon : BellIcon}
color={convo?.muted ? 'negative_subtle' : 'secondary'}
icon={convo?.muted ? BellOffIcon : BellIcon}
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}
/>
<SettingsButton
icon={EditIcon}
label={l`Edit this group chats name`}
text={l`Edit name`}
onPress={handlePromptName}
/>
{isOwner ? (
<SettingsButton
icon={EditIcon}
label={l`Edit this group chats name`}
text={l`Edit name`}
onPress={handlePromptName}
/>
) : null}
<SettingsButton
icon={ChainLinkIcon}
label={l`Create an invite link for this group chat`}
text={l`Invite link`}
onPress={handlePromptInviteLink}
/>
<SettingsButton
color={isLocked ? 'negative_subtle' : 'secondary'}
icon={LockIcon}
label={
isLocked ? l`Unlock this group chat` : l`Lock this group chat`
}
text={isLocked ? l`Locked` : l`Lock`}
onPress={isLocked ? handleUnlock : handlePromptLock}
/>
{isOwner ? (
<SettingsButton
color={isLocked ? 'negative_subtle' : 'secondary'}
icon={LockIcon}
label={
isLocked ? l`Unlock this group chat` : l`Lock this group chat`
}
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>
<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({
color = 'secondary',
icon,
@@ -713,6 +913,7 @@ function SettingsButton({
<ButtonIcon icon={icon} size="md" />
</Button>
<Text
numberOfLines={1}
style={[
a.text_2xs,
a.font_medium,
@@ -726,11 +927,28 @@ function SettingsButton({
)
}
type EditNamePromptProps = {
control: Dialog.DialogOuterProps['control']
value: string
onChangeText: (value: string) => void
onConfirm: () => void
function SettingsButtonPlaceholder() {
const t = useTheme()
const {t: l} = useLingui()
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({
@@ -738,7 +956,12 @@ function EditNamePrompt({
value,
onChangeText,
onConfirm,
}: EditNamePromptProps) {
}: {
control: Dialog.DialogOuterProps['control']
value: string
onChangeText: (value: string) => void
onConfirm: () => void
}) {
const {t: l} = useLingui()
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>) {
const {
state: hover,
@@ -365,7 +365,7 @@ export function MessagesList({
profile={convoState.convo.members.find(
member => member.did === item.message.sender.did,
)}
isGroupChat={convoState.convo.kind === 'group'}
isGroupChat={convoState.getGroupInfo?.() != null}
/>
)
} else if (item.type === 'deleted-message') {
@@ -31,13 +31,13 @@ export function useMuteConvo(
mutationFn: async ({mute}: {mute: boolean}) => {
if (!convoId) throw new Error('No convoId provided')
if (mute) {
const {data} = await agent.api.chat.bsky.convo.muteConvo(
const {data} = await agent.chat.bsky.convo.muteConvo(
{convoId},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
return data
} else {
const {data} = await agent.api.chat.bsky.convo.unmuteConvo(
const {data} = await agent.chat.bsky.convo.unmuteConvo(
{convoId},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)