From b278913f4b550a5363e48d7359aa3c38767884e7 Mon Sep 17 00:00:00 2001
From: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Date: Tue, 7 Apr 2026 11:21:38 -0700
Subject: [PATCH] Add member list to group clip clop settings (#10194)
---
src/screens/Messages/ConversationSettings.tsx | 468 +++++++++++++++++-
1 file changed, 444 insertions(+), 24 deletions(-)
diff --git a/src/screens/Messages/ConversationSettings.tsx b/src/screens/Messages/ConversationSettings.tsx
index d4fbbe1b70..895836532a 100644
--- a/src/screens/Messages/ConversationSettings.tsx
+++ b/src/screens/Messages/ConversationSettings.tsx
@@ -1,23 +1,65 @@
-import React, {useState} from 'react'
-import {View} from 'react-native'
+import {useMemo, useState} from 'react'
+import {Pressable, View} from 'react-native'
+import {moderateProfile} from '@atproto/api'
+import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
+import {useNavigation} from '@react-navigation/native'
-import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
+import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
+import {type NavigationProp} from '#/lib/routes/types'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {List} from '#/view/com/util/List'
+import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, type ButtonColor, ButtonIcon} from '#/components/Button'
import type * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+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 {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron'
import {type Props as SVGIconProps} from '#/components/icons/common'
+import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
import {EditBig_Stroke2_Corner0_Rounded as EditIcon} from '#/components/icons/EditBig'
import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
+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 {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import * as Layout from '#/components/Layout'
+import {InlineLinkText} from '#/components/Link'
+import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
+import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
+import {IS_NATIVE} from '#/env'
+import type * as bsky from '#/types/bsky'
+
+const MEMBER_LIMIT = 50
+const ROW_SPACING = 10
+
+type Item =
+ | {
+ type: 'MEMBERS_AND_REQUESTS'
+ }
+ | {
+ type: 'ADD_MEMBERS_LINK'
+ }
+ | {
+ type: 'CHAT_MEMBER'
+ profile: bsky.profile.AnyProfileView
+ status: 'admin' | 'member' | 'invited'
+ }
/**
* TODO This is just layout for now.
@@ -36,15 +78,375 @@ export function MessagesConversationSettingsScreen() {
-
-
-
+
)
}
+function keyExtractor(item: Item) {
+ return item.type === 'CHAT_MEMBER' ? item.profile.did : item.type
+}
+
function SettingsInner() {
- return
+ const initialNumToRender = useInitialNumToRender({minItemHeight: 68})
+ const bottomBarOffset = useBottomBarOffset()
+
+ const data: bsky.profile.AnyProfileView[] = []
+ const invites: string[] = []
+
+ const items = [
+ {
+ type: 'MEMBERS_AND_REQUESTS',
+ },
+ {
+ type: 'ADD_MEMBERS_LINK',
+ },
+ ...data.map((profile, index) => ({
+ type: 'CHAT_MEMBER',
+ profile,
+ status:
+ index === 0
+ ? 'admin'
+ : invites.includes(profile.did)
+ ? 'invited'
+ : 'member',
+ })),
+ ]
+
+ function renderItem({item}: {item: Item}) {
+ switch (item.type) {
+ case 'MEMBERS_AND_REQUESTS':
+ return
+ case 'ADD_MEMBERS_LINK':
+ return
+ case 'CHAT_MEMBER':
+ return
+ default:
+ return null
+ }
+ }
+
+ return (
+
}
+ renderItem={renderItem}
+ sideBorders={false}
+ windowSize={11}
+ onEndReachedThreshold={IS_NATIVE ? 1.5 : 0}
+ />
+ )
+}
+
+function MembersAndRequests({
+ memberCount,
+ requestCount,
+}: {
+ memberCount: number
+ requestCount: number
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ return (
+
+
+
+ Members{' '}
+
+ {l`${memberCount}/${MEMBER_LIMIT}`}
+
+
+ {l`${plural(requestCount, {
+ one: '# request',
+ other: '# requests',
+ })}`}
+
+
+ )
+}
+
+function AddMembersLink() {
+ const t = useTheme()
+
+ return (
+
+
+ [
+ a.flex_row,
+ a.align_center,
+ a.justify_between,
+ pressed && web({outline: 'none'}),
+ ]}>
+ {({pressed}) => (
+ <>
+
+
+
+
+
+
+ Add members
+
+
+
+
+ >
+ )}
+
+
+
+ )
+}
+
+function Member({
+ profile,
+ status,
+}: {
+ profile: bsky.profile.AnyProfileView
+ status: 'admin' | 'member' | 'invited'
+}) {
+ const navigation = useNavigation()
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ const moderationOpts = useModerationOpts()
+ const moderation = useMemo(
+ () =>
+ moderationOpts ? moderateProfile(profile, moderationOpts) : undefined,
+ [profile, moderationOpts],
+ )
+
+ if (!moderation) return null
+
+ const invitedByDisplayName = 'Darrin Loeliger'
+
+ const isDeletedAccount = profile.handle === 'missing.invalid'
+ const displayName = isDeletedAccount
+ ? l`Deleted Account`
+ : sanitizeDisplayName(
+ profile.displayName || profile.handle,
+ moderation.ui('displayName'),
+ )
+
+ let invitedBy: React.ReactNode | null = (
+
+ {l`Added by ${invitedByDisplayName}`}
+
+ )
+ let statusBadge: React.ReactNode | null =
+ switch (status) {
+ case 'admin':
+ invitedBy = null
+ statusBadge =
+ break
+ case 'invited':
+ invitedBy = (
+
+ {l`Invited by ${invitedByDisplayName}`}
+
+ )
+ statusBadge =
+ break
+ }
+
+ return (
+
+ {
+ navigation.navigate('Profile', {name: profile.did})
+ }}>
+
+
+
+
+
+ {displayName}
+
+
+ {sanitizeHandle(profile.handle, '@')}
+
+ {invitedBy ? (
+
+ {invitedBy}
+
+ ) : null}
+
+
+ {statusBadge}
+
+
+
+ )
+}
+
+function StatusBadge({label}: {label: string}) {
+ const t = useTheme()
+
+ return (
+
+
+ {label}
+
+
+ )
+}
+
+function MemberMenu({profile}: {profile: bsky.profile.AnyProfileView}) {
+ const navigation = useNavigation()
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ const moderationOpts = useModerationOpts()
+ const moderation = useMemo(
+ () =>
+ moderationOpts ? moderateProfile(profile, moderationOpts) : undefined,
+ [profile, moderationOpts],
+ )
+
+ if (!moderation) return null
+
+ const isDeletedAccount = profile.handle === 'missing.invalid'
+ const displayName = isDeletedAccount
+ ? l`Deleted Account`
+ : sanitizeDisplayName(
+ profile.displayName || profile.handle,
+ moderation.ui('displayName'),
+ )
+
+ return (
+
+
+ {({props, state}) => (
+
+
+
+ )}
+
+
+
+ {
+ navigation.navigate('Profile', {name: profile.did})
+ }}>
+
+ Go to profile
+
+
+
+ {}}>
+
+ Message
+
+
+
+
+
+
+ {}}>
+
+ Block
+
+
+
+ {}}>
+
+ Remove from chat
+
+
+
+
+
+
+ )
}
function SettingsHeading() {
@@ -175,21 +577,19 @@ function SettingsHeading() {
)
}
-type SettingsButtonProps = {
- color?: ButtonColor
- icon: React.ComponentType
- label: string
- text: string
- onPress: () => void
-}
-
function SettingsButton({
color = 'secondary',
icon,
label,
text,
onPress,
-}: SettingsButtonProps) {
+}: {
+ color?: ButtonColor
+ icon: React.ComponentType
+ label: string
+ text: string
+ onPress: () => void
+}) {
const t = useTheme()
return (
@@ -267,12 +667,13 @@ function EditNamePrompt({
)
}
-type InviteLinkPromptProps = {
+function InviteLinkPrompt({
+ control,
+ onConfirm,
+}: {
control: Dialog.DialogOuterProps['control']
onConfirm: () => void
-}
-
-function InviteLinkPrompt({control, onConfirm}: InviteLinkPromptProps) {
+}) {
const {t: l} = useLingui()
return (
@@ -287,12 +688,13 @@ function InviteLinkPrompt({control, onConfirm}: InviteLinkPromptProps) {
)
}
-type LockChatPromptProps = {
+function LockChatPrompt({
+ control,
+ onConfirm,
+}: {
control: Dialog.DialogOuterProps['control']
onConfirm: () => void
-}
-
-function LockChatPrompt({control, onConfirm}: LockChatPromptProps) {
+}) {
const {t: l} = useLingui()
return (
@@ -306,3 +708,21 @@ function LockChatPrompt({control, onConfirm}: LockChatPromptProps) {
/>
)
}
+
+function SubtleHoverWrapper({children}: React.PropsWithChildren) {
+ const {
+ state: hover,
+ onIn: onHoverIn,
+ onOut: onHoverOut,
+ } = useInteractionState()
+
+ return (
+
+
+ {children}
+
+ )
+}