Create group clip clops settings header (#10168)

This commit is contained in:
DS Boyce
2026-04-07 10:55:12 -07:00
committed by Samuel Newman
parent 61f9d2faf4
commit 6fd666e9ce
6 changed files with 469 additions and 7 deletions
+6
View File
@@ -78,6 +78,7 @@ import HashtagScreen from '#/screens/Hashtag'
import {LogScreen} from '#/screens/Log'
import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings'
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
@@ -568,6 +569,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => MessagesConversationScreen}
options={{title: title(msg`Chat`), requireAuth: true}}
/>
<Stack.Screen
name="MessagesConversationSettings"
getComponent={() => MessagesConversationSettingsScreen}
options={{title: title(msg`Group chat settings`), requireAuth: true}}
/>
<Stack.Screen
name="MessagesSettings"
getComponent={() => MessagesSettingsScreen}
+125
View File
@@ -0,0 +1,125 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
type Props = {
profiles: number[]
size?: 'small' | 'medium' | 'large'
}
/**
* TODO This is just layout for now.
*/
export function AvatarBubbles({profiles, size = 'large'}: Props) {
const containerSize = size === 'small' ? 40 : size === 'medium' ? 56 : 120
const scale = size === 'small' ? 40 / 120 : size === 'medium' ? 56 / 120 : 1
const marginOffset = size === 'small' || size === 'medium' ? -2 : 0
let avatars = (
<>
<AvatarBubble size={76} x={-2} y={-2} style={[a.z_20]} />
<AvatarBubble size={76} x={42} y={42} style={[a.z_10]} />
</>
)
if (profiles.length === 3) {
avatars = (
<>
<AvatarBubble size={68} x={-2} y={-2} />
<AvatarBubble size={56} x={38} y={62} />
<AvatarBubble size={46} x={71} y={18} />
</>
)
}
if (profiles.length >= 4) {
avatars = (
<>
<AvatarBubble size={68} x={-2} y={-2} />
<AvatarBubble size={56} x={60} y={49} />
<AvatarBubble size={42} x={14} y={74} />
<AvatarBubble size={32} x={72} y={9} />
</>
)
}
return (
<View
style={[
a.p_2xs,
{
height: containerSize,
width: containerSize,
},
]}>
<View
style={[
{
marginTop: marginOffset,
marginLeft: marginOffset,
transform: [{scale}],
transformOrigin: 'top left',
},
]}>
{avatars}
</View>
</View>
)
}
type AvatarBubbleProps = {
size: number
style?: StyleProp<ViewStyle>
x: number
y: number
}
function AvatarBubble({size, style, x, y}: AvatarBubbleProps) {
const t = useTheme()
return (
<View
style={[
a.absolute,
a.rounded_full,
a.flex_grow_0,
{
borderColor: t.atoms.text_inverted.color,
borderWidth: 2,
transform: [{translateX: x}, {translateY: y}],
},
style,
]}>
<AvatarPlaceholder size={size} />
</View>
)
}
type AvatarProps = {
size?: number
}
function AvatarPlaceholder({size = 76}: AvatarProps) {
const t = useTheme()
return (
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
t.atoms.bg_contrast_200,
{
width: size,
height: size,
},
]}>
<PersonIcon
width={size * 0.5}
height={size * 0.5}
fill={t.atoms.text_inverted.color}
/>
</View>
)
}
+28 -7
View File
@@ -25,9 +25,9 @@ import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {
@@ -35,11 +35,13 @@ import {
PersonCheck_Stroke2_Corner0_Rounded as PersonCheck,
PersonX_Stroke2_Corner0_Rounded as PersonX,
} from '#/components/icons/Person'
import {SettingsGear2_Stroke2_Corner0_Rounded as GearIcon} from '#/components/icons/SettingsGear2'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import * as Menu from '#/components/Menu'
import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky'
let ConvoMenu = ({
@@ -95,7 +97,7 @@ let ConvoMenu = ({
shape="round"
variant="ghost"
style={[a.bg_transparent]}>
<ButtonIcon icon={DotsHorizontal} size="md" />
<ButtonIcon icon={DotsHorizontalIcon} size="md" />
</Button>
)}
</Menu.Trigger>
@@ -181,6 +183,7 @@ function MenuContent({
reportControl: Prompt.PromptControlProps
blockedByListControl: Prompt.PromptControlProps
}) {
const ax = useAnalytics()
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const {mutate: markAsRead} = useMarkAsReadMutation()
@@ -192,6 +195,12 @@ function MenuContent({
const convoId = initialConvo.id
const {data: convo} = useConvoQuery(initialConvo)
const onNavigateToSettings = useCallback(() => {
navigation.navigate('MessagesConversationSettings', {
conversation: convoId,
})
}, [convoId, navigation])
const onNavigateToProfile = useCallback(() => {
navigation.navigate('Profile', {name: profile.did})
}, [navigation, profile.did])
@@ -226,6 +235,8 @@ function MenuContent({
}
}, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock])
const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable)
return isDeletedAccount ? (
<Menu.Item
label={_(msg`Leave conversation`)}
@@ -233,10 +244,20 @@ function MenuContent({
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
</Menu.Item>
) : (
<>
{isGroupChatEnabled ? (
<Menu.Group>
<Menu.Item label={_(msg`Settings`)} onPress={onNavigateToSettings}>
<Menu.ItemText>
<Trans>Settings</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={GearIcon} />
</Menu.Item>
</Menu.Group>
) : null}
<Menu.Group>
{showMarkAsRead && (
<Menu.Item
@@ -245,7 +266,7 @@ function MenuContent({
<Menu.ItemText>
<Trans>Mark as read</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Bubble} />
<Menu.ItemIcon icon={BubbleIcon} />
</Menu.Item>
)}
<Menu.Item
@@ -296,7 +317,7 @@ function MenuContent({
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
</Menu.Item>
</Menu.Group>
</>
+1
View File
@@ -73,6 +73,7 @@ export type CommonNavigatorParams = {
Hashtag: {tag: string; author?: string}
Topic: {topic: string}
MessagesConversation: {conversation: string; embed?: string; accept?: true}
MessagesConversationSettings: {conversation: string}
MessagesSettings: undefined
MessagesInbox: undefined
NotificationsActivityList: {posts: string}
+1
View File
@@ -85,6 +85,7 @@ export const router = new Router<AllNavigatableRoutes>({
MessagesSettings: '/messages/settings',
MessagesInbox: '/messages/inbox',
MessagesConversation: '/messages/:conversation',
MessagesConversationSettings: '/messages/:conversation/settings',
// starter packs
Start: '/start/:name/:rkey',
StarterPackEdit: '/starter-pack/edit/:rkey',
@@ -0,0 +1,308 @@
import React, {useState} from 'react'
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {atoms as a, useBreakpoints, useTheme} 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 {
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 {EditBig_Stroke2_Corner0_Rounded as EditIcon} from '#/components/icons/EditBig'
import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
import * as Layout from '#/components/Layout'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
/**
* TODO This is just layout for now.
*/
export function MessagesConversationSettingsScreen() {
const {gtTablet} = useBreakpoints()
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>
<Layout.Center>
<SettingsInner />
</Layout.Center>
</Layout.Screen>
)
}
function SettingsInner() {
return <SettingsHeading />
}
function SettingsHeading() {
const t = useTheme()
const {t: l} = useLingui()
const editNamePrompt = Prompt.usePromptControl()
const inviteLinkPrompt = Prompt.usePromptControl()
const lockChatPrompt = Prompt.usePromptControl()
const [groupName, setGroupName] = useState('Work in Progress')
const [newGroupName, setNewGroupName] = useState('Work in Progress')
const [isMuted, setIsMuted] = useState(false)
const [isLocked, setIsLocked] = useState(false)
const handleToggleMute = () => {
setIsMuted(prev => !prev)
}
const handlePromptName = () => {
editNamePrompt.open()
}
const handleEditName = () => {
setGroupName(newGroupName)
editNamePrompt.close()
}
const handlePromptInviteLink = () => {
inviteLinkPrompt.open()
}
const handleConfirmInviteLink = () => {
inviteLinkPrompt.close()
}
const handlePromptLock = () => {
lockChatPrompt.open()
}
const handleConfirmLock = () => {
setIsLocked(true)
}
const handleUnlock = () => {
setIsLocked(false)
}
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={[1, 2, 3, 4]} />
</View>
<Text
style={[
a.text_2xl,
a.font_bold,
a.text_center,
a.pt_lg,
t.atoms.text,
]}>
{groupName}
</Text>
<Text
style={[
a.text_sm,
a.text_center,
a.pt_xs,
a.px_xl,
t.atoms.text_contrast_high,
]}>
Created April 2, 2026
</Text>
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.gap_2xl,
a.pt_2xl,
]}>
<SettingsButton
color={isMuted ? 'negative_subtle' : 'secondary'}
icon={isMuted ? BellOffIcon : BellIcon}
label={
isMuted ? l`Unmute this group chat` : l`Mute this group chat`
}
text={isMuted ? l`Muted` : l`Mute`}
onPress={handleToggleMute}
/>
<SettingsButton
icon={EditIcon}
label={l`Edit this group chats name`}
text={l`Edit name`}
onPress={handlePromptName}
/>
<SettingsButton
icon={ChainLinkIcon}
label={l`Create an invite link for this group chat`}
text={l`Invite link`}
onPress={handlePromptInviteLink}
/>
<SettingsButton
color={isLocked ? 'negative_subtle' : 'secondary'}
icon={LockIcon}
label={
isLocked ? l`Unlock this group chat` : l`Lock this group chat`
}
text={isLocked ? l`Locked` : l`Lock`}
onPress={isLocked ? handleUnlock : handlePromptLock}
/>
</View>
</View>
<EditNamePrompt
control={editNamePrompt}
value={newGroupName}
onChangeText={setNewGroupName}
onConfirm={handleEditName}
/>
<InviteLinkPrompt
control={inviteLinkPrompt}
onConfirm={handleConfirmInviteLink}
/>
<LockChatPrompt control={lockChatPrompt} onConfirm={handleConfirmLock} />
</>
)
}
type SettingsButtonProps = {
color?: ButtonColor
icon: React.ComponentType<SVGIconProps>
label: string
text: string
onPress: () => void
}
function SettingsButton({
color = 'secondary',
icon,
label,
text,
onPress,
}: SettingsButtonProps) {
const t = useTheme()
return (
<View>
<Button
color={color}
size="large"
shape="round"
label={label}
onPress={onPress}>
<ButtonIcon icon={icon} size="md" />
</Button>
<Text
style={[
a.text_2xs,
a.font_medium,
a.text_center,
a.pt_xs,
t.atoms.text,
]}>
{text}
</Text>
</View>
)
}
type EditNamePromptProps = {
control: Dialog.DialogOuterProps['control']
value: string
onChangeText: (value: string) => void
onConfirm: () => void
}
function EditNamePrompt({
control,
value,
onChangeText,
onConfirm,
}: EditNamePromptProps) {
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}
onSubmitEditing={onConfirm}
/>
</TextField.Root>
</View>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action
cta={l`Save`}
shouldCloseOnPress={false}
onPress={onConfirm}
/>
<Prompt.Cancel />
</Prompt.Actions>
</>
</Prompt.Outer>
)
}
type InviteLinkPromptProps = {
control: Dialog.DialogOuterProps['control']
onConfirm: () => void
}
function InviteLinkPrompt({control, onConfirm}: InviteLinkPromptProps) {
const {t: l} = useLingui()
return (
<Prompt.Basic
control={control}
title={l`Invite link`}
description={l`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. Your name, avatar, and the name of the group chat will be visible to everyone`}
confirmButtonCta={l`Get started`}
cancelButtonCta={l`Cancel`}
onConfirm={onConfirm}
/>
)
}
type LockChatPromptProps = {
control: Dialog.DialogOuterProps['control']
onConfirm: () => void
}
function LockChatPrompt({control, onConfirm}: LockChatPromptProps) {
const {t: l} = useLingui()
return (
<Prompt.Basic
control={control}
title={l`Lock group chat?`}
description={l`Members can still read chat history but cant send new messages.`}
confirmButtonCta={l`Lock group chat`}
cancelButtonCta={l`Cancel`}
onConfirm={onConfirm}
/>
)
}