Group Clops Feature Branch (#10360)
Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Co-authored-by: Samuel Newman <mozzius@protonmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useAddGroupMembers} from '#/state/queries/messages/add-group-members'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {AddMembersFlow} from '#/components/dms/AddMembersFlow'
|
||||
import {type ConvoWithDetails} from '#/components/dms/util'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function AddMembersLink({
|
||||
convo,
|
||||
members,
|
||||
}: {
|
||||
convo: ConvoWithDetails
|
||||
members: string[]
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const addMembersControl = Dialog.useDialogControl()
|
||||
|
||||
const convoId = convo.view.id
|
||||
const {mutate: addGroupMembers, isPending: isAddPending} = useAddGroupMembers(
|
||||
convoId,
|
||||
{
|
||||
onSuccess: () => {
|
||||
addMembersControl.close()
|
||||
},
|
||||
onError: e => {
|
||||
logger.error('Failed to add group chat members', {message: e})
|
||||
Toast.show(l`Failed to add members`, {type: 'error'})
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disabled={isAddPending}
|
||||
label={l`Add members`}
|
||||
onPress={addMembersControl.open}>
|
||||
{({interacting}) => (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.px_xl,
|
||||
a.py_sm,
|
||||
interacting ? [t.atoms.bg_contrast_25] : [],
|
||||
]}>
|
||||
<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,
|
||||
interacting
|
||||
? 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
|
||||
numberOfLines={1}
|
||||
style={[a.text_md, a.font_semi_bold, a.mx_sm, t.atoms.text]}>
|
||||
<Trans>Add members</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
{isAddPending ? (
|
||||
<Loader size="md" />
|
||||
) : (
|
||||
<ChevronIcon style={[t.atoms.text_contrast_medium]} size="md" />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Dialog.Outer
|
||||
control={addMembersControl}
|
||||
testID="addChatMembersDialog"
|
||||
nativeOptions={{fullHeight: true}}>
|
||||
<Dialog.Handle />
|
||||
<AddMembersFlow
|
||||
members={members}
|
||||
title={l`Add members`}
|
||||
onAddMembers={(members, profiles) => {
|
||||
addGroupMembers({members, profiles})
|
||||
}}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import {View} from 'react-native'
|
||||
import {moderateProfile} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {
|
||||
type ConvoWithDetails,
|
||||
type GroupConvoMember,
|
||||
} from '#/components/dms/util'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {MemberMenu} from './MemberMenu'
|
||||
import {StatusBadge} from './StatusBadge'
|
||||
import {SubtleHoverWrapper} from './SubtleHoverWrapper'
|
||||
|
||||
const outerStyles = [a.px_xl, a.py_sm, a.flex_row, a.align_center, a.gap_sm]
|
||||
|
||||
export function Member({
|
||||
convo,
|
||||
profile: profileUnshadowed,
|
||||
status,
|
||||
isOwner,
|
||||
}: {
|
||||
convo: ConvoWithDetails
|
||||
profile: GroupConvoMember
|
||||
status: 'owner' | 'standard' | 'invited'
|
||||
isOwner: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const {currentAccount} = useSession()
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
if (!moderationOpts) {
|
||||
return <MemberPlaceholder />
|
||||
}
|
||||
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
|
||||
const isDeletedAccount = profile.handle === 'missing.invalid'
|
||||
const displayName = isDeletedAccount
|
||||
? l`Deleted Account`
|
||||
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
|
||||
const isProfileOwner = profile.did === convo.primaryMember.did
|
||||
const isSelf = currentAccount?.did === profile.did
|
||||
let statusBadge: React.ReactNode | null = null
|
||||
if (isSelf) {
|
||||
if (status === 'owner') {
|
||||
statusBadge = <StatusBadge label={l`Admin`} />
|
||||
}
|
||||
} else {
|
||||
statusBadge = (
|
||||
<MemberMenu
|
||||
convo={convo}
|
||||
profile={profile}
|
||||
displayName={displayName}
|
||||
type={status}
|
||||
isOwner={isOwner}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const joinedReason = profile.kind?.addedBy
|
||||
? l`Added by ${createSanitizedDisplayName(
|
||||
profile.kind.addedBy,
|
||||
true,
|
||||
moderateProfile(profile.kind.addedBy, moderationOpts).ui('displayName'),
|
||||
)}`
|
||||
: `Added by invite link`
|
||||
|
||||
return (
|
||||
<SubtleHoverWrapper>
|
||||
<View style={outerStyles}>
|
||||
<ProfileCard.Link profile={profile} style={[a.flex_1]}>
|
||||
<ProfileCard.Outer>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
size={48}
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<View style={[a.flex_1]}>
|
||||
<ProfileCard.Name
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.Handle
|
||||
profile={profile}
|
||||
textStyle={[a.text_xs, native({top: -1})]}
|
||||
/>
|
||||
{!isProfileOwner && (
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
web(a.pt_2xs),
|
||||
]}>
|
||||
{joinedReason}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ProfileCard.Header>
|
||||
</ProfileCard.Outer>
|
||||
</ProfileCard.Link>
|
||||
{statusBadge}
|
||||
</View>
|
||||
</SubtleHoverWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export function MemberPlaceholder() {
|
||||
return (
|
||||
<View style={outerStyles}>
|
||||
<ProfileCard.Outer>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.AvatarPlaceholder size={48} />
|
||||
<ProfileCard.NameAndHandlePlaceholder />
|
||||
</ProfileCard.Header>
|
||||
</ProfileCard.Outer>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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,65 @@
|
||||
import {View} from 'react-native'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {MEMBER_LIMIT} from './constants'
|
||||
|
||||
export function MembersAndRequests({
|
||||
memberCount,
|
||||
requestCount,
|
||||
hasMoreRequests,
|
||||
isOwner,
|
||||
}: {
|
||||
memberCount: number
|
||||
requestCount: number
|
||||
hasMoreRequests: boolean
|
||||
isOwner: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.justify_between, a.px_xl, a.pt_xl, a.pb_sm]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Text style={[a.text_lg, a.font_semi_bold, t.atoms.text]}>
|
||||
<Trans>Members</Trans>
|
||||
</Text>
|
||||
<View
|
||||
style={[a.px_xs, a.py_2xs, t.atoms.bg_contrast_50, a.rounded_full]}>
|
||||
<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>
|
||||
</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 @@
|
||||
export const MEMBER_LIMIT = 50
|
||||
@@ -0,0 +1,637 @@
|
||||
import {useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {StackActions, useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
import {
|
||||
type CommonNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
type NavigationProp,
|
||||
} from '#/lib/routes/types'
|
||||
import {logger} from '#/logger'
|
||||
import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo'
|
||||
import {ConvoStatus} from '#/state/messages/convo/types'
|
||||
import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name'
|
||||
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
|
||||
import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members'
|
||||
import {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests'
|
||||
import {useLockConvo} from '#/state/queries/messages/lock-conversation'
|
||||
import {useMuteConvo} from '#/state/queries/messages/mute-conversation'
|
||||
import {useSession} from '#/state/session'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||
import {Button, type ButtonColor, ButtonIcon} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {
|
||||
type ConvoWithDetails,
|
||||
type GroupConvoMember,
|
||||
} from '#/components/dms/util'
|
||||
import {Error} from '#/components/Error'
|
||||
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
|
||||
import {
|
||||
Bell2_Stroke2_Corner0_Rounded as BellIcon,
|
||||
Bell2Off_Stroke2_Corner0_Rounded as BellOffIcon,
|
||||
} from '#/components/icons/Bell2'
|
||||
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
|
||||
import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig'
|
||||
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
|
||||
import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {InviteLinkDialog} from '../components/InviteLinkDialog'
|
||||
import {AddMembersLink} from './AddMembersLink'
|
||||
import {Member, MemberPlaceholder} from './Member'
|
||||
import {MembersAndRequests} from './MembersAndRequests'
|
||||
import {EditNamePrompt, LeaveChatPrompt, LockChatPrompt} from './prompts'
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
type Item =
|
||||
| {type: 'MEMBERS_AND_REQUESTS'; key: string}
|
||||
| {type: 'ADD_MEMBERS_LINK'; key: string}
|
||||
| {
|
||||
type: 'CHAT_MEMBER'
|
||||
key: string
|
||||
profile: GroupConvoMember
|
||||
status: 'owner' | 'standard' | 'invited'
|
||||
}
|
||||
| {
|
||||
type: 'CHAT_MEMBER_PLACEHOLDER'
|
||||
key: string
|
||||
}
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
CommonNavigatorParams,
|
||||
'MessagesConversationSettings'
|
||||
>
|
||||
|
||||
export function MessagesConversationSettingsScreen({route}: Props) {
|
||||
const {gtTablet} = useBreakpoints()
|
||||
|
||||
const convoId = route.params.conversation
|
||||
|
||||
return (
|
||||
<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 />
|
||||
</ConvoProvider>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsInner() {
|
||||
const {t: l} = useLingui()
|
||||
const convoState = useConvo()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isConvoActive(convoState)) {
|
||||
return (
|
||||
<Layout.Content>
|
||||
<View style={[a.align_center, a.justify_center, a.flex_1, a.py_4xl]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
</Layout.Content>
|
||||
)
|
||||
}
|
||||
|
||||
if (convoState.convo?.kind !== 'group') {
|
||||
return (
|
||||
<Error
|
||||
title={l`Wrong kind of conversation`}
|
||||
message={l`This screen is only available for group conversations.`}
|
||||
onGoBack={() => {
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.replace('Messages', {animation: 'pop'})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <GroupSettings convo={convoState.convo} />
|
||||
}
|
||||
|
||||
function keyExtractor(item: Item) {
|
||||
return item.key
|
||||
}
|
||||
|
||||
function GroupSettings({
|
||||
convo,
|
||||
}: {
|
||||
convo: Extract<ConvoWithDetails, {kind: 'group'}>
|
||||
}) {
|
||||
const initialNumToRender = useInitialNumToRender({minItemHeight: 68})
|
||||
const bottomBarOffset = useBottomBarOffset()
|
||||
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const primaryMember = convo?.primaryMember
|
||||
const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did
|
||||
|
||||
const {data: memberListData = [], isPending} = useListConvoMembersQuery({
|
||||
convoId: convo.view.id,
|
||||
placeholderData: convo?.members,
|
||||
})
|
||||
|
||||
// TODO Need this data in order to populate this array. -dsb
|
||||
const invites: string[] = []
|
||||
|
||||
const {data: joinRequestsData, hasNextPage: hasMoreRequests} =
|
||||
useListJoinRequestsQuery({
|
||||
convoId: convo.view.id,
|
||||
enabled: isOwner,
|
||||
})
|
||||
const requestCount =
|
||||
joinRequestsData?.pages.reduce(
|
||||
(sum, page) => sum + page.requests.length,
|
||||
0,
|
||||
) ?? 0
|
||||
|
||||
const items: Item[] = [
|
||||
{
|
||||
type: 'MEMBERS_AND_REQUESTS',
|
||||
key: 'members-and-requests',
|
||||
},
|
||||
...(isOwner
|
||||
? [{type: 'ADD_MEMBERS_LINK', key: 'add-members-link'} as const]
|
||||
: []),
|
||||
]
|
||||
if (isPending) {
|
||||
// should never be pending if we correctly set the query cache data
|
||||
Array.from({length: 5}).forEach((_, i) =>
|
||||
items.push({
|
||||
type: 'CHAT_MEMBER_PLACEHOLDER',
|
||||
key: `chat-member-placeholder-${i}`,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
items.push(
|
||||
...memberListData
|
||||
.sort((a, b) => {
|
||||
const aIsOwner = a.did === primaryMember?.did
|
||||
const bIsOwner = b.did === primaryMember?.did
|
||||
const aIsSelf = a.did === currentAccount?.did
|
||||
const bIsSelf = b.did === currentAccount?.did
|
||||
if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1
|
||||
if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1
|
||||
return 0
|
||||
})
|
||||
.map(
|
||||
(profile): Item => ({
|
||||
type: 'CHAT_MEMBER',
|
||||
key: profile.did,
|
||||
profile: profile as GroupConvoMember,
|
||||
status:
|
||||
primaryMember?.did === profile.did
|
||||
? 'owner'
|
||||
: invites.includes(profile.did)
|
||||
? 'invited'
|
||||
: 'standard',
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function renderItem({item}: {item: Item}) {
|
||||
switch (item.type) {
|
||||
case 'MEMBERS_AND_REQUESTS':
|
||||
return (
|
||||
<MembersAndRequests
|
||||
memberCount={convo.details.memberCount}
|
||||
requestCount={requestCount}
|
||||
hasMoreRequests={!!hasMoreRequests}
|
||||
isOwner={isOwner}
|
||||
/>
|
||||
)
|
||||
case 'ADD_MEMBERS_LINK':
|
||||
return convo ? (
|
||||
<AddMembersLink
|
||||
convo={convo}
|
||||
members={memberListData.map(profile => profile.did)}
|
||||
/>
|
||||
) : null
|
||||
case 'CHAT_MEMBER':
|
||||
return convo ? (
|
||||
<Member
|
||||
convo={convo}
|
||||
profile={item.profile}
|
||||
status={item.status}
|
||||
isOwner={isOwner}
|
||||
/>
|
||||
) : null
|
||||
case 'CHAT_MEMBER_PLACEHOLDER':
|
||||
return <MemberPlaceholder />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
data={items}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: bottomBarOffset + a.pb_xl.paddingBottom,
|
||||
}}
|
||||
desktopFixedHeight
|
||||
initialNumToRender={initialNumToRender}
|
||||
keyExtractor={keyExtractor}
|
||||
ListHeaderComponent={
|
||||
convo?.kind === 'group' ? (
|
||||
<SettingsHeader convo={convo} isOwner={isOwner} />
|
||||
) : (
|
||||
<SettingsHeaderPlaceholder />
|
||||
)
|
||||
}
|
||||
renderItem={renderItem}
|
||||
sideBorders={false}
|
||||
windowSize={11}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 lockStatus = convo.details.lockStatus
|
||||
|
||||
// TODO Enable this once the feature is working end-to-end. -dsb
|
||||
// const {joinLink} = convo.details
|
||||
const isJoinLinkEnabled = false
|
||||
// const isJoinLinkEnabled =
|
||||
// isOwner || (!isOwner && joinLink?.enabledStatus === 'enabled')
|
||||
|
||||
// TODO Enable this once the feature is working end-to-end. -dsb
|
||||
const isReportLinkEnabled = false
|
||||
|
||||
const {mutate: editGroupName} = useEditGroupChatName(convo.view.id, {
|
||||
onError: e => {
|
||||
setNewGroupName(groupName)
|
||||
logger.error('Failed to edit group chat name', {message: e})
|
||||
Toast.show(l`Failed to edit group chat name`, {type: 'error'})
|
||||
},
|
||||
})
|
||||
|
||||
const {mutate: muteConvo} = useMuteConvo(convo.view.id, {
|
||||
onSuccess: data => {
|
||||
if (data.convo.muted) {
|
||||
Toast.show(l({message: 'Group chat muted', context: 'toast'}))
|
||||
} else {
|
||||
Toast.show(l({message: 'Group chat unmuted', context: 'toast'}))
|
||||
}
|
||||
},
|
||||
onError: e => {
|
||||
logger.error('Failed to mute group chat', {message: e})
|
||||
Toast.show(l`Failed to mute group chat`, {type: 'error'})
|
||||
},
|
||||
})
|
||||
|
||||
const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, {
|
||||
onSuccess: () => {
|
||||
// Settings > Chat > Chat list
|
||||
navigation.dispatch(StackActions.pop(2))
|
||||
},
|
||||
onError: e => {
|
||||
logger.error('Failed to leave group chat', {message: e})
|
||||
Toast.show(l({message: 'Failed to leave group chat', context: 'toast'}), {
|
||||
type: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const {mutate: lockConvo} = useLockConvo(convo.view.id, {
|
||||
onSuccess: data => {
|
||||
const kind = data.convo.kind as ChatBskyConvoDefs.GroupConvo
|
||||
if (kind.lockStatus === 'locked') {
|
||||
Toast.show(l({message: 'Group chat locked', context: 'toast'}))
|
||||
} else {
|
||||
Toast.show(l({message: 'Group chat unlocked', context: 'toast'}))
|
||||
}
|
||||
},
|
||||
onError: (e, {lock}) => {
|
||||
if (lock) {
|
||||
logger.error('Failed to lock group chat', {message: e})
|
||||
Toast.show(l`Failed to lock group chat`, {type: 'error'})
|
||||
} else {
|
||||
logger.error('Failed to unlock group chat', {message: e})
|
||||
Toast.show(l`Failed to unlock group chat`, {type: 'error'})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const inviteLinkDialog = Dialog.useDialogControl()
|
||||
const editNamePrompt = Prompt.usePromptControl()
|
||||
const lockChatPrompt = Prompt.usePromptControl()
|
||||
const leaveChatPrompt = Prompt.usePromptControl()
|
||||
|
||||
const handleToggleMute = () => {
|
||||
muteConvo({mute: !convo.view.muted})
|
||||
}
|
||||
|
||||
// TODO Need to implement this when the backend is ready. -dsb
|
||||
const handleReportChat = () => {}
|
||||
|
||||
const handlePromptName = () => {
|
||||
setNewGroupName(groupName)
|
||||
editNamePrompt.open()
|
||||
}
|
||||
|
||||
const handleEditName = () => {
|
||||
editGroupName({name: newGroupName})
|
||||
}
|
||||
|
||||
const handleConfirmLock = () => {
|
||||
lockConvo({lock: true})
|
||||
}
|
||||
|
||||
const handleUnlock = () => {
|
||||
lockConvo({lock: false})
|
||||
}
|
||||
|
||||
// TODO The creation date doesn't exist yet. -dsb
|
||||
const showCreatedAt = false
|
||||
const createdAt = new Date()
|
||||
|
||||
const canLockGroupChat = isOwner && lockStatus !== 'locked-permanently'
|
||||
|
||||
return (
|
||||
<>
|
||||
<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={lockStatus === 'locked' ? 'negative_subtle' : 'secondary'}
|
||||
icon={LockIcon}
|
||||
label={
|
||||
lockStatus === 'locked'
|
||||
? l`Unlock this group chat`
|
||||
: l`Lock this group chat`
|
||||
}
|
||||
text={lockStatus === 'locked' ? l`Locked` : l`Lock`}
|
||||
onPress={
|
||||
lockStatus === 'locked' ? 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',
|
||||
disabled,
|
||||
icon,
|
||||
label,
|
||||
text,
|
||||
onPress,
|
||||
}: {
|
||||
color?: ButtonColor
|
||||
disabled?: boolean
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
label: string
|
||||
text: string
|
||||
onPress: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.align_center]}>
|
||||
<Button
|
||||
color={color}
|
||||
disabled={disabled}
|
||||
size="large"
|
||||
shape="round"
|
||||
label={label}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
{
|
||||
width: 48,
|
||||
height: 48,
|
||||
},
|
||||
]}>
|
||||
<ButtonIcon icon={icon} size="md" />
|
||||
</Button>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.font_medium,
|
||||
a.text_center,
|
||||
a.pt_xs,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsButtonPlaceholder() {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<View style={[a.align_center]}>
|
||||
<Button color="secondary" size="large" shape="round" label={l`Loading…`}>
|
||||
<ButtonIcon icon={EllipsisIcon} size="md" />
|
||||
</Button>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.text_xs,
|
||||
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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -298,7 +298,10 @@ function BaseChatItem({
|
||||
|
||||
// System message
|
||||
if (ChatBskyConvoDefs.isSystemMessageView(convo.lastMessage)) {
|
||||
const info = getSystemMessageInfo(convo.lastMessage.data, convo.members)
|
||||
const info = getSystemMessageInfo(
|
||||
convo.lastMessage.data,
|
||||
new Map(convo.members.map(m => [m.did, m])),
|
||||
)
|
||||
if (info) {
|
||||
lastMessage = i18n._(info.message)
|
||||
lastMessageSentAt = convo.lastMessage.sentAt
|
||||
|
||||
@@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {type ActiveConvoStates} from '#/state/messages/convo'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
||||
import {KnownFollowers} from '#/components/KnownFollowers'
|
||||
@@ -16,16 +15,15 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {currentAccount} = useSession()
|
||||
const leaveConvoControl = usePromptControl()
|
||||
|
||||
const onAcceptChat = useCallback(() => {
|
||||
convoState.markConvoAccepted()
|
||||
}, [convoState])
|
||||
|
||||
const otherUser = convoState.recipients.find(
|
||||
user => user.did !== currentAccount?.did,
|
||||
)
|
||||
// either the other person, or the chat owner
|
||||
// if we ever allow someone other than the owner to invite people, this will need to change
|
||||
const otherUser = convoState.convo.primaryMember
|
||||
|
||||
if (!moderationOpts) {
|
||||
return null
|
||||
@@ -44,7 +42,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
|
||||
{otherUser && (
|
||||
<RejectMenu
|
||||
label={_(msg`Block or report`)}
|
||||
convo={convoState.convo}
|
||||
convo={convoState.convo.view}
|
||||
profile={otherUser}
|
||||
color="negative_subtle"
|
||||
size="small"
|
||||
@@ -53,14 +51,14 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
|
||||
)}
|
||||
<DeleteChatButton
|
||||
label={_(msg`Delete`)}
|
||||
convo={convoState.convo}
|
||||
convo={convoState.convo.view}
|
||||
color="secondary"
|
||||
size="small"
|
||||
currentScreen="conversation"
|
||||
onPress={leaveConvoControl.open}
|
||||
/>
|
||||
<LeaveConvoPrompt
|
||||
convoId={convoState.convo.id}
|
||||
convoId={convoState.convo.view.id}
|
||||
control={leaveConvoControl}
|
||||
currentScreen="conversation"
|
||||
hasMessages={false}
|
||||
@@ -69,7 +67,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
|
||||
<View style={[a.w_full, a.flex_row]}>
|
||||
<AcceptChatButton
|
||||
onAcceptConvo={onAcceptChat}
|
||||
convo={convoState.convo}
|
||||
convo={convoState.convo.view}
|
||||
color="primary_subtle"
|
||||
size="small"
|
||||
currentScreen="conversation"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import {useCallback, useEffect, useState} from 'react'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import Animated, {
|
||||
FadeOutUp,
|
||||
useReducedMotion,
|
||||
ZoomIn,
|
||||
} from 'react-native-reanimated'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, type ButtonProps} from '#/components/Button'
|
||||
import {SquareBehindSquare_Stroke2_Corner2_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function CopyTextButton({
|
||||
children,
|
||||
disabled,
|
||||
style,
|
||||
value,
|
||||
onPress: onPressProp,
|
||||
...props
|
||||
}: ButtonProps & {value: string}) {
|
||||
const t = useTheme()
|
||||
|
||||
const [hasBeenCopied, setHasBeenCopied] = useState(false)
|
||||
|
||||
const isReducedMotionEnabled = useReducedMotion()
|
||||
|
||||
useEffect(() => {
|
||||
if (hasBeenCopied) {
|
||||
const timeout = setTimeout(
|
||||
() => setHasBeenCopied(false),
|
||||
isReducedMotionEnabled ? 2000 : 100,
|
||||
)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [hasBeenCopied, isReducedMotionEnabled])
|
||||
|
||||
const onPress = useCallback(
|
||||
(evt: GestureResponderEvent) => {
|
||||
void Clipboard.setStringAsync(value)
|
||||
setHasBeenCopied(true)
|
||||
onPressProp?.(evt)
|
||||
},
|
||||
[value, onPressProp],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[a.relative]}>
|
||||
{hasBeenCopied && (
|
||||
<Animated.View
|
||||
entering={ZoomIn.duration(100)}
|
||||
exiting={FadeOutUp.duration(2000)}
|
||||
style={[
|
||||
a.absolute,
|
||||
{bottom: '100%', right: 0},
|
||||
a.justify_center,
|
||||
a.gap_sm,
|
||||
a.z_10,
|
||||
a.pb_sm,
|
||||
]}
|
||||
pointerEvents="none">
|
||||
<Text
|
||||
style={[
|
||||
a.font_medium,
|
||||
a.text_right,
|
||||
a.text_sm,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>Copied!</Trans>
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
<Button
|
||||
color="secondary"
|
||||
disabled={disabled}
|
||||
style={[a.flex_1, a.justify_between, {borderRadius: 10}, style]}
|
||||
onPress={onPress}
|
||||
{...props}>
|
||||
{context => (
|
||||
<View style={[a.flex_1, a.flex_row, a.justify_between, a.p_md]}>
|
||||
{typeof children === 'function' ? children(context) : children}
|
||||
{disabled ? null : <ButtonIcon icon={CopyIcon} size="lg" />}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, type ButtonProps} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function EditTextButton({
|
||||
children,
|
||||
style,
|
||||
onPress,
|
||||
...props
|
||||
}: ButtonProps & {value: string}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.relative]}>
|
||||
<Button
|
||||
color="secondary"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.justify_between,
|
||||
a.rounded_full,
|
||||
a.border,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
style,
|
||||
]}
|
||||
onPress={onPress}
|
||||
{...props}>
|
||||
{context => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
]}>
|
||||
{typeof children === 'function' ? children(context) : children}
|
||||
<View
|
||||
style={[
|
||||
a.ml_sm,
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_50,
|
||||
{paddingHorizontal: 10, paddingVertical: 8},
|
||||
]}>
|
||||
<Text
|
||||
style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
|
||||
<Trans>Edit</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import {useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {useCreateJoinLink} from '#/state/queries/messages/create-join-link'
|
||||
import {useDisableJoinLink} from '#/state/queries/messages/disable-join-link'
|
||||
import {useEditJoinLink} from '#/state/queries/messages/edit-join-link'
|
||||
import {useEnableJoinLink} from '#/state/queries/messages/enable-join-link'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {
|
||||
Button,
|
||||
ButtonIcon,
|
||||
ButtonText,
|
||||
StackedButton,
|
||||
} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {type ConvoWithDetails} from '#/components/dms/util'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
|
||||
import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight'
|
||||
import {ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon} from '#/components/icons/ChainLink'
|
||||
import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {CopyTextButton} from './CopyTextButton'
|
||||
import {EditTextButton} from './EditTextButton'
|
||||
|
||||
enum Step {
|
||||
INFO,
|
||||
GENERATE,
|
||||
MANAGE,
|
||||
}
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
})
|
||||
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
export function InviteLinkDialog({
|
||||
convo,
|
||||
control,
|
||||
isOwner,
|
||||
}: {
|
||||
convo: Extract<ConvoWithDetails, {kind: 'group'}>
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
isOwner: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const ownerName = createSanitizedDisplayName(convo.primaryMember)
|
||||
|
||||
const {joinLink} = convo.details
|
||||
const enabledStatus = joinLink?.enabledStatus
|
||||
|
||||
const defaultStep = joinLink ? Step.MANAGE : Step.INFO
|
||||
const defaultWhoCanJoin = joinLink
|
||||
? [
|
||||
`${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`,
|
||||
]
|
||||
: ['anyone']
|
||||
|
||||
const [step, setStep] = useState<Step>(defaultStep)
|
||||
const [whoCanJoin, setWhoCanJoin] = useState(defaultWhoCanJoin)
|
||||
|
||||
const {openComposer} = useOpenComposer()
|
||||
|
||||
const {mutate: createJoinLink, isPending: isCreating} = useCreateJoinLink(
|
||||
convo.view.id,
|
||||
{
|
||||
onSuccess: () => {
|
||||
setStep(Step.MANAGE)
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(l`Failed to create invite link`, {
|
||||
type: 'error',
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
const {mutate: editJoinLink, isPending: isEditing} = useEditJoinLink(
|
||||
convo.view.id,
|
||||
{
|
||||
onSuccess: () => {
|
||||
setStep(Step.MANAGE)
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(l`Failed to edit invite link`, {
|
||||
type: 'error',
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
const {mutate: disableJoinLink, isPending: isDisabling} = useDisableJoinLink(
|
||||
convo.view.id,
|
||||
{
|
||||
onError: () => {
|
||||
Toast.show(l`Failed to disable invite link`, {
|
||||
type: 'error',
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
const {mutate: enableJoinLink, isPending: isEnabling} = useEnableJoinLink(
|
||||
convo.view.id,
|
||||
{
|
||||
onError: () => {
|
||||
Toast.show(l`Failed to enable invite link`, {
|
||||
type: 'error',
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
const isSaving = isCreating || isEditing
|
||||
|
||||
const whoCanJoinOptions = [
|
||||
{
|
||||
name: 'anyone',
|
||||
owner: l`Anyone can join instantly`,
|
||||
member: l`Anyone can join instantly`,
|
||||
},
|
||||
{
|
||||
name: 'anyone:requireApproval',
|
||||
owner: l`Anyone can request to join`,
|
||||
member: l`Anyone can request to join`,
|
||||
},
|
||||
{
|
||||
name: 'followedByOwner',
|
||||
owner: l`People I follow can join instantly`,
|
||||
member: l`People ${ownerName} follows can join instantly`,
|
||||
},
|
||||
{
|
||||
name: 'followedByOwner:requireApproval',
|
||||
owner: l`People I follow can request to join`,
|
||||
member: l`People ${ownerName} follows can request to join`,
|
||||
},
|
||||
]
|
||||
|
||||
let content: React.ReactNode = null
|
||||
let header: string | null = null
|
||||
switch (step) {
|
||||
case Step.INFO:
|
||||
header = l`Invite link`
|
||||
content = (
|
||||
<>
|
||||
<View>
|
||||
<Text style={[a.text_md, t.atoms.text]}>
|
||||
<Trans>
|
||||
An invite link lets people join this group chat without being
|
||||
added directly. You control who can use the link and whether
|
||||
they need your approval. You can disable the link at any time.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[a.mt_lg, a.text_md, t.atoms.text]}>
|
||||
<Trans>
|
||||
Your name, avatar, and the name of the group chat will be
|
||||
visible to everyone.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.mt_4xl]}>
|
||||
<Button
|
||||
label={l`Get started`}
|
||||
color="primary"
|
||||
size="large"
|
||||
onPress={() => {
|
||||
setStep(Step.GENERATE)
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Get started</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={ArrowRightIcon} />
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
break
|
||||
case Step.GENERATE:
|
||||
header = l`Generate invite link`
|
||||
content = (
|
||||
<>
|
||||
<View>
|
||||
<Text style={[a.text_md, t.atoms.text]}>
|
||||
<Trans>Choose who can join this group chat and how.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.mt_lg]}>
|
||||
<Toggle.Group
|
||||
label={l`Who can join this group chat and how`}
|
||||
type="radio"
|
||||
values={whoCanJoin}
|
||||
onChange={setWhoCanJoin}>
|
||||
<View style={[a.gap_sm]}>
|
||||
{whoCanJoinOptions.map(option => (
|
||||
<Toggle.Item
|
||||
key={option.name}
|
||||
highlightRow={true}
|
||||
label={isOwner ? option.owner : option.member}
|
||||
name={option.name}
|
||||
style={[a.flex_1]}>
|
||||
{({selected}) => (
|
||||
<TargetOption
|
||||
label={isOwner ? option.owner : option.member}
|
||||
selected={selected}
|
||||
/>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
))}
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
</View>
|
||||
<View style={[a.mt_4xl]}>
|
||||
<Button
|
||||
label={l`Generate invite link`}
|
||||
color="primary"
|
||||
size="large"
|
||||
disabled={isSaving}
|
||||
onPress={() => {
|
||||
const parts = whoCanJoin[0].split(':')
|
||||
const joinRule = parts[0]
|
||||
const requireApproval = parts[1] === 'requireApproval'
|
||||
if (joinLink && enabledStatus === 'enabled') {
|
||||
editJoinLink({
|
||||
joinRule,
|
||||
requireApproval,
|
||||
})
|
||||
} else {
|
||||
createJoinLink({
|
||||
joinRule,
|
||||
requireApproval,
|
||||
})
|
||||
}
|
||||
}}>
|
||||
<ButtonText>
|
||||
{joinLink && enabledStatus === 'enabled'
|
||||
? l`Update invite link`
|
||||
: l`Generate invite link`}
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={isSaving ? Loader : ArrowRightIcon} />
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
break
|
||||
case Step.MANAGE: {
|
||||
const hasJoinLinkCode = joinLink && joinLink.code !== ''
|
||||
const joinLinkURI = hasJoinLinkCode
|
||||
? `https://bsky.app/chat/${joinLink.code}`
|
||||
: 'https://bsky.app/chat'
|
||||
const createdAt = joinLink ? new Date(joinLink.createdAt) : null
|
||||
const currentOption = whoCanJoinOptions.find(
|
||||
o => o.name === whoCanJoin[0],
|
||||
)
|
||||
const ownerValue = currentOption?.owner ?? whoCanJoinOptions[0].owner
|
||||
const memberValue = currentOption?.member ?? whoCanJoinOptions[0].member
|
||||
header =
|
||||
enabledStatus === 'enabled' ? l`Invite link` : l`Invite link disabled`
|
||||
content = (
|
||||
<>
|
||||
<View style={[a.mt_lg]}>
|
||||
<CopyTextButton
|
||||
disabled={enabledStatus === 'disabled' || !hasJoinLinkCode}
|
||||
label={l`Invite link`}
|
||||
value={joinLinkURI}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.mr_xs,
|
||||
a.text_md,
|
||||
enabledStatus === 'disabled'
|
||||
? t.atoms.text_contrast_low
|
||||
: t.atoms.text,
|
||||
]}>
|
||||
{joinLinkURI}
|
||||
</Text>
|
||||
</CopyTextButton>
|
||||
{createdAt ? (
|
||||
<Text style={[a.mt_xs, a.text_xs, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Created {timeFormatter.format(createdAt)}{' '}
|
||||
{dateFormatter.format(createdAt)}
|
||||
</Trans>
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{enabledStatus === 'enabled' ? (
|
||||
<View style={[a.mt_lg]}>
|
||||
{isOwner ? (
|
||||
<EditTextButton
|
||||
label={l`Edit link settings`}
|
||||
value={ownerValue}
|
||||
onPress={() => setStep(Step.GENERATE)}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.mr_xs,
|
||||
a.text_md,
|
||||
t.atoms.text,
|
||||
{maxWidth: '80%'},
|
||||
]}>
|
||||
{ownerValue}
|
||||
</Text>
|
||||
</EditTextButton>
|
||||
) : (
|
||||
<Text style={[a.text_sm, t.atoms.text]}>{memberValue}</Text>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
{enabledStatus === 'enabled' ? (
|
||||
<View style={[a.flex_row, a.justify_between, a.gap_sm, a.mt_lg]}>
|
||||
{isOwner ? (
|
||||
<StackedButton
|
||||
label={l`Disable`}
|
||||
icon={isDisabling ? Loader : ChainLinkBrokenIcon}
|
||||
color="negative_subtle"
|
||||
style={[a.flex_1, a.rounded_full]}
|
||||
disabled={isDisabling}
|
||||
onPress={() => {
|
||||
disableJoinLink()
|
||||
}}>
|
||||
<Trans>Disable</Trans>
|
||||
</StackedButton>
|
||||
) : null}
|
||||
<StackedButton
|
||||
disabled={enabledStatus === 'disabled'}
|
||||
label={l`Post link`}
|
||||
icon={EditIcon}
|
||||
color="primary_subtle"
|
||||
style={[a.flex_1, a.rounded_full]}
|
||||
onPress={() => {
|
||||
control.close(() => {
|
||||
openComposer({
|
||||
text: joinLinkURI,
|
||||
logContext: 'Other',
|
||||
})
|
||||
})
|
||||
}}>
|
||||
<Trans>Post link</Trans>
|
||||
</StackedButton>
|
||||
<StackedButton
|
||||
disabled={enabledStatus === 'disabled'}
|
||||
label={l`Share`}
|
||||
icon={ArrowShareRightIcon}
|
||||
color="primary_subtle"
|
||||
style={[a.flex_1, a.rounded_full]}
|
||||
onPress={() => {
|
||||
void shareUrl(joinLinkURI)
|
||||
}}>
|
||||
<Trans>Share</Trans>
|
||||
</StackedButton>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[a.gap_md, a.mt_lg]}>
|
||||
<Button
|
||||
label={l`Re-enable invite link`}
|
||||
color="primary"
|
||||
size="large"
|
||||
disabled={isEnabling}
|
||||
onPress={() => {
|
||||
enableJoinLink()
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Re-enable link</Trans>
|
||||
</ButtonText>
|
||||
{isEnabling && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
<Button
|
||||
label={l`Generate new invite link`}
|
||||
color="secondary"
|
||||
size="large"
|
||||
onPress={() => setStep(Step.GENERATE)}>
|
||||
<ButtonText>
|
||||
<Trans>Generate new link</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOwner && (!joinLink || joinLink?.enabledStatus === 'disabled')) {
|
||||
header = l`Invite link`
|
||||
content = (
|
||||
<>
|
||||
<View style={[a.mt_lg]}>
|
||||
<Text style={[a.text_sm, t.atoms.text]}>
|
||||
<Trans>There is no invite link for this group chat.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.gap_md, a.mt_lg]}>
|
||||
<Button
|
||||
label={l`Close`}
|
||||
color="primary"
|
||||
size="large"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={() => {
|
||||
setStep(defaultStep)
|
||||
setWhoCanJoin(defaultWhoCanJoin)
|
||||
}}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
header={
|
||||
<View>
|
||||
<View style={[IS_WEB ? [a.px_2xl, a.pt_xl] : {paddingTop: 10}]}>
|
||||
<Text style={[a.font_bold, a.text_2xl, a.mb_sm, t.atoms.text]}>
|
||||
{header}
|
||||
</Text>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</View>
|
||||
}
|
||||
label={l`Group chat invite link dialog`}
|
||||
style={web({maxWidth: 400})}>
|
||||
{content}
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetOption({label, selected}: {label: string; selected: boolean}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText
|
||||
style={[
|
||||
a.font_normal,
|
||||
a.flex_1,
|
||||
a.leading_tight,
|
||||
selected ? t.atoms.text : t.atoms.text_contrast_high,
|
||||
]}>
|
||||
{label}
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -250,10 +250,8 @@ export function MessagesList({
|
||||
)
|
||||
|
||||
const onStartReached = useCallback(() => {
|
||||
if (hasScrolled && prevContentHeight.current > layoutHeight.get()) {
|
||||
void convoState.fetchMessageHistory()
|
||||
}
|
||||
}, [convoState, hasScrolled, layoutHeight])
|
||||
void convoState.fetchMessageHistory()
|
||||
}, [convoState])
|
||||
|
||||
const onScroll = useCallback(
|
||||
(e: ScrollEvent) => {
|
||||
@@ -376,10 +374,7 @@ export function MessagesList({
|
||||
return (
|
||||
<MessageItem
|
||||
item={item}
|
||||
profile={convoState.convo.members.find(
|
||||
member => member.did === item.message.sender.did,
|
||||
)}
|
||||
isGroupChat={convoState.isGroup()}
|
||||
isGroupChat={convoState.convo.kind === 'group'}
|
||||
/>
|
||||
)
|
||||
} else if (item.type === 'deleted-message') {
|
||||
@@ -448,8 +443,9 @@ export function MessagesList({
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<MaybeLoader isLoading={convoState.isFetchingHistory} />
|
||||
{convoState.isGroup() && convoState.hasAllHistory ? (
|
||||
<MessagesListInfoPanel convoState={convoState} />
|
||||
{convoState.convo?.kind === 'group' &&
|
||||
convoState.hasAllHistory ? (
|
||||
<MessagesListInfoPanel convo={convoState.convo} />
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
@@ -577,7 +573,7 @@ function getFooterState(
|
||||
}
|
||||
}
|
||||
|
||||
if (convoState.convo.status === 'request' && !hasAcceptOverride) {
|
||||
if (convoState.convo.view.status === 'request' && !hasAcceptOverride) {
|
||||
return 'request'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,72 +1,90 @@
|
||||
import {View} from 'react-native'
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {type ConvoState} from '#/state/messages/convo/types'
|
||||
import {logger} from '#/logger'
|
||||
import {useAddGroupMembers} from '#/state/queries/messages/add-group-members'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {AddMembersFlow} from '#/components/dms/AddMembersFlow'
|
||||
import {type ConvoWithDetails} from '#/components/dms/util'
|
||||
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
|
||||
import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {InviteLinkDialog} from './InviteLinkDialog'
|
||||
|
||||
export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
|
||||
export function MessagesListInfoPanel({
|
||||
convo,
|
||||
}: {
|
||||
convo: Extract<ConvoWithDetails, {kind: 'group'}>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const addMembersControl = Dialog.useDialogControl()
|
||||
const inviteLinkControl = Dialog.useDialogControl()
|
||||
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const isOwner =
|
||||
currentAccount?.did == null
|
||||
? false
|
||||
: convoState.getPrimaryMember?.()?.did === currentAccount.did
|
||||
// TODO Get this from @api/atproto - dsb
|
||||
const isLinkEnabled = false
|
||||
const convoId = convo.view.id
|
||||
const {mutate: addGroupMembers} = useAddGroupMembers(convoId, {
|
||||
onSuccess: () => {
|
||||
addMembersControl.close()
|
||||
},
|
||||
onError: e => {
|
||||
logger.error('Failed to add group chat members', {message: e})
|
||||
Toast.show(l`Failed to add members`, {type: 'error'})
|
||||
},
|
||||
})
|
||||
|
||||
const groupName = convoState.getGroupInfo?.()?.name
|
||||
// TODO Enable this once the feature is working end-to-end. -dsb
|
||||
// const joinLink = groupConvo?.details.joinLink
|
||||
const isJoinLinkEnabled = false
|
||||
// (isOwner && groupConvo) ||
|
||||
// (!isOwner && groupConvo && joinLink?.enabledStatus === 'enabled')
|
||||
|
||||
const members = (convoState?.convo?.members ?? []).filter(
|
||||
const isOwner = convo?.primaryMember.did === currentAccount?.did
|
||||
|
||||
const members = (convo?.members ?? []).filter(
|
||||
profile => profile.did !== currentAccount?.did,
|
||||
)
|
||||
|
||||
let names: React.ReactNode | null = null
|
||||
let names: React.ReactNode = null
|
||||
if (members.length === 1) {
|
||||
names = <Trans>New chat with {members[0].displayName}</Trans>
|
||||
}
|
||||
if (members.length === 2) {
|
||||
} else if (members.length === 2) {
|
||||
names = (
|
||||
<Trans>
|
||||
New chat with {members[0].displayName} and {members[1].displayName}
|
||||
</Trans>
|
||||
)
|
||||
}
|
||||
if (members.length > 2) {
|
||||
} else if (members.length > 2) {
|
||||
const memberCount = convo.details.memberCount - 2
|
||||
names = (
|
||||
<Trans>
|
||||
New chat with {members[0].displayName}, {members[1].displayName}, and{' '}
|
||||
<Plural
|
||||
value={members.length - 2}
|
||||
one={`${members.length - 2} more`}
|
||||
other={`${members.length - 2} more`}
|
||||
value={memberCount}
|
||||
one={`${memberCount} more`}
|
||||
other={`${memberCount} more`}
|
||||
/>
|
||||
.
|
||||
</Trans>
|
||||
)
|
||||
}
|
||||
|
||||
const showButtons = isOwner || isLinkEnabled
|
||||
const showButtons = isOwner || isJoinLinkEnabled
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={[a.align_center, a.justify_center]}>
|
||||
<AvatarBubbles animate={true} profiles={members} />
|
||||
{groupName ? (
|
||||
<AvatarBubbles animate={true} profiles={convo?.members} />
|
||||
{convo.details.name ? (
|
||||
<Text style={[a.text_2xl, a.font_bold, a.mt_lg, t.atoms.text]}>
|
||||
{groupName}
|
||||
{convo.details.name}
|
||||
</Text>
|
||||
) : null}
|
||||
{names ? (
|
||||
@@ -102,12 +120,16 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
|
||||
</ButtonText>
|
||||
</Button>
|
||||
) : null}
|
||||
{isOwner || isLinkEnabled ? (
|
||||
{isJoinLinkEnabled ? (
|
||||
<Button
|
||||
color="secondary"
|
||||
size="small"
|
||||
label={l`Click here to view or create an invite link for this group chat`}
|
||||
onPress={() => {}}>
|
||||
label={
|
||||
isOwner
|
||||
? l`Click here to create or manage an invite link for this group chat`
|
||||
: l`Click here to view the invite link for this group chat`
|
||||
}
|
||||
onPress={inviteLinkControl.open}>
|
||||
<ButtonIcon icon={ChainLinkIcon} />
|
||||
<ButtonText>
|
||||
<Trans>Invite link</Trans>
|
||||
@@ -117,17 +139,22 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<InviteLinkDialog
|
||||
isOwner={isOwner}
|
||||
convo={convo}
|
||||
control={inviteLinkControl}
|
||||
/>
|
||||
<Dialog.Outer
|
||||
control={addMembersControl}
|
||||
testID="addChatMembersDialog"
|
||||
nativeOptions={{fullHeight: true}}>
|
||||
<Dialog.Handle />
|
||||
<AddMembersFlow
|
||||
members={members.map(profile => profile.did)}
|
||||
title={l`Add people`}
|
||||
onAddMembers={(_dids: string[]) => {
|
||||
// TODO Add members here
|
||||
addMembersControl.close()
|
||||
}}
|
||||
onAddMembers={(members, profiles) =>
|
||||
addGroupMembers({members, profiles})
|
||||
}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user