[Chat] Gate group chats for under-18 users (#10548)

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2026-06-09 16:31:22 +03:00
committed by GitHub
parent 2cb225dddd
commit 84c4223ff9
16 changed files with 180 additions and 96 deletions
@@ -20,6 +20,7 @@ import {useMessagesEventBus} from '#/state/messages/events'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAgent, useSession} from '#/state/session'
import {parseConvoView} from '#/components/dms/util'
import {useAgeAssurance} from '#/ageAssurance'
import * as bsky from '#/types/bsky'
import {RQKEY as CONVO_KEY} from './conversation'
import {useLeftConvos} from './leave-conversation'
@@ -122,10 +123,12 @@ export function ListConvosProviderInner({
}: {
children: React.ReactNode
}) {
const aa = useAgeAssurance()
const {refetch, data} = useListConvosQuery({
readState: 'unread',
limit: UNREAD_LIMIT,
lockStatus: 'unlocked',
kind: aa.flags.groupChatDisabled ? 'direct' : 'all',
})
const messagesBus = useMessagesEventBus()
const queryClient = useQueryClient()
@@ -3,23 +3,71 @@ import {type ChatBskyActorDeclaration} from '@atproto/api'
import {networkRetry} from '#/lib/async/retry'
import {logger} from '#/logger'
import {setOtherRequiredDataActorDeclarationCache} from '#/ageAssurance/data'
import {
getDidFromAgentSession,
getOtherRequiredDataFromCache,
setOtherRequiredDataActorDeclarationCache,
} from '#/ageAssurance/data'
/**
* Helper to update the chat settings record.
* Updates the chat actor declaration record to restrict who can contact the
* user. Both restrictions write to the same record (`rkey: 'self'`), so this
* is a single helper to avoid two concurrent `putRecord` calls racing each
* other and clobbering one another's changes.
*
* - `restrictIncoming`: sets `allowIncoming: 'none'` (used when a user isn't
* age-assured).
* - `restrictGroupInvites`: sets `allowGroupInvites: 'none'` (used for under-18
* users, who per spec cannot participate in group chats).
*
* Dimensions that aren't being restricted preserve their cached value, falling
* back to the lexicon defaults when the cache is empty.
*/
export async function restrictChatSettings({
agent,
did,
restrictIncoming = false,
restrictGroupInvites = false,
}: {
agent: AtpAgent
did: string
restrictIncoming?: boolean
restrictGroupInvites?: boolean
}): Promise<void> {
const did = getDidFromAgentSession(agent)
if (!did) return
const cached = getOtherRequiredDataFromCache({did})?.actorDeclaration
// When the cache is empty we fall back to defaults for any dimension we're
// not explicitly restricting, which could drop/downgrade a value the user
// has actually set server-side. The cache should be hydrated by
// prefetchOtherRequiredData before any of these paths fire, so log if that
// assumption ever breaks. (Restricting both dimensions is unaffected, so
// don't warn for the common signup/birthdate-change case.)
if (!cached && (!restrictIncoming || !restrictGroupInvites)) {
logger.warn(
`restrictChatSettings: cache miss, falling back to defaults for unrestricted dimensions`,
)
}
const record: ChatBskyActorDeclaration.Main = {
$type: 'chat.bsky.actor.declaration',
allowIncoming: restrictIncoming
? 'none'
: (cached?.allowIncoming ?? 'following'),
allowGroupInvites: restrictGroupInvites
? 'none'
: cached?.allowGroupInvites,
}
// Nothing to do if the record already reflects the desired restrictions.
if (
cached?.allowIncoming === record.allowIncoming &&
cached?.allowGroupInvites === record.allowGroupInvites
) {
return
}
try {
const record: ChatBskyActorDeclaration.Main = {
$type: 'chat.bsky.actor.declaration',
allowIncoming: 'none',
}
await networkRetry(3, () =>
agent.com.atproto.repo.putRecord({
repo: did,
+14 -1
View File
@@ -1,9 +1,22 @@
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
import {DEFAULT_LABEL_SETTINGS} from '@atproto/api'
import {
type ThreadViewPreferences,
type UsePreferencesQueryResponse,
} from '#/state/queries/preferences/types'
/**
* More strict than our default settings for logged in users.
*
* Defined here rather than in `./moderation` to avoid a module-init cycle:
* `moderation` imports `./index`, which re-exports this file, so reading the
* value from `moderation` at init time lands in its temporal dead zone.
*/
export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: typeof DEFAULT_LABEL_SETTINGS =
Object.fromEntries(
Object.entries(DEFAULT_LABEL_SETTINGS).map(([key, _pref]) => [key, 'hide']),
)
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
{
hideReplies: false,
+1 -13
View File
@@ -1,22 +1,10 @@
import {useMemo} from 'react'
import {
BskyAgent,
DEFAULT_LABEL_SETTINGS,
interpretLabelValueDefinitions,
} from '@atproto/api'
import {BskyAgent, interpretLabelValueDefinitions} from '@atproto/api'
import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities'
import {useLabelersDetailedInfoQuery} from '../labeler'
import {usePreferencesQuery} from './index'
/**
* More strict than our default settings for logged in users.
*/
export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: typeof DEFAULT_LABEL_SETTINGS =
Object.fromEntries(
Object.entries(DEFAULT_LABEL_SETTINGS).map(([key, _pref]) => [key, 'hide']),
)
export function useMyLabelersQuery({
excludeNonConfigurableLabelers = false,
}: {