Tweak conversation settings (#10347)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
|||||||
|
import {Pressable, View} from 'react-native'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useAddGroupMembers} from '#/state/queries/messages/add-group-members'
|
||||||
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {AddMembersFlow} from '#/components/dms/AddMembersFlow'
|
||||||
|
import {type ConvoWithDetails} from '#/components/dms/util'
|
||||||
|
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron'
|
||||||
|
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {SubtleHoverWrapper} from './SubtleHoverWrapper'
|
||||||
|
|
||||||
|
export function AddMembersLink({
|
||||||
|
convo,
|
||||||
|
members,
|
||||||
|
}: {
|
||||||
|
convo: ConvoWithDetails
|
||||||
|
members: string[]
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
const addMembersControl = Dialog.useDialogControl()
|
||||||
|
|
||||||
|
const convoId = convo.view.id
|
||||||
|
const {mutate: addGroupMembers} = useAddGroupMembers(convoId, {
|
||||||
|
onSuccess: () => {
|
||||||
|
addMembersControl.close()
|
||||||
|
},
|
||||||
|
onError: e => {
|
||||||
|
logger.error('Failed to add group chat members', {message: e})
|
||||||
|
Toast.show(l`Failed to add members`, {type: 'error'})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SubtleHoverWrapper>
|
||||||
|
<View style={[a.mx_xl]}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
style={({pressed}) => [
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
pressed && web({outline: 'none'}),
|
||||||
|
]}
|
||||||
|
onPress={addMembersControl.open}>
|
||||||
|
{({pressed}) => (
|
||||||
|
<>
|
||||||
|
<View>
|
||||||
|
<View style={[a.flex_row, a.align_center]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
a.p_lg,
|
||||||
|
a.rounded_full,
|
||||||
|
pressed
|
||||||
|
? t.atoms.bg_contrast_100
|
||||||
|
: t.atoms.bg_contrast_50,
|
||||||
|
{
|
||||||
|
height: 48,
|
||||||
|
width: 48,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<PlusIcon
|
||||||
|
style={[t.atoms.text_contrast_high]}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
a.font_semi_bold,
|
||||||
|
a.pl_sm,
|
||||||
|
t.atoms.text,
|
||||||
|
]}>
|
||||||
|
<Trans>Add members</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ChevronIcon style={[t.atoms.text_contrast_medium]} size="md" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</SubtleHoverWrapper>
|
||||||
|
|
||||||
|
<Dialog.Outer
|
||||||
|
control={addMembersControl}
|
||||||
|
testID="addChatMembersDialog"
|
||||||
|
nativeOptions={{fullHeight: true}}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<AddMembersFlow
|
||||||
|
members={members}
|
||||||
|
title={l`Add members`}
|
||||||
|
onAddMembers={members => {
|
||||||
|
addGroupMembers({members})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Dialog.Outer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {Pressable, View} from 'react-native'
|
||||||
|
import {moderateProfile} from '@atproto/api'
|
||||||
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||||
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
|
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
|
import {type ConvoWithDetails} from '#/components/dms/util'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
import {MemberMenu} from './MemberMenu'
|
||||||
|
import {StatusBadge} from './StatusBadge'
|
||||||
|
import {SubtleHoverWrapper} from './SubtleHoverWrapper'
|
||||||
|
|
||||||
|
export function Member({
|
||||||
|
convo,
|
||||||
|
profile: profileUnshadowed,
|
||||||
|
status,
|
||||||
|
isOwner,
|
||||||
|
}: {
|
||||||
|
convo: ConvoWithDetails
|
||||||
|
profile: bsky.profile.AnyProfileView
|
||||||
|
status: 'owner' | 'standard' | 'invited'
|
||||||
|
isOwner: boolean
|
||||||
|
}) {
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
const profile = useProfileShadow(profileUnshadowed)
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const moderation = moderationOpts
|
||||||
|
? moderateProfile(profile, moderationOpts)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// TODO Render a skeleton here. -dsb
|
||||||
|
if (!moderation) return null
|
||||||
|
|
||||||
|
const isDeletedAccount = profile.handle === 'missing.invalid'
|
||||||
|
const displayName = isDeletedAccount
|
||||||
|
? l`Deleted Account`
|
||||||
|
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
|
||||||
|
|
||||||
|
const isSelf = currentAccount?.did === profile.did
|
||||||
|
let statusBadge: React.ReactNode | null = null
|
||||||
|
if (isSelf) {
|
||||||
|
if (status === 'owner') {
|
||||||
|
statusBadge = <StatusBadge label={l`Admin`} />
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statusBadge = (
|
||||||
|
<MemberMenu
|
||||||
|
convo={convo}
|
||||||
|
profile={profile}
|
||||||
|
displayName={displayName}
|
||||||
|
type={status}
|
||||||
|
isOwner={isOwner}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubtleHoverWrapper>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.justify_between, a.mx_xl]}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={l`View ${displayName}’s profile`}
|
||||||
|
accessibilityHint={l`Opens this member’s profile`}
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center]}
|
||||||
|
onPress={() => {
|
||||||
|
navigation.navigate('Profile', {name: profile.handle})
|
||||||
|
}}>
|
||||||
|
<PreviewableUserAvatar
|
||||||
|
profile={profile}
|
||||||
|
size={48}
|
||||||
|
moderation={moderation.ui('avatar')}
|
||||||
|
/>
|
||||||
|
<View style={[a.mx_sm]}>
|
||||||
|
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_xs,
|
||||||
|
{color: t.palette.contrast_500},
|
||||||
|
web(a.pt_2xs),
|
||||||
|
]}>
|
||||||
|
{sanitizeHandle(profile.handle, '@')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
<View>{statusBadge}</View>
|
||||||
|
</View>
|
||||||
|
</SubtleHoverWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import {useState} from 'react'
|
||||||
|
import {Pressable} from 'react-native'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
|
||||||
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {type Shadow} from '#/state/cache/types'
|
||||||
|
import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability'
|
||||||
|
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
|
||||||
|
import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group'
|
||||||
|
import {useProfileBlockMutationQueue} from '#/state/queries/profile'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {type ConvoWithDetails} from '#/components/dms/util'
|
||||||
|
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
|
||||||
|
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
|
||||||
|
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
|
||||||
|
import {
|
||||||
|
Person_Stroke2_Corner2_Rounded as PersonIcon,
|
||||||
|
PersonX_Stroke2_Corner0_Rounded as PersonXIcon,
|
||||||
|
} from '#/components/icons/Person'
|
||||||
|
import * as Menu from '#/components/Menu'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
import {BlockMemberPrompt} from './prompts'
|
||||||
|
import {StatusBadge} from './StatusBadge'
|
||||||
|
|
||||||
|
export function MemberMenu({
|
||||||
|
convo,
|
||||||
|
profile,
|
||||||
|
displayName,
|
||||||
|
type,
|
||||||
|
isOwner,
|
||||||
|
}: {
|
||||||
|
convo: ConvoWithDetails
|
||||||
|
profile: Shadow<bsky.profile.AnyProfileView>
|
||||||
|
type: 'owner' | 'standard' | 'invited'
|
||||||
|
displayName: string
|
||||||
|
isOwner: boolean
|
||||||
|
}) {
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const ax = useAnalytics()
|
||||||
|
|
||||||
|
const requireEmailVerification = useRequireEmailVerification()
|
||||||
|
|
||||||
|
const blockMemberPrompt = Prompt.usePromptControl()
|
||||||
|
|
||||||
|
const [menuDidOpen, setMenuDidOpen] = useState(false)
|
||||||
|
const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, {
|
||||||
|
enabled: menuDidOpen,
|
||||||
|
})
|
||||||
|
const {mutate: initiateConvo} = useGetConvoForMembers({
|
||||||
|
onSuccess: ({convo}) => {
|
||||||
|
ax.metric('chat:open', {logContext: 'ConvoSettings'})
|
||||||
|
navigation.navigate('MessagesConversation', {conversation: convo.id})
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Toast.show(l`Failed to create conversation`, {type: 'error'})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const convoId = convo.view.id
|
||||||
|
const {mutate: removeMembers} = useRemoveFromGroupChat(convoId, {
|
||||||
|
onError: e => {
|
||||||
|
logger.error('Failed to remove group chat member', {message: e})
|
||||||
|
Toast.show(l`Failed to remove group chat member`, {type: 'error'})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
|
||||||
|
|
||||||
|
const messageMember = () => {
|
||||||
|
if (!convoAvailability?.canChat) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (convoAvailability.convo) {
|
||||||
|
ax.metric('chat:open', {logContext: 'ConvoSettings'})
|
||||||
|
navigation.navigate('MessagesConversation', {
|
||||||
|
conversation: convoAvailability.convo.id,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
ax.metric('chat:create', {logContext: 'ConvoSettings'})
|
||||||
|
initiateConvo([profile.did])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMessageMember = requireEmailVerification(messageMember, {
|
||||||
|
instructions: [
|
||||||
|
<Trans key="message">
|
||||||
|
Before you can message another user, you must first verify your email.
|
||||||
|
</Trans>,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleBlockMember = async () => {
|
||||||
|
if (profile.viewer?.blocking) {
|
||||||
|
try {
|
||||||
|
await queueUnblock()
|
||||||
|
Toast.show(l({message: 'Account unblocked', context: 'toast'}))
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as Error
|
||||||
|
if (e?.name !== 'AbortError') {
|
||||||
|
logger.error('Failed to unblock account', {message: e})
|
||||||
|
Toast.show(l`There was an issue! ${e.toString()}`, {
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await queueBlock()
|
||||||
|
Toast.show(l({message: 'Account blocked', context: 'toast'}))
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as Error
|
||||||
|
if (e?.name !== 'AbortError') {
|
||||||
|
logger.error('Failed to block account', {message: e})
|
||||||
|
Toast.show(l`There was an issue! ${e.toString()}`, {
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canBlockMember = type === 'owner' || type === 'standard'
|
||||||
|
const canRemoveMember = isOwner && type !== 'invited'
|
||||||
|
// TODO Need to integrate this. -dsb
|
||||||
|
const canUninviteMember = false
|
||||||
|
// const canUninviteMember = isOwner && type === 'invited'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Menu.Root>
|
||||||
|
<Menu.Trigger label={l`Open chat member options for ${displayName}`}>
|
||||||
|
{({props, state, control: menuControl}) => {
|
||||||
|
const isActive =
|
||||||
|
state.hovered || state.pressed || menuControl.isOpen
|
||||||
|
const triggerProps = {
|
||||||
|
...props,
|
||||||
|
onPress: () => {
|
||||||
|
setMenuDidOpen(true)
|
||||||
|
props.onPress()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return type === 'owner' || type === 'invited' ? (
|
||||||
|
<StatusBadge
|
||||||
|
label={type === 'owner' ? l`Admin` : l`Invited`}
|
||||||
|
pressableProps={triggerProps}
|
||||||
|
style={[
|
||||||
|
isActive
|
||||||
|
? {
|
||||||
|
backgroundColor: t.palette.contrast_0,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Pressable
|
||||||
|
{...triggerProps}
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
a.p_sm,
|
||||||
|
isActive
|
||||||
|
? {
|
||||||
|
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 context="action">Message</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={MessageIcon} />
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Group>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Group>
|
||||||
|
{canBlockMember ? (
|
||||||
|
<Menu.Item
|
||||||
|
label={
|
||||||
|
profile.viewer?.blocking
|
||||||
|
? l`Unblock ${displayName}`
|
||||||
|
: l`Block ${displayName}`
|
||||||
|
}
|
||||||
|
onPress={
|
||||||
|
profile.viewer?.blocking
|
||||||
|
? handleBlockMember
|
||||||
|
: blockMemberPrompt.open
|
||||||
|
}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Block</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={PersonXIcon} />
|
||||||
|
</Menu.Item>
|
||||||
|
) : null}
|
||||||
|
{canRemoveMember ? (
|
||||||
|
<Menu.Item
|
||||||
|
label={l`Remove ${displayName} from this group chat`}
|
||||||
|
onPress={() => removeMembers({members: [profile.did]})}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Remove from chat</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
|
||||||
|
</Menu.Item>
|
||||||
|
) : null}
|
||||||
|
{canUninviteMember ? (
|
||||||
|
<Menu.Item
|
||||||
|
label={l`Uninvite ${displayName} from this group chat`}
|
||||||
|
// TODO Need to wire up the uninvite flow. -dsb
|
||||||
|
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()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
import {plural} from '@lingui/core/macro'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {InlineLinkText} from '#/components/Link'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {MEMBER_LIMIT} from './constants'
|
||||||
|
|
||||||
|
export function MembersAndRequests({
|
||||||
|
memberCount,
|
||||||
|
requestCount,
|
||||||
|
hasMoreRequests,
|
||||||
|
isOwner,
|
||||||
|
}: {
|
||||||
|
memberCount: number
|
||||||
|
requestCount: number
|
||||||
|
hasMoreRequests: boolean
|
||||||
|
isOwner: boolean
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_row, a.justify_between, a.mx_xl, a.mt_lg, a.mb_sm]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
|
<Text style={[a.text_lg, a.font_semi_bold, t.atoms.text]}>
|
||||||
|
<Trans>Members</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[a.text_xs, a.font_medium, {color: t.palette.contrast_500}]}>
|
||||||
|
{l({
|
||||||
|
message: `${memberCount}/${MEMBER_LIMIT}`,
|
||||||
|
comment:
|
||||||
|
'The number of group chat members out of the total number of permitted users.',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isOwner && requestCount > 0 ? (
|
||||||
|
<InlineLinkText
|
||||||
|
label={l`View incoming group chat requests`}
|
||||||
|
style={[a.text_sm, a.text_right, a.font_semi_bold]}
|
||||||
|
// TODO Need to implement this. -dsb
|
||||||
|
to="#">
|
||||||
|
{hasMoreRequests
|
||||||
|
? l({
|
||||||
|
message: `${requestCount}+ requests`,
|
||||||
|
comment:
|
||||||
|
'Displayed when there are more than 50 requests to join a group chat',
|
||||||
|
})
|
||||||
|
: l({
|
||||||
|
message: plural(requestCount, {
|
||||||
|
one: '# request',
|
||||||
|
other: '# requests',
|
||||||
|
}),
|
||||||
|
comment: 'The number of requests to join a group chat.',
|
||||||
|
})}
|
||||||
|
</InlineLinkText>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {type TriggerChildProps} from '#/components/Menu/types'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function StatusBadge({
|
||||||
|
label,
|
||||||
|
style,
|
||||||
|
pressableProps,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
pressableProps?: TriggerChildProps['props']
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const badgeStyle = [
|
||||||
|
a.rounded_xs,
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
{
|
||||||
|
paddingTop: 3,
|
||||||
|
paddingBottom: 3,
|
||||||
|
paddingLeft: 6,
|
||||||
|
paddingRight: 6,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]
|
||||||
|
|
||||||
|
const labelText = (
|
||||||
|
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (pressableProps) {
|
||||||
|
return (
|
||||||
|
<Pressable style={badgeStyle} {...pressableProps}>
|
||||||
|
{labelText}
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <View style={badgeStyle}>{labelText}</View>
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
|
import {SubtleHover} from '#/components/SubtleHover'
|
||||||
|
|
||||||
|
export function SubtleHoverWrapper({
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<unknown>) {
|
||||||
|
const {
|
||||||
|
state: hover,
|
||||||
|
onIn: onHoverIn,
|
||||||
|
onOut: onHoverOut,
|
||||||
|
} = useInteractionState()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
// Web-only
|
||||||
|
onPointerEnter={onHoverIn}
|
||||||
|
// Web-only
|
||||||
|
onPointerLeave={onHoverOut}
|
||||||
|
style={a.pointer}>
|
||||||
|
<SubtleHover hover={hover} />
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const ROW_SPACING = 20
|
||||||
|
export const MEMBER_LIMIT = 50
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
import {useState} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
import {StackActions, useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
|
||||||
|
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||||
|
import {
|
||||||
|
type CommonNavigatorParams,
|
||||||
|
type NativeStackScreenProps,
|
||||||
|
type NavigationProp,
|
||||||
|
} from '#/lib/routes/types'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {ConvoProvider, useConvo} from '#/state/messages/convo'
|
||||||
|
import {ConvoStatus} from '#/state/messages/convo/types'
|
||||||
|
import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name'
|
||||||
|
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
|
||||||
|
import {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests'
|
||||||
|
import {useMuteConvo} from '#/state/queries/messages/mute-conversation'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {List} from '#/view/com/util/List'
|
||||||
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
|
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||||
|
import {Button, type ButtonColor, ButtonIcon} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
|
||||||
|
import {Error} from '#/components/Error'
|
||||||
|
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
|
||||||
|
import {
|
||||||
|
Bell2_Stroke2_Corner0_Rounded as BellIcon,
|
||||||
|
Bell2Off_Stroke2_Corner0_Rounded as BellOffIcon,
|
||||||
|
} from '#/components/icons/Bell2'
|
||||||
|
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
|
||||||
|
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||||
|
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
|
||||||
|
import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig'
|
||||||
|
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
|
||||||
|
import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
|
||||||
|
import * as Layout from '#/components/Layout'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {IS_NATIVE} from '#/env'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
import {InviteLinkDialog} from '../components/InviteLinkDialog'
|
||||||
|
import {AddMembersLink} from './AddMembersLink'
|
||||||
|
import {ROW_SPACING} from './constants'
|
||||||
|
import {Member} from './Member'
|
||||||
|
import {MembersAndRequests} from './MembersAndRequests'
|
||||||
|
import {EditNamePrompt, LeaveChatPrompt, LockChatPrompt} from './prompts'
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
|
||||||
|
type Item =
|
||||||
|
| {
|
||||||
|
type: 'MEMBERS_AND_REQUESTS'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'ADD_MEMBERS_LINK'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'CHAT_MEMBER'
|
||||||
|
profile: bsky.profile.AnyProfileView
|
||||||
|
status: 'owner' | 'standard' | 'invited'
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<
|
||||||
|
CommonNavigatorParams,
|
||||||
|
'MessagesConversationSettings'
|
||||||
|
>
|
||||||
|
|
||||||
|
export function MessagesConversationSettingsScreen({route}: Props) {
|
||||||
|
const {gtTablet} = useBreakpoints()
|
||||||
|
|
||||||
|
const convoId = route.params.conversation
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout.Screen>
|
||||||
|
<Layout.Header.Outer>
|
||||||
|
<Layout.Header.BackButton />
|
||||||
|
<Layout.Header.Content align={gtTablet ? 'left' : 'platform'}>
|
||||||
|
<Layout.Header.TitleText>
|
||||||
|
<Trans>Group chat settings</Trans>
|
||||||
|
</Layout.Header.TitleText>
|
||||||
|
</Layout.Header.Content>
|
||||||
|
<Layout.Header.Slot />
|
||||||
|
</Layout.Header.Outer>
|
||||||
|
<ConvoProvider key={convoId} convoId={convoId}>
|
||||||
|
<SettingsInner convoId={convoId} />
|
||||||
|
</ConvoProvider>
|
||||||
|
</Layout.Screen>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyExtractor(item: Item) {
|
||||||
|
return item.type === 'CHAT_MEMBER' ? item.profile.did : item.type
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsInner({convoId}: {convoId: string}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
const initialNumToRender = useInitialNumToRender({minItemHeight: 68})
|
||||||
|
const bottomBarOffset = useBottomBarOffset()
|
||||||
|
|
||||||
|
const convoState = useConvo()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
|
const convo = convoState.convo
|
||||||
|
? parseConvoView(convoState.convo, currentAccount?.did)
|
||||||
|
: null
|
||||||
|
const primaryMember = convo?.primaryMember
|
||||||
|
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({
|
||||||
|
convoId,
|
||||||
|
enabled: isOwner,
|
||||||
|
})
|
||||||
|
const requestCount =
|
||||||
|
joinRequestsData?.pages.reduce(
|
||||||
|
(sum, page) => sum + page.requests.length,
|
||||||
|
0,
|
||||||
|
) ?? 0
|
||||||
|
|
||||||
|
const items: Item[] = [
|
||||||
|
{
|
||||||
|
type: 'MEMBERS_AND_REQUESTS',
|
||||||
|
},
|
||||||
|
...(isOwner ? [{type: 'ADD_MEMBERS_LINK'} as const] : []),
|
||||||
|
...[...data]
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aIsOwner = a.did === primaryMember?.did
|
||||||
|
const bIsOwner = b.did === primaryMember?.did
|
||||||
|
const aIsSelf = a.did === currentAccount?.did
|
||||||
|
const bIsSelf = b.did === currentAccount?.did
|
||||||
|
if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1
|
||||||
|
if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
.map(
|
||||||
|
(profile): Item => ({
|
||||||
|
type: 'CHAT_MEMBER',
|
||||||
|
profile,
|
||||||
|
status:
|
||||||
|
primaryMember?.did === profile.did
|
||||||
|
? 'owner'
|
||||||
|
: invites.includes(profile.did)
|
||||||
|
? 'invited'
|
||||||
|
: 'standard',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
function renderItem({item}: {item: Item}) {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'MEMBERS_AND_REQUESTS':
|
||||||
|
return (
|
||||||
|
<MembersAndRequests
|
||||||
|
memberCount={data.length}
|
||||||
|
requestCount={requestCount}
|
||||||
|
hasMoreRequests={!!hasMoreRequests}
|
||||||
|
isOwner={isOwner}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'ADD_MEMBERS_LINK':
|
||||||
|
return convo ? (
|
||||||
|
<AddMembersLink
|
||||||
|
convo={convo}
|
||||||
|
members={data.map(profile => profile.did)}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
case 'CHAT_MEMBER':
|
||||||
|
return convo ? (
|
||||||
|
<Member
|
||||||
|
convo={convo}
|
||||||
|
profile={item.profile}
|
||||||
|
status={item.status}
|
||||||
|
isOwner={isOwner}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (convoState.status === ConvoStatus.Error) {
|
||||||
|
return (
|
||||||
|
<Error
|
||||||
|
title={l`Something went wrong`}
|
||||||
|
message={l`We couldn’t load this conversation’s settings`}
|
||||||
|
onRetry={() => convoState.error.retry()}
|
||||||
|
sideBorders={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
data={items}
|
||||||
|
contentContainerStyle={{
|
||||||
|
gap: ROW_SPACING,
|
||||||
|
paddingBottom: bottomBarOffset + ROW_SPACING,
|
||||||
|
}}
|
||||||
|
desktopFixedHeight
|
||||||
|
initialNumToRender={initialNumToRender}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
ListHeaderComponent={
|
||||||
|
convo?.kind === 'group' ? (
|
||||||
|
<SettingsHeader convo={convo} isOwner={isOwner} />
|
||||||
|
) : (
|
||||||
|
<SettingsHeaderPlaceholder />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderItem={renderItem}
|
||||||
|
sideBorders={false}
|
||||||
|
windowSize={11}
|
||||||
|
// TODO Paginate on relatedProfiles. -dsb
|
||||||
|
onEndReached={() => {}}
|
||||||
|
onEndReachedThreshold={IS_NATIVE ? 1.5 : 0}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsHeader({
|
||||||
|
convo,
|
||||||
|
isOwner,
|
||||||
|
}: {
|
||||||
|
convo: Extract<ConvoWithDetails, {kind: 'group'}>
|
||||||
|
isOwner: boolean
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
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)
|
||||||
|
logger.error('Failed to edit group chat name', {message: e})
|
||||||
|
Toast.show(l`Failed to edit group chat name`, {
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const {mutate: muteConvo} = useMuteConvo(convo.view.id, {
|
||||||
|
onSuccess: data => {
|
||||||
|
if (data.convo.muted) {
|
||||||
|
Toast.show(l({message: 'Group chat muted', context: 'toast'}))
|
||||||
|
} else {
|
||||||
|
Toast.show(l({message: 'Group chat unmuted', context: 'toast'}))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: e => {
|
||||||
|
logger.error('Failed to mute group chat', {message: e})
|
||||||
|
Toast.show(l`Failed to mute group chat`, {
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
// Settings > Chat > Chat list
|
||||||
|
navigation.dispatch(StackActions.pop(2))
|
||||||
|
},
|
||||||
|
onError: e => {
|
||||||
|
logger.error('Failed to leave group chat', {message: e})
|
||||||
|
Toast.show(l({message: 'Failed to leave group chat', context: 'toast'}), {
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const inviteLinkDialog = Dialog.useDialogControl()
|
||||||
|
const editNamePrompt = Prompt.usePromptControl()
|
||||||
|
const lockChatPrompt = Prompt.usePromptControl()
|
||||||
|
const leaveChatPrompt = Prompt.usePromptControl()
|
||||||
|
|
||||||
|
const handleToggleMute = () => {
|
||||||
|
muteConvo({mute: !convo.view.muted})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO Need to implement this when the backend is ready. -dsb
|
||||||
|
const handleReportChat = () => {}
|
||||||
|
|
||||||
|
const handlePromptName = () => {
|
||||||
|
setNewGroupName(groupName)
|
||||||
|
editNamePrompt.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditName = () => {
|
||||||
|
editGroupName({name: newGroupName})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmLock = () => {
|
||||||
|
setIsLocked(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnlock = () => {
|
||||||
|
setIsLocked(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO The creation date doesn't exist yet. -dsb
|
||||||
|
const showCreatedAt = false
|
||||||
|
const createdAt = new Date()
|
||||||
|
|
||||||
|
// TODO Need to implement this. -dsb
|
||||||
|
const canLockGroupChat = false
|
||||||
|
// const canLockGroupChat = isOwner
|
||||||
|
|
||||||
|
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={convo.members} />
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_2xl,
|
||||||
|
a.font_bold,
|
||||||
|
a.text_center,
|
||||||
|
a.pt_lg,
|
||||||
|
t.atoms.text,
|
||||||
|
]}>
|
||||||
|
{groupName}
|
||||||
|
</Text>
|
||||||
|
{showCreatedAt ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.text_center,
|
||||||
|
a.pt_xs,
|
||||||
|
a.px_xl,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
]}>
|
||||||
|
<Trans>Created {dateFormatter.format(createdAt)}</Trans>
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
a.gap_2xl,
|
||||||
|
a.pt_2xl,
|
||||||
|
]}>
|
||||||
|
<SettingsButton
|
||||||
|
color={convo.view.muted ? 'negative_subtle' : 'secondary'}
|
||||||
|
icon={convo.view.muted ? BellOffIcon : BellIcon}
|
||||||
|
label={
|
||||||
|
convo.view.muted
|
||||||
|
? l`Unmute this group chat`
|
||||||
|
: l`Mute this group chat`
|
||||||
|
}
|
||||||
|
text={convo.view.muted ? l`Muted` : l`Mute`}
|
||||||
|
onPress={handleToggleMute}
|
||||||
|
/>
|
||||||
|
{isOwner ? (
|
||||||
|
<SettingsButton
|
||||||
|
icon={EditIcon}
|
||||||
|
label={l`Edit this group chat’s name`}
|
||||||
|
text={l`Edit name`}
|
||||||
|
onPress={handlePromptName}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{isJoinLinkEnabled ? (
|
||||||
|
<SettingsButton
|
||||||
|
icon={ChainLinkIcon}
|
||||||
|
label={
|
||||||
|
isOwner
|
||||||
|
? l`Create or modify an invite link for this group chat`
|
||||||
|
: l`View the invite link for this group chat`
|
||||||
|
}
|
||||||
|
text={l`Invite link`}
|
||||||
|
onPress={inviteLinkDialog.open}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{canLockGroupChat ? (
|
||||||
|
<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 : lockChatPrompt.open}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{isOwner ? null : isReportLinkEnabled ? (
|
||||||
|
<SettingsButton
|
||||||
|
color="secondary"
|
||||||
|
icon={FlagIcon}
|
||||||
|
label={l`Report this group chat`}
|
||||||
|
text={l`Report`}
|
||||||
|
onPress={handleReportChat}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{isOwner ? null : (
|
||||||
|
<SettingsButton
|
||||||
|
color="secondary"
|
||||||
|
icon={ArrowBoxLeftIcon}
|
||||||
|
label={l`Leave this group chat`}
|
||||||
|
text={l`Leave`}
|
||||||
|
onPress={leaveChatPrompt.open}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<EditNamePrompt
|
||||||
|
control={editNamePrompt}
|
||||||
|
value={newGroupName}
|
||||||
|
onChangeText={setNewGroupName}
|
||||||
|
onConfirm={handleEditName}
|
||||||
|
/>
|
||||||
|
<InviteLinkDialog
|
||||||
|
convo={convo}
|
||||||
|
control={inviteLinkDialog}
|
||||||
|
isOwner={isOwner}
|
||||||
|
/>
|
||||||
|
<LockChatPrompt control={lockChatPrompt} onConfirm={handleConfirmLock} />
|
||||||
|
<LeaveChatPrompt
|
||||||
|
control={leaveChatPrompt}
|
||||||
|
groupName={groupName}
|
||||||
|
onConfirm={leaveConvo}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsHeaderPlaceholder() {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
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]}>
|
||||||
|
…
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.text_center,
|
||||||
|
a.pt_xs,
|
||||||
|
a.px_xl,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
]}>
|
||||||
|
…
|
||||||
|
</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,
|
||||||
|
label,
|
||||||
|
text,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
color?: ButtonColor
|
||||||
|
icon: React.ComponentType<SVGIconProps>
|
||||||
|
label: string
|
||||||
|
text: string
|
||||||
|
onPress: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.align_center]}>
|
||||||
|
<Button
|
||||||
|
color={color}
|
||||||
|
size="large"
|
||||||
|
shape="round"
|
||||||
|
label={label}
|
||||||
|
onPress={onPress}>
|
||||||
|
<ButtonIcon icon={icon} size="md" />
|
||||||
|
</Button>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[
|
||||||
|
a.text_2xs,
|
||||||
|
a.font_medium,
|
||||||
|
a.text_center,
|
||||||
|
a.pt_xs,
|
||||||
|
t.atoms.text,
|
||||||
|
]}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
]}>
|
||||||
|
…
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import type * as Dialog from '#/components/Dialog'
|
||||||
|
import * as TextField from '#/components/forms/TextField'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
|
||||||
|
export function EditNamePrompt({
|
||||||
|
control,
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
control: Dialog.DialogOuterProps['control']
|
||||||
|
value: string
|
||||||
|
onChangeText: (value: string) => void
|
||||||
|
onConfirm: () => void
|
||||||
|
}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Prompt.Outer control={control}>
|
||||||
|
<>
|
||||||
|
<Prompt.Content>
|
||||||
|
<Prompt.TitleText>
|
||||||
|
<Trans>Edit group name</Trans>
|
||||||
|
</Prompt.TitleText>
|
||||||
|
<View style={[a.my_sm]}>
|
||||||
|
<TextField.Root isInvalid={false}>
|
||||||
|
<TextField.Input
|
||||||
|
label={l`Edit group name`}
|
||||||
|
placeholder={l`Group name`}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoFocus
|
||||||
|
onSubmitEditing={onConfirm}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
</View>
|
||||||
|
</Prompt.Content>
|
||||||
|
<Prompt.Actions>
|
||||||
|
<Prompt.Action cta={l`Save`} onPress={onConfirm} />
|
||||||
|
<Prompt.Cancel />
|
||||||
|
</Prompt.Actions>
|
||||||
|
</>
|
||||||
|
</Prompt.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LockChatPrompt({
|
||||||
|
control,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
control: Dialog.DialogOuterProps['control']
|
||||||
|
onConfirm: () => void
|
||||||
|
}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Prompt.Basic
|
||||||
|
control={control}
|
||||||
|
title={l`Lock group chat?`}
|
||||||
|
description={l`Members can still read chat history but can’t send new messages.`}
|
||||||
|
confirmButtonCta={l`Lock group chat`}
|
||||||
|
cancelButtonCta={l`Cancel`}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeaveChatPrompt({
|
||||||
|
control,
|
||||||
|
groupName,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
control: Dialog.DialogOuterProps['control']
|
||||||
|
groupName: string
|
||||||
|
onConfirm: () => void
|
||||||
|
}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Prompt.Basic
|
||||||
|
control={control}
|
||||||
|
title={l`Are you sure you want to leave ${groupName}?`}
|
||||||
|
description={l`You won’t be able to rejoin unless you’re invited.`}
|
||||||
|
confirmButtonCta={l`Leave group chat`}
|
||||||
|
confirmButtonColor="negative"
|
||||||
|
cancelButtonCta={l`Cancel`}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user