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} + + ) +}