diff --git a/src/components/dms/InitiateChatFlow.tsx b/src/components/dms/InitiateChatFlow.tsx index 32da1e3e5b..4958597130 100644 --- a/src/components/dms/InitiateChatFlow.tsx +++ b/src/components/dms/InitiateChatFlow.tsx @@ -34,6 +34,7 @@ import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Ti import * as ProfileCard from '#/components/ProfileCard' import * as Prompt from '#/components/Prompt' import {Text} from '#/components/Typography' +import {useAgeAssurance} from '#/ageAssurance' import {IS_NATIVE, IS_WEB} from '#/env' import type * as bsky from '#/types/bsky' import {ChatProfileTabs} from './ChatProfileTabs' @@ -207,6 +208,7 @@ export function InitiateChatFlow({ const [footerHeight, setFooterHeight] = useState(0) const listRef = useRef(null) const {currentAccount} = useSession() + const aa = useAgeAssurance() const inputRef = useRef(null) const accountTooNewPromptControl = Dialog.useDialogControl() @@ -327,7 +329,11 @@ export function InitiateChatFlow({ }) } - if (chatState === ChatState.NEW_CHAT && searchText === '') { + if ( + chatState === ChatState.NEW_CHAT && + searchText === '' && + !aa.flags.isDeclaredUnderAdultAge + ) { _items.unshift({type: 'newGroupChat', key: 'newGroupChat'}) } @@ -341,6 +347,7 @@ export function InitiateChatFlow({ results, currentAccount?.did, follows, + aa.flags.isDeclaredUnderAdultAge, ]) if (searchText && !isFetching && !items.length && !isError) { diff --git a/src/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx index 83c4b0c004..3c9ed6a5cb 100644 --- a/src/screens/Messages/ChatList.tsx +++ b/src/screens/Messages/ChatList.tsx @@ -198,6 +198,7 @@ export function ChatList({ }) { const t = useTheme() const {t: l} = useLingui() + const aa = useAgeAssurance() const scrollElRef: ListRef = useAnimatedRef() const {isWithinSplitView} = useIsWithinSplitView() @@ -230,6 +231,7 @@ export function ChatList({ const {refetch: refetchInbox} = useListConvosQuery({ status: 'request', + kind: aa.flags.isDeclaredUnderAdultAge ? 'direct' : 'all', }) useRefreshOnFocus(refetch) @@ -449,6 +451,7 @@ export function Header({ }) { const {t: l} = useLingui() const {gtMobile} = useBreakpoints() + const aa = useAgeAssurance() const requireEmailVerification = useRequireEmailVerification() const leftConvos = useLeftConvos() @@ -456,6 +459,7 @@ export function Header({ useListConvosQuery({ status: 'request', readState: 'unread', + kind: aa.flags.isDeclaredUnderAdultAge ? 'direct' : 'all', }) const inboxAllConvos = diff --git a/src/screens/Messages/Inbox.tsx b/src/screens/Messages/Inbox.tsx index 55d3b0f4d7..df388c3b72 100644 --- a/src/screens/Messages/Inbox.tsx +++ b/src/screens/Messages/Inbox.tsx @@ -43,6 +43,7 @@ import * as Layout from '#/components/Layout' import {ListFooter} from '#/components/Lists' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {useAgeAssurance} from '#/ageAssurance' import {IS_NATIVE} from '#/env' import {RequestListItem} from './components/RequestListItem' import {useIsWithinSplitView} from './components/splitView/context' @@ -63,8 +64,12 @@ export function MessagesInboxScreen(props: Props) { export function MessagesInboxScreenInner({}: Props) { const {gtTablet} = useBreakpoints() + const aa = useAgeAssurance() - const listConvosQuery = useListConvosQuery({status: 'request'}) + const listConvosQuery = useListConvosQuery({ + status: 'request', + kind: aa.flags.isDeclaredUnderAdultAge ? 'direct' : 'all', + }) const {data} = listConvosQuery const leftConvos = useLeftConvos() diff --git a/src/screens/Messages/Settings.tsx b/src/screens/Messages/Settings.tsx index f49311b0da..04c530aecc 100644 --- a/src/screens/Messages/Settings.tsx +++ b/src/screens/Messages/Settings.tsx @@ -20,6 +20,7 @@ import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/compon import * as Layout from '#/components/Layout' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {useAgeAssurance} from '#/ageAssurance' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' import {useBackgroundNotificationPreferences} from '../../../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' @@ -45,6 +46,7 @@ export function MessagesSettingsScreenInner({}: Props) { const t = useTheme() const {t: l} = useLingui() const ax = useAnalytics() + const aa = useAgeAssurance() const {currentAccount} = useSession() const {data: profile} = useProfileQuery({ did: currentAccount!.did, @@ -54,6 +56,7 @@ export function MessagesSettingsScreenInner({}: Props) { const exportCarControl = Dialog.useDialogControl() const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable) + const groupInvitesLocked = aa.flags.isDeclaredUnderAdultAge const allowMessagesFromOptions: {name: AllowIncoming; label: string}[] = [ { @@ -191,17 +194,26 @@ export function MessagesSettingsScreenInner({}: Props) { a.leading_snug, t.atoms.text_contrast_high, ]}> - - You can continue ongoing conversations regardless of which - setting you choose. - + {groupInvitesLocked ? ( + + Group chats are only available to users 18 and over. + + ) : ( + + You can continue ongoing conversations regardless of which + setting you choose. + + )} diff --git a/src/state/birthdate.ts b/src/state/birthdate.ts index 71ca871df3..1dd5bac1cc 100644 --- a/src/state/birthdate.ts +++ b/src/state/birthdate.ts @@ -1,6 +1,7 @@ import {useMemo} from 'react' import {useMutation, useQueryClient} from '@tanstack/react-query' +import {restrictGroupChatSettings} from '#/state/queries/messages/restrictChatSettings' import {preferencesQueryKey} from '#/state/queries/preferences' import {useAgent, useSession} from '#/state/session' import {usePatchAgeAssuranceOtherRequiredData} from '#/ageAssurance' @@ -67,6 +68,10 @@ export function useBirthdateMutation() { if (isUnderAge(birthDate.toISOString(), 18)) { maybeRestrictChatSettings({agent}) + const did = agent.sessionManager.did + if (did) { + await restrictGroupChatSettings({agent, did}) + } } /** diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx index f026e57d33..49454b2fc6 100644 --- a/src/state/queries/messages/list-conversations.tsx +++ b/src/state/queries/messages/list-conversations.tsx @@ -14,6 +14,7 @@ import { } from '@tanstack/react-query' import throttle from 'lodash.throttle' +import {useAgeAssurance} from '#/ageAssurance' import {DM_SERVICE_HEADERS} from '#/lib/constants' import {useCurrentConvoId} from '#/state/messages/current-convo-id' import {useMessagesEventBus} from '#/state/messages/events' @@ -115,10 +116,12 @@ export function ListConvosProviderInner({ }: { children: React.ReactNode }) { + const aa = useAgeAssurance() const {refetch, data} = useListConvosQuery({ readState: 'unread', limit: UNREAD_LIMIT, lockStatus: 'unlocked', + kind: aa.flags.isDeclaredUnderAdultAge ? 'direct' : 'all', }) const messagesBus = useMessagesEventBus() const queryClient = useQueryClient() diff --git a/src/state/queries/messages/restrictChatSettings.ts b/src/state/queries/messages/restrictChatSettings.ts index 3a1e64eff1..89acf83028 100644 --- a/src/state/queries/messages/restrictChatSettings.ts +++ b/src/state/queries/messages/restrictChatSettings.ts @@ -3,7 +3,10 @@ import {type ChatBskyActorDeclaration} from '@atproto/api' import {networkRetry} from '#/lib/async/retry' import {logger} from '#/logger' -import {setOtherRequiredDataActorDeclarationCache} from '#/ageAssurance/data' +import { + getOtherRequiredDataFromCache, + setOtherRequiredDataActorDeclarationCache, +} from '#/ageAssurance/data' /** * Helper to update the chat settings record. @@ -37,3 +40,44 @@ export async function restrictChatSettings({ logger.error(`restrictChatSettings: failed to set chat declaration`) } } + +/** + * Locks the user out of being added to group chats by setting + * `allowGroupInvites: 'none'` on the chat actor declaration. Used for under-18 + * users, who per spec cannot participate in group chats. + * + * Preserves the existing `allowIncoming` value if one is cached, otherwise + * defaults to 'following' (the lexicon default) since the field is required. + */ +export async function restrictGroupChatSettings({ + agent, + did, +}: { + agent: AtpAgent + did: string +}): Promise { + const cached = getOtherRequiredDataFromCache({did})?.actorDeclaration + if (cached?.allowGroupInvites === 'none') return + + try { + const record: ChatBskyActorDeclaration.Main = { + $type: 'chat.bsky.actor.declaration', + allowIncoming: cached?.allowIncoming ?? 'following', + allowGroupInvites: 'none', + } + await networkRetry(3, () => + agent.com.atproto.repo.putRecord({ + repo: did, + collection: 'chat.bsky.actor.declaration', + rkey: 'self', + record, + }), + ) + setOtherRequiredDataActorDeclarationCache({ + did, + actorDeclaration: record, + }) + } catch { + logger.error(`restrictGroupChatSettings: failed to set chat declaration`) + } +} diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 9f2d70927b..ff57002566 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -19,9 +19,13 @@ import { PUBLIC_BSKY_SERVICE, TIMELINE_SAVED_FEED, } from '#/lib/constants' +import {getAge} from '#/lib/strings/time' import {logger} from '#/logger' import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate' -import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings' +import { + restrictChatSettings, + restrictGroupChatSettings, +} from '#/state/queries/messages/restrictChatSettings' import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' import { prefetchAgeAssuranceData, @@ -223,6 +227,9 @@ export async function createAgentAndCreateAccount( if (state.access !== AgeAssuranceAccess.Full) { restrictChatSettings({agent, did: account.did}) } + if (getAge(birthDate) < 18) { + restrictGroupChatSettings({agent, did: account.did}) + } }), ]).then(promises => { const rejected = promises.filter(p => p.status === 'rejected')