Create group clip clops settings header (#10168)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 chat’s 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 can’t send new messages.`}
|
||||
confirmButtonCta={l`Lock group chat`}
|
||||
cancelButtonCta={l`Cancel`}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user