[Chat] Enforce limits for group size (#10753)
This commit is contained in:
@@ -667,7 +667,6 @@ function SearchInput({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
// @ts-ignore bottom sheet input types issue — esb
|
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder={l`Search`}
|
placeholder={l`Search`}
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {Trans, useLingui} from '@lingui/react/macro'
|
|||||||
|
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||||
|
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
|
||||||
import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members'
|
import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members'
|
||||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
@@ -141,6 +142,15 @@ export function AddMembersFlow({
|
|||||||
[memberListData],
|
[memberListData],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const {data: chatStatus} = useChatActorStatusQuery()
|
||||||
|
const groupMemberLimit = chatStatus?.groupMemberLimit
|
||||||
|
// The existing members (including the viewer) already occupy slots, so the
|
||||||
|
// number of people that can still be added is whatever's left.
|
||||||
|
const remainingSlots =
|
||||||
|
groupMemberLimit !== undefined
|
||||||
|
? Math.max(0, groupMemberLimit - memberListData.length)
|
||||||
|
: undefined
|
||||||
|
|
||||||
const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, {
|
const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, {
|
||||||
groupChatDids: [],
|
groupChatDids: [],
|
||||||
groupChatProfiles: [],
|
groupChatProfiles: [],
|
||||||
@@ -471,6 +481,7 @@ export function AddMembersFlow({
|
|||||||
values={groupChatDids}
|
values={groupChatDids}
|
||||||
onChange={setGroupChatMembers}
|
onChange={setGroupChatMembers}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
maxSelections={remainingSlots}
|
||||||
label={l`Add group chat members`}
|
label={l`Add group chat members`}
|
||||||
style={web([a.contents])}>
|
style={web([a.contents])}>
|
||||||
<Dialog.InnerFlatList
|
<Dialog.InnerFlatList
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import {LayoutAnimation, type TextInput, View} from 'react-native'
|
|||||||
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {MAX_GROUP_NAME_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
|
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||||
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
|
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
|
||||||
@@ -212,6 +214,7 @@ export function InitiateChatFlow({
|
|||||||
|
|
||||||
const {data: chatStatus} = useChatActorStatusQuery()
|
const {data: chatStatus} = useChatActorStatusQuery()
|
||||||
const canCreateGroups = chatStatus?.canCreateGroups ?? true
|
const canCreateGroups = chatStatus?.canCreateGroups ?? true
|
||||||
|
const groupMemberLimit = chatStatus?.groupMemberLimit
|
||||||
|
|
||||||
const [searchText, setSearchText] = useState('')
|
const [searchText, setSearchText] = useState('')
|
||||||
|
|
||||||
@@ -458,6 +461,11 @@ export function InitiateChatFlow({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const groupNameTooLong = isOverMaxGraphemeCount({
|
||||||
|
text: groupName,
|
||||||
|
maxCount: MAX_GROUP_NAME_GRAPHEME_LENGTH,
|
||||||
|
})
|
||||||
|
|
||||||
let buttonLabel = l`Continue to group name`
|
let buttonLabel = l`Continue to group name`
|
||||||
let buttonText = l`Next`
|
let buttonText = l`Next`
|
||||||
let handleButtonPress = handlePressNext
|
let handleButtonPress = handlePressNext
|
||||||
@@ -470,7 +478,7 @@ export function InitiateChatFlow({
|
|||||||
buttonText = l`Create`
|
buttonText = l`Create`
|
||||||
handleButtonPress = handlePressConfirm
|
handleButtonPress = handlePressConfirm
|
||||||
showButton = true
|
showButton = true
|
||||||
isButtonDisabled = groupName === ''
|
isButtonDisabled = groupName === '' || groupNameTooLong
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,7 +572,7 @@ export function InitiateChatFlow({
|
|||||||
{chatState === ChatState.GROUP_NAME ? (
|
{chatState === ChatState.GROUP_NAME ? (
|
||||||
<View
|
<View
|
||||||
style={[a.w_full, a.relative, web(a.pt_md), native(a.pt_xl)]}>
|
style={[a.w_full, a.relative, web(a.pt_md), native(a.pt_xl)]}>
|
||||||
<TextField.Root>
|
<TextField.Root isInvalid={groupNameTooLong}>
|
||||||
<TextField.Input
|
<TextField.Input
|
||||||
label={l`Group name`}
|
label={l`Group name`}
|
||||||
value={groupName}
|
value={groupName}
|
||||||
@@ -573,6 +581,7 @@ export function InitiateChatFlow({
|
|||||||
selectTextOnFocus={IS_NATIVE}
|
selectTextOnFocus={IS_NATIVE}
|
||||||
autoFocus={false}
|
autoFocus={false}
|
||||||
accessibilityRole="text"
|
accessibilityRole="text"
|
||||||
|
clearButtonMode="while-editing"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
@@ -582,6 +591,20 @@ export function InitiateChatFlow({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
|
{groupNameTooLong ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.mt_xs,
|
||||||
|
a.font_semi_bold,
|
||||||
|
{color: t.palette.negative_400},
|
||||||
|
]}>
|
||||||
|
<Trans>
|
||||||
|
Group name is too long. The maximum number of characters
|
||||||
|
is {MAX_GROUP_NAME_GRAPHEME_LENGTH}.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<UserSearchInput
|
<UserSearchInput
|
||||||
@@ -622,6 +645,8 @@ export function InitiateChatFlow({
|
|||||||
handleButtonPress,
|
handleButtonPress,
|
||||||
buttonText,
|
buttonText,
|
||||||
groupName,
|
groupName,
|
||||||
|
groupNameTooLong,
|
||||||
|
t.palette.negative_400,
|
||||||
searchText,
|
searchText,
|
||||||
control,
|
control,
|
||||||
showChatProfileTabs,
|
showChatProfileTabs,
|
||||||
@@ -664,6 +689,11 @@ export function InitiateChatFlow({
|
|||||||
values={groupChatDids}
|
values={groupChatDids}
|
||||||
onChange={setGroupChatMembers}
|
onChange={setGroupChatMembers}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
maxSelections={
|
||||||
|
// groupMemberLimit counts the creator, who is added implicitly, so
|
||||||
|
// reserve one slot for them
|
||||||
|
groupMemberLimit ? groupMemberLimit - 1 : undefined
|
||||||
|
}
|
||||||
label={
|
label={
|
||||||
chatState === ChatState.NEW_GROUP_CHAT
|
chatState === ChatState.NEW_GROUP_CHAT
|
||||||
? l`Select group chat members`
|
? l`Select group chat members`
|
||||||
|
|||||||
@@ -34,32 +34,40 @@ export function GroupChatProfileCard({
|
|||||||
name={profile.did}
|
name={profile.did}
|
||||||
label={displayName}
|
label={displayName}
|
||||||
style={[a.flex_1, a.py_sm, a.px_lg]}>
|
style={[a.flex_1, a.py_sm, a.px_lg]}>
|
||||||
<View style={[a.flex_grow, !enabled ? {opacity: 0.5} : null]}>
|
{({disabled, selected}) => (
|
||||||
<ProfileCard.Header>
|
<>
|
||||||
<ProfileCard.Avatar
|
<View
|
||||||
profile={profile}
|
style={[
|
||||||
moderationOpts={moderationOpts}
|
a.flex_grow,
|
||||||
size={44}
|
!enabled || (disabled && !selected) ? {opacity: 0.5} : null,
|
||||||
disabledPreview
|
]}>
|
||||||
/>
|
<ProfileCard.Header>
|
||||||
<View>
|
<ProfileCard.Avatar
|
||||||
<ProfileCard.Name
|
profile={profile}
|
||||||
profile={profile}
|
moderationOpts={moderationOpts}
|
||||||
moderationOpts={moderationOpts}
|
size={44}
|
||||||
/>
|
disabledPreview
|
||||||
{enabled ? (
|
/>
|
||||||
<ProfileCard.Handle profile={profile} />
|
<View>
|
||||||
) : (
|
<ProfileCard.Name
|
||||||
<Text
|
profile={profile}
|
||||||
style={[a.leading_snug, t.atoms.text_contrast_high]}
|
moderationOpts={moderationOpts}
|
||||||
numberOfLines={2}>
|
/>
|
||||||
<Trans>{handle} can’t be added</Trans>
|
{enabled ? (
|
||||||
</Text>
|
<ProfileCard.Handle profile={profile} />
|
||||||
)}
|
) : (
|
||||||
|
<Text
|
||||||
|
style={[a.leading_snug, t.atoms.text_contrast_high]}
|
||||||
|
numberOfLines={2}>
|
||||||
|
<Trans>{handle} can’t be added</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ProfileCard.Header>
|
||||||
</View>
|
</View>
|
||||||
</ProfileCard.Header>
|
{enabled ? <Toggle.Checkbox /> : null}
|
||||||
</View>
|
</>
|
||||||
{enabled ? <Toggle.Checkbox /> : null}
|
)}
|
||||||
</Toggle.Item>
|
</Toggle.Item>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export const MAX_DRAFT_GRAPHEME_LENGTH = 1000
|
|||||||
|
|
||||||
export const MAX_DM_GRAPHEME_LENGTH = 1000
|
export const MAX_DM_GRAPHEME_LENGTH = 1000
|
||||||
|
|
||||||
|
export const MAX_GROUP_NAME_GRAPHEME_LENGTH = 50
|
||||||
|
|
||||||
// Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html
|
// Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html
|
||||||
// but increasing limit per user feedback
|
// but increasing limit per user feedback
|
||||||
export const MAX_ALT_TEXT = 2000
|
export const MAX_ALT_TEXT = 2000
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {atoms as a} from '#/alf'
|
import {MAX_GROUP_NAME_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||||
|
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import type * as Dialog from '#/components/Dialog'
|
import type * as Dialog from '#/components/Dialog'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function EditNamePrompt({
|
export function EditNamePrompt({
|
||||||
control,
|
control,
|
||||||
@@ -17,8 +20,14 @@ export function EditNamePrompt({
|
|||||||
onChangeText: (value: string) => void
|
onChangeText: (value: string) => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
const nameTooLong = isOverMaxGraphemeCount({
|
||||||
|
text: value,
|
||||||
|
maxCount: MAX_GROUP_NAME_GRAPHEME_LENGTH,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Prompt.Outer control={control}>
|
<Prompt.Outer control={control}>
|
||||||
<>
|
<>
|
||||||
@@ -27,7 +36,7 @@ export function EditNamePrompt({
|
|||||||
<Trans>Edit group name</Trans>
|
<Trans>Edit group name</Trans>
|
||||||
</Prompt.TitleText>
|
</Prompt.TitleText>
|
||||||
<View style={[a.my_sm]}>
|
<View style={[a.my_sm]}>
|
||||||
<TextField.Root isInvalid={false}>
|
<TextField.Root isInvalid={nameTooLong}>
|
||||||
<TextField.Input
|
<TextField.Input
|
||||||
label={l`Edit group name`}
|
label={l`Edit group name`}
|
||||||
placeholder={l`Group name`}
|
placeholder={l`Group name`}
|
||||||
@@ -38,13 +47,31 @@ export function EditNamePrompt({
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
autoFocus
|
autoFocus
|
||||||
onSubmitEditing={onConfirm}
|
onSubmitEditing={nameTooLong ? undefined : onConfirm}
|
||||||
/>
|
/>
|
||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
|
{nameTooLong ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.mt_xs,
|
||||||
|
a.font_semi_bold,
|
||||||
|
{color: t.palette.negative_400},
|
||||||
|
]}>
|
||||||
|
<Trans>
|
||||||
|
Group name is too long. The maximum number of characters is{' '}
|
||||||
|
{MAX_GROUP_NAME_GRAPHEME_LENGTH}.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</Prompt.Content>
|
</Prompt.Content>
|
||||||
<Prompt.Actions>
|
<Prompt.Actions>
|
||||||
<Prompt.Action cta={l`Save`} onPress={onConfirm} />
|
<Prompt.Action
|
||||||
|
cta={l`Save`}
|
||||||
|
onPress={onConfirm}
|
||||||
|
disabled={nameTooLong}
|
||||||
|
/>
|
||||||
<Prompt.Cancel />
|
<Prompt.Cancel />
|
||||||
</Prompt.Actions>
|
</Prompt.Actions>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user