From b8cabfaae6bbd1825772421095097bff45d88d48 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 20 Apr 2026 09:33:38 -0700 Subject: [PATCH 1/8] Skip empty posts when publishing threads (#10307) Co-authored-by: Claude Opus 4.6 (1M context) --- src/view/com/composer/Composer.tsx | 91 ++++++++++++++++++++++++------ 1 file changed, 75 insertions(+), 16 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index d12b3a0bea..2801239a81 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -207,6 +207,8 @@ export const ComposePost = ({ const setLangPrefs = useLanguagePrefsApi() const textInputRef = useRef(null) const discardPromptControl = Prompt.usePromptControl() + const emptyPostsPromptControl = Prompt.usePromptControl() + const skipEmptyConfirmedRef = useRef(false) const {mutateAsync: saveDraft, isPending: _isSavingDraft} = useSaveDraftMutation() const {mutate: cleanupPublishedDraft} = useCleanupPublishedDraftMutation() @@ -783,16 +785,47 @@ export const ComposePost = ({ const canPost = !missingAltError && + thread.posts.some(post => !isEmptyPost(post)) && thread.posts.every( post => - post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH && - !isEmptyPost(post) && - !( - post.embed.media?.type === 'video' && - post.embed.media.video.status === 'error' - ), + isEmptyPost(post) || + (post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH && + !( + post.embed.media?.type === 'video' && + post.embed.media.video.status === 'error' + )), ) + const getFilteredThread = (): { + type: 'none' | 'trailing-only' | 'non-trailing' + filteredThread: ThreadDraft + } => { + const nonEmptyPosts = thread.posts.filter(post => !isEmptyPost(post)) + + if (nonEmptyPosts.length === thread.posts.length) { + return {type: 'none', filteredThread: thread} + } + + let lastNonEmptyIndex = -1 + for (let i = thread.posts.length - 1; i >= 0; i--) { + if (!isEmptyPost(thread.posts[i])) { + lastNonEmptyIndex = i + break + } + } + + const hasNonTrailingEmpty = thread.posts.some( + (post, i) => i < lastNonEmptyIndex && isEmptyPost(post), + ) + + const filteredThread: ThreadDraft = {...thread, posts: nonEmptyPosts} + + return { + type: hasNonTrailingEmpty ? 'non-trailing' : 'trailing-only', + filteredThread, + } + } + const onPressPublish = useCallback(async () => { if (isPublishing) { return @@ -802,8 +835,15 @@ export const ComposePost = ({ return } + const {type: emptyType, filteredThread} = getFilteredThread() + + if (emptyType === 'non-trailing' && !skipEmptyConfirmedRef.current) { + emptyPostsPromptControl.open() + return + } + if ( - thread.posts.some( + filteredThread.posts.some( post => post.embed.media?.type === 'video' && post.embed.media.video.asset && @@ -814,6 +854,7 @@ export const ComposePost = ({ return } + skipEmptyConfirmedRef.current = false setError('') setIsPublishing(true) @@ -826,7 +867,7 @@ export const ComposePost = ({ agent, queryClient, { - thread, + thread: filteredThread, replyTo: replyTo?.uri, onStateChange: setPublishingStage, langs: currentLanguages, @@ -857,10 +898,10 @@ export const ComposePost = ({ const res = await agent.app.bsky.unspecced.getPostThreadV2({ anchor: postUri!, above: false, - below: thread.posts.length - 1, + below: filteredThread.posts.length - 1, branchingFactor: 1, }) - if (res.data.thread.length !== thread.posts.length) { + if (res.data.thread.length !== filteredThread.posts.length) { throw new Error(`composer: app view is not ready`) } if ( @@ -887,7 +928,9 @@ export const ComposePost = ({ } catch (e: any) { logger.error(e, { message: `Composer: create post failed`, - hasImages: thread.posts.some(p => p.embed.media?.type === 'images'), + hasImages: filteredThread.posts.some( + p => p.embed.media?.type === 'images', + ), }) let err = cleanError(e.message) @@ -902,14 +945,14 @@ export const ComposePost = ({ } finally { if (postUri) { let index = 0 - for (let post of thread.posts) { + for (let post of filteredThread.posts) { ax.metric('post:create', { imageCount: post.embed.media?.type === 'images' ? post.embed.media.images.length : 0, isReply: index > 0 || !!replyTo, - isPartOfThread: thread.posts.length > 1, + isPartOfThread: filteredThread.posts.length > 1, hasLink: !!post.embed.link, hasQuote: !!post.embed.quote, langs: fromPostLanguages(currentLanguages), @@ -918,9 +961,9 @@ export const ComposePost = ({ index++ } } - if (thread.posts.length > 1) { + if (filteredThread.posts.length > 1) { ax.metric('thread:create', { - postCount: thread.posts.length, + postCount: filteredThread.posts.length, isReply: !!replyTo, }) } @@ -973,7 +1016,7 @@ export const ComposePost = ({ - {thread.posts.length > 1 + {filteredThread.posts.length > 1 ? l`Your posts were sent` : replyTo ? l`Your reply was sent` @@ -1016,8 +1059,14 @@ export const ComposePost = ({ composerState.isDirty, cleanupPublishedDraft, loadedDraftCreatedAt, + emptyPostsPromptControl, ]) + const handleConfirmSkipEmpty = () => { + skipEmptyConfirmedRef.current = true + void onPressPublish() + } + // Preserves the referential identity passed to each post item. // Avoids re-rendering all posts on each keystroke. const onComposerPostPublish = useNonReactiveCallback(() => { @@ -1029,6 +1078,7 @@ export const ComposePost = ({ let erroredVideos = 0 let uploadingVideos = 0 for (let post of thread.posts) { + if (isEmptyPost(post)) continue if (post.embed.media?.type === 'video') { const video = post.embed.media.video if (video.status === 'error') { @@ -1268,6 +1318,15 @@ export const ComposePost = ({ )} + + ) From ae0c2e8697f3b0422980eb2db9a35355cf9b5683 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 20 Apr 2026 11:09:35 -0700 Subject: [PATCH 2/8] [Web] Scale animation when clicking on images (#10305) --- src/components/images/AutoSizedImage.tsx | 20 +++++++++++++++++--- src/components/images/Gallery/index.tsx | 17 +++++++++++------ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/components/images/AutoSizedImage.tsx b/src/components/images/AutoSizedImage.tsx index 3d4856adac..2df8d056d6 100644 --- a/src/components/images/AutoSizedImage.tsx +++ b/src/components/images/AutoSizedImage.tsx @@ -13,7 +13,7 @@ import {Trans} from '@lingui/react/macro' import {type Dimensions} from '#/lib/media/types' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, useTheme, web} from '#/alf' import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {Text} from '#/components/Typography' @@ -210,12 +210,17 @@ export function AutoSizedImage({ color: utils.alpha(t.atoms.bg.backgroundColor, 0.2), foreground: true, }} - style={[ + style={({pressed}) => [ a.w_full, a.rounded_md, a.overflow_hidden, t.atoms.bg_contrast_25, {aspectRatio: max ?? 1}, + web([ + a.transition_transform, + {transitionDuration: '200ms'}, + pressed && {transform: [{scale: 0.99}]}, + ]), ]}> {contents} @@ -237,7 +242,16 @@ export function AutoSizedImage({ color: utils.alpha(t.atoms.bg.backgroundColor, 0.2), foreground: true, }} - style={[a.h_full]}> + style={({pressed}) => [ + a.h_full, + a.rounded_md, + a.overflow_hidden, + web([ + a.transition_transform, + {transitionDuration: '200ms'}, + pressed && {transform: [{scale: 0.99}]}, + ]), + ]}> {contents} diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx index fce785f75b..1566f09736 100644 --- a/src/components/images/Gallery/index.tsx +++ b/src/components/images/Gallery/index.tsx @@ -430,15 +430,20 @@ function GalleryImage({ color: utils.alpha(t.atoms.bg.backgroundColor, 0.2), foreground: true, }} - style={[ + style={({pressed}) => [ a.rounded_md, a.overflow_hidden, t.atoms.bg_contrast_25, - web({ - cursor: 'inherit', - outline: 0, - border: 0, - }), + web([ + { + cursor: 'inherit', + outline: 0, + border: 0, + }, + a.transition_transform, + {transitionDuration: '200ms'}, + pressed && {transform: [{scale: 0.99}]}, + ]), ]}> Date: Mon, 20 Apr 2026 11:34:25 -0700 Subject: [PATCH 3/8] Delete "non-standard" styles from `pal` (#10279) --- src/lib/ThemeContext.tsx | 2 - src/lib/hooks/usePalette.ts | 8 -- src/lib/styles.ts | 2 - src/lib/themes.ts | 49 -------- .../notifications/NotificationFeedItem.tsx | 14 +-- src/view/com/post/Post.tsx | 26 +++-- src/view/com/posts/PostFeedItem.tsx | 18 ++- src/view/com/posts/ViewFullThread.tsx | 107 +++++++++--------- 8 files changed, 88 insertions(+), 138 deletions(-) diff --git a/src/lib/ThemeContext.tsx b/src/lib/ThemeContext.tsx index d499910a79..19abb9a7f8 100644 --- a/src/lib/ThemeContext.tsx +++ b/src/lib/ThemeContext.tsx @@ -21,8 +21,6 @@ export type PaletteColor = { textInverted: string link: string border: string - borderDark: string - icon: string [k: string]: string } export type Palette = Record diff --git a/src/lib/hooks/usePalette.ts b/src/lib/hooks/usePalette.ts index db226fba65..d7cf26f472 100644 --- a/src/lib/hooks/usePalette.ts +++ b/src/lib/hooks/usePalette.ts @@ -13,12 +13,10 @@ export interface UsePaletteValue { viewLight: ViewStyle btn: ViewStyle border: ViewStyle - borderDark: ViewStyle text: TextStyle textLight: TextStyle textInverted: TextStyle link: TextStyle - icon: TextStyle } /** @@ -42,9 +40,6 @@ export function usePalette(color: PaletteColorName): UsePaletteValue { border: { borderColor: palette.border, }, - borderDark: { - borderColor: palette.borderDark, - }, text: { color: palette.text, }, @@ -57,9 +52,6 @@ export function usePalette(color: PaletteColorName): UsePaletteValue { link: { color: palette.link, }, - icon: { - color: palette.icon, - }, } }, [theme, color]) } diff --git a/src/lib/styles.ts b/src/lib/styles.ts index 8500632e37..03822b6c13 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -54,8 +54,6 @@ export const colors = { green3: '#20bc07', green4: '#148203', green5: '#082b03', - - unreadNotifBg: '#ebf6ff', } /** diff --git a/src/lib/themes.ts b/src/lib/themes.ts index d99dd37b2c..1df0a555fe 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -17,19 +17,6 @@ export const defaultTheme: Theme = { textInverted: lightPalette.white, link: lightPalette.primary_500, border: lightPalette.contrast_100, - borderDark: lightPalette.contrast_200, - icon: lightPalette.contrast_500, - - // non-standard - textVeryLight: lightPalette.contrast_400, - replyLine: lightPalette.contrast_100, - replyLineDot: lightPalette.contrast_200, - unreadNotifBg: lightPalette.primary_25, - unreadNotifBorder: lightPalette.primary_100, - postCtrl: lightPalette.contrast_500, - brandText: lightPalette.primary_500, - emptyStateIcon: lightPalette.contrast_300, - borderLinkHover: lightPalette.contrast_300, }, primary: { background: colors.blue3, @@ -39,8 +26,6 @@ export const defaultTheme: Theme = { textInverted: colors.blue3, link: colors.blue0, border: colors.blue4, - borderDark: colors.blue5, - icon: colors.blue4, }, secondary: { background: colors.green3, @@ -50,8 +35,6 @@ export const defaultTheme: Theme = { textInverted: colors.green4, link: colors.green1, border: colors.green4, - borderDark: colors.green5, - icon: colors.green4, }, inverted: { background: darkPalette.black, @@ -61,8 +44,6 @@ export const defaultTheme: Theme = { textInverted: darkPalette.black, link: darkPalette.primary_500, border: darkPalette.contrast_100, - borderDark: darkPalette.contrast_200, - icon: darkPalette.contrast_500, }, error: { background: colors.red3, @@ -72,8 +53,6 @@ export const defaultTheme: Theme = { textInverted: colors.red3, link: colors.red1, border: colors.red4, - borderDark: colors.red5, - icon: colors.red4, }, }, shapes: { @@ -303,19 +282,6 @@ export const darkTheme: Theme = { textInverted: darkPalette.black, link: darkPalette.primary_500, border: darkPalette.contrast_100, - borderDark: darkPalette.contrast_200, - icon: darkPalette.contrast_500, - - // non-standard - textVeryLight: darkPalette.contrast_400, - replyLine: darkPalette.contrast_200, - replyLineDot: darkPalette.contrast_200, - unreadNotifBg: darkPalette.primary_25, - unreadNotifBorder: darkPalette.primary_100, - postCtrl: darkPalette.contrast_500, - brandText: darkPalette.primary_500, - emptyStateIcon: darkPalette.contrast_300, - borderLinkHover: darkPalette.contrast_300, }, primary: { ...defaultTheme.palette.primary, @@ -333,8 +299,6 @@ export const darkTheme: Theme = { textInverted: darkPalette.white, link: lightPalette.primary_500, border: lightPalette.contrast_100, - borderDark: lightPalette.contrast_200, - icon: lightPalette.contrast_500, }, }, } @@ -352,19 +316,6 @@ export const dimTheme: Theme = { textInverted: dimPalette.black, link: dimPalette.primary_500, border: dimPalette.contrast_100, - borderDark: dimPalette.contrast_200, - icon: dimPalette.contrast_500, - - // non-standard - textVeryLight: dimPalette.contrast_400, - replyLine: dimPalette.contrast_200, - replyLineDot: dimPalette.contrast_200, - unreadNotifBg: dimPalette.primary_25, - unreadNotifBorder: dimPalette.primary_100, - postCtrl: dimPalette.contrast_500, - brandText: dimPalette.primary_500, - emptyStateIcon: dimPalette.contrast_300, - borderLinkHover: dimPalette.contrast_300, }, }, } diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index 79a8d746e8..5f337044e5 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -26,7 +26,6 @@ import {useQueryClient} from '@tanstack/react-query' import {DM_SERVICE_HEADERS, MAX_POST_LINES} from '#/lib/constants' import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue' -import {usePalette} from '#/lib/hooks/usePalette' import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' import {forceLTR} from '#/lib/strings/bidi' @@ -92,10 +91,9 @@ let NotificationFeedItem = ({ hideTopBorder?: boolean }): React.ReactNode => { const queryClient = useQueryClient() - const pal = usePalette('default') const t = useTheme() const {_, i18n} = useLingui() - const [isAuthorsExpanded, setAuthorsExpanded] = useState(false) + const [isAuthorsExpanded, setIsAuthorsExpanded] = useState(false) const itemHref = useMemo(() => { switch (item.type) { case 'post-like': @@ -145,7 +143,7 @@ let NotificationFeedItem = ({ e.preventDefault() e.stopPropagation() } - setAuthorsExpanded(currentlyExpanded => !currentlyExpanded) + setIsAuthorsExpanded(currentlyExpanded => !currentlyExpanded) } const onBeforePress = useCallback(() => { @@ -222,8 +220,8 @@ let NotificationFeedItem = ({ post={item.subject} style={ isHighlighted && { - backgroundColor: pal.colors.unreadNotifBg, - borderColor: pal.colors.unreadNotifBorder, + backgroundColor: t.palette.primary_25, + borderColor: t.palette.primary_100, } } hideTopBorder={hideTopBorder} @@ -577,8 +575,8 @@ let NotificationFeedItem = ({ item.notification.isRead ? undefined : { - backgroundColor: pal.colors.unreadNotifBg, - borderColor: pal.colors.unreadNotifBorder, + backgroundColor: t.palette.primary_25, + borderColor: t.palette.primary_100, }, !hideTopBorder && a.border_t, a.overflow_hidden, diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 052de3bab3..62e7cb2aae 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -12,10 +12,8 @@ import {useQueryClient} from '@tanstack/react-query' import {MAX_POST_LINES} from '#/lib/constants' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' -import {usePalette} from '#/lib/hooks/usePalette' import {makeProfileLink} from '#/lib/routes/links' import {countLines} from '#/lib/strings/helpers' -import {colors} from '#/lib/styles' import { POST_TOMBSTONE, type Shadow, @@ -26,7 +24,7 @@ import {unstableCacheProfileView} from '#/state/queries/profile' import {Link} from '#/view/com/util/Link' import {PostMeta} from '#/view/com/util/PostMeta' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a} from '#/alf' +import {atoms as a, select, useTheme} from '#/alf' import { GalleryBleed, maybeApplyGalleryOffsetStyles, @@ -119,7 +117,7 @@ function PostInner({ onBeforePress?: () => void }) { const queryClient = useQueryClient() - const pal = usePalette('default') + const t = useTheme() const {openComposer} = useOpenComposer() const [limitLines, setLimitLines] = useState( () => countLines(richText?.text) >= MAX_POST_LINES, @@ -164,8 +162,8 @@ function PostInner({ href={itemHref} style={[ styles.outer, - pal.border, - !hideTopBorder && {borderTopWidth: StyleSheet.hairlineWidth}, + t.atoms.border_contrast_low, + !hideTopBorder && a.border_t, style, ]} onBeforePress={onBeforePress} @@ -176,7 +174,20 @@ function PostInner({ setHover(false) }}> - {showReplyLine && } + {showReplyLine && ( + + )} { const urip = new AtUri(uri) return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey) }, [uri]) - const {_} = useLingui() + const {t: l} = useLingui() return ( - - - - + {({hovered}) => ( + <> + - - - - - - - - {/* HACKFIX: Trans isn't working after SDK 53 upgrade -sfn */} - {_(msg`View full thread`)} - + + + + + + + + + + {/* HACKFIX: Trans isn't working after SDK 53 upgrade -sfn */} + {l`View full thread`} + + + )} ) } - -const styles = StyleSheet.create({ - viewFullThread: { - flexDirection: 'row', - gap: 10, - paddingLeft: 18, - }, - viewFullThreadDots: { - width: 42, - alignItems: 'center', - }, -}) From d58ff89441c4d892007613a4d7030a6202a79a18 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:34:32 -0700 Subject: [PATCH 4/8] Require age assurance to access chat settings (#10053) --- src/screens/Messages/ChatList.tsx | 22 +++++++++++++--------- src/screens/Messages/Settings.tsx | 13 ++++++++++++- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx index 3463b04ed0..1e842fcc0e 100644 --- a/src/screens/Messages/ChatList.tsx +++ b/src/screens/Messages/ChatList.tsx @@ -38,6 +38,7 @@ import * as Layout from '#/components/Layout' import {Link} from '#/components/Link' import {ListFooter} from '#/components/Lists' import {Text} from '#/components/Typography' +import {useAgeAssurance} from '#/ageAssurance' import {IS_NATIVE} from '#/env' import {ChatListItem} from './components/ChatListItem' import {InboxPreview} from './components/InboxPreview' @@ -71,21 +72,24 @@ type Props = NativeStackScreenProps export function MessagesScreen(props: Props) { const {_} = useLingui() const aaCopy = useAgeAssuranceCopy() + const aa = useAgeAssurance() return ( - - Chat settings - - + aa.flags.chatDisabled ? null : ( + + + Chat settings + + + ) }> diff --git a/src/screens/Messages/Settings.tsx b/src/screens/Messages/Settings.tsx index 12e2fd5c2c..a43da45b6d 100644 --- a/src/screens/Messages/Settings.tsx +++ b/src/screens/Messages/Settings.tsx @@ -11,6 +11,8 @@ import {useProfileQuery} from '#/state/queries/profile' import {useSession} from '#/state/session' import {atoms as a} from '#/alf' import {Admonition} from '#/components/Admonition' +import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen' +import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' import {Divider} from '#/components/Divider' import * as Toggle from '#/components/forms/Toggle' import * as Layout from '#/components/Layout' @@ -24,7 +26,16 @@ type AllowIncoming = 'all' | 'none' | 'following' type Props = NativeStackScreenProps export function MessagesSettingsScreen(props: Props) { - return + const {_} = useLingui() + const aaCopy = useAgeAssuranceCopy() + + return ( + + + + ) } export function MessagesSettingsScreenInner({}: Props) { From 8c2e4c6fadd28b7f8f4b255f778bc95a57a38505 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:39:12 -0700 Subject: [PATCH 5/8] Write chat declaration record in response to different events (#10216) Co-authored-by: Eric Bailey --- src/ageAssurance/data.tsx | 35 ++- src/ageAssurance/index.tsx | 13 +- src/ageAssurance/state.ts | 211 ++++++++++++------ src/ageAssurance/util.ts | 21 +- src/components/dialogs/BirthDateSettings.tsx | 40 ++-- src/state/birthdate.ts | 6 + .../queries/messages/actor-declaration.ts | 24 +- .../queries/messages/restrictChatSettings.ts | 39 ++++ src/state/session/__tests__/session-test.ts | 3 + src/state/session/agent.ts | 31 +-- 10 files changed, 298 insertions(+), 125 deletions(-) create mode 100644 src/state/queries/messages/restrictChatSettings.ts diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index 2839d2b488..fc7f2c2b80 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -4,6 +4,7 @@ import { type AppBskyAgeassuranceGetConfig, type AppBskyAgeassuranceGetState, AtpAgent, + type ChatBskyActorDeclaration, getAgeAssuranceRegionConfig, } from '@atproto/api' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' @@ -19,6 +20,7 @@ import { hasSnoozedBirthdateUpdateForDid, snoozeBirthdateUpdateAllowedForDid, } from '#/state/birthdate' +import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declaration' import {useAgent, useSession} from '#/state/session' import * as debug from '#/ageAssurance/debug' import {logger} from '#/ageAssurance/logger' @@ -53,7 +55,7 @@ const [, cacheHydrationPromise] = persistQueryClient({ persister, }) -function getDidFromAgentSession(agent: AtpAgent) { +export function getDidFromAgentSession(agent: AtpAgent) { const sessionManager = agent.sessionManager if (!sessionManager || !sessionManager.did) return return sessionManager.did @@ -329,19 +331,25 @@ export function useServerStateQuery() { export type OtherRequiredData = { birthdate: string | undefined + actorDeclaration?: ChatBskyActorDeclaration.Main } export function createOtherRequiredDataQueryKey({did}: {did: string}) { return ['otherRequiredData', did] } -export async function getOtherRequiredData({ +async function getOtherRequiredData({ agent, }: { agent: AtpAgent }): Promise { if (debug.enabled) return debug.resolve(debug.otherRequiredData) - const [prefs] = await Promise.all([agent.getPreferences()]) + const did = getDidFromAgentSession(agent) + const [prefs, actorDeclaration] = await Promise.all([ + agent.getPreferences(), + fetchActorDeclarationRecord({did, agent}), + ]) const data: OtherRequiredData = { birthdate: prefs.birthDate ? prefs.birthDate.toISOString() : undefined, + actorDeclaration, } /** @@ -359,7 +367,6 @@ export async function getOtherRequiredData({ } } - const did = getDidFromAgentSession(agent) if (data && did && birthdateCache.has(did)) { /* * If birthdate was just set, use the local cache value. On subsequent @@ -394,6 +401,26 @@ export function getOtherRequiredDataFromCache({ createOtherRequiredDataQueryKey({did}), ) } +export function setOtherRequiredDataActorDeclarationCache({ + did, + actorDeclaration, +}: { + did: string + actorDeclaration: ChatBskyActorDeclaration.Main +}) { + const prev = getOtherRequiredDataFromCache({did}) + const next: OtherRequiredData = { + birthdate: prev?.birthdate, + actorDeclaration: { + ...(prev?.actorDeclaration || {}), + ...actorDeclaration, + }, + } + qc.setQueryData( + createOtherRequiredDataQueryKey({did}), + next, + ) +} export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) { const did = getDidFromAgentSession(agent) diff --git a/src/ageAssurance/index.tsx b/src/ageAssurance/index.tsx index 80b44f1dcf..c64492eff6 100644 --- a/src/ageAssurance/index.tsx +++ b/src/ageAssurance/index.tsx @@ -1,6 +1,7 @@ import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications' +import {useAgent} from '#/state/session' import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay' import { AgeAssuranceDataProvider, @@ -18,6 +19,7 @@ import { } from '#/ageAssurance/types' import { isUnderAge, + maybeRestrictChatSettings, MIN_ACCESS_AGE, useAgeAssuranceRegionConfigWithFallback, } from '#/ageAssurance/util' @@ -78,6 +80,7 @@ export function Provider({children}: {children: React.ReactNode}) { } function InnerProvider({children}: {children: React.ReactNode}) { + const agent = useAgent() const state = useAgeAssuranceState() const {data} = useAgeAssuranceDataContext() const config = useAgeAssuranceRegionConfigWithFallback() @@ -85,11 +88,13 @@ function InnerProvider({children}: {children: React.ReactNode}) { const handleAccessUpdate = useCallback( (s: AgeAssuranceState) => { - void getAndRegisterPushToken({ - isAgeRestricted: s.access !== AgeAssuranceAccess.Full, - }) + const isAgeRestricted = s.access !== AgeAssuranceAccess.Full + if (isAgeRestricted) { + void getAndRegisterPushToken({isAgeRestricted}) + maybeRestrictChatSettings({agent}) + } }, - [getAndRegisterPushToken], + [agent, getAndRegisterPushToken], ) useOnAgeAssuranceAccessUpdate(handleAccessUpdate) diff --git a/src/ageAssurance/state.ts b/src/ageAssurance/state.ts index 6499a705e6..5aac40ef44 100644 --- a/src/ageAssurance/state.ts +++ b/src/ageAssurance/state.ts @@ -1,8 +1,15 @@ import {useEffect, useMemo, useState} from 'react' import {computeAgeAssuranceRegionAccess} from '@atproto/api' +import {getAge} from '#/lib/strings/time' import {useSession} from '#/state/session' -import {useAgeAssuranceDataContext} from '#/ageAssurance/data' +import { + type AgeAssuranceData, + getConfigFromCache, + getOtherRequiredDataFromCache, + getServerStateFromCache, + useAgeAssuranceDataContext, +} from '#/ageAssurance/data' import {logger} from '#/ageAssurance/logger' import { AgeAssuranceAccess, @@ -12,82 +19,144 @@ import { parseStatusFromString, } from '#/ageAssurance/types' import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util' -import {useGeolocation} from '#/geolocation' +import {type Geolocation, useGeolocation} from '#/geolocation' +import {device} from '#/storage' + +/** + * Get final evaluated age assurance state. Handles fallbacks and defers to + * server state before computing access based on AA config from the server + + * geolocation and other data. + */ +export function computeAgeAssuranceState({ + hasSession, + config, + geolocation, + state, + data, +}: { + hasSession: boolean + config: AgeAssuranceData['config'] + geolocation: Geolocation + state: AgeAssuranceData['state'] + data: AgeAssuranceData['data'] +}) { + /** + * This is where we control logged-out moderation prefs. It's all + * downstream of AA now. + */ + if (!hasSession) + return { + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.Safe, + } + + /** + * This can happen if the prefetch fails (such as due to network issues). + * The query handler will try it again, but if it continues to fail, of + * course we won't have config. + * + * In this case, fail open to avoid blocking users. + */ + if (!config) { + logger.warn('useAgeAssuranceState: missing config') + return { + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.Safe, + error: 'config' as const, + } + } + + const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation) + const isAARequired = region.countryCode !== '*' + const isTerminalState = + state?.status === 'assured' || state?.status === 'blocked' + + /* + * If we are in a terminal state and AA is required for this region, + * we can trust the server state completely and avoid recomputing. + */ + if (isTerminalState && isAARequired) { + return { + lastInitiatedAt: state.lastInitiatedAt, + status: parseStatusFromString(state.status), + access: parseAccessFromString(state.access), + } + } + + /* + * Otherwise, we need to compute the access based on the latest data. For + * accounts with an accurate birthdate, our default fallback rules should + * ensure correct access. + */ + const result = computeAgeAssuranceRegionAccess(region, data) + const computed = { + lastInitiatedAt: state?.lastInitiatedAt, + // prefer server state + status: state?.status + ? parseStatusFromString(state?.status) + : AgeAssuranceStatus.Unknown, + // prefer server state + access: result + ? parseAccessFromString(result.access) + : AgeAssuranceAccess.Full, + } + logger.debug('debug useAgeAssuranceState', { + region, + state, + data, + computed, + }) + return computed +} + +/** + * This is a last-ditch helper for out-of-band reads of the AA state, such as + * during account creation. Don't use it for anything else. + */ +export function getAndComputeAgeAssuranceState({did}: {did: string}) { + const config = getConfigFromCache() + const state = getServerStateFromCache({did}) + const data = getOtherRequiredDataFromCache({did}) + const geolocation = device.get(['mergedGeolocation']) + + if (!geolocation || !config || !state || !data) { + return { + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.Safe, + } + } + + return computeAgeAssuranceState({ + hasSession: true, + config, + geolocation, + state: state.state, + data: { + accountCreatedAt: state.metadata?.accountCreatedAt, + declaredAge: data?.birthdate + ? getAge(new Date(data.birthdate)) + : undefined, + birthdate: data?.birthdate, + }, + }) +} export function useAgeAssuranceState(): AgeAssuranceState { const {hasSession} = useSession() const geolocation = useGeolocation() const {config, state, data} = useAgeAssuranceDataContext() - return useMemo(() => { - /** - * This is where we control logged-out moderation prefs. It's all - * downstream of AA now. - */ - if (!hasSession) - return { - status: AgeAssuranceStatus.Unknown, - access: AgeAssuranceAccess.Safe, - } - - /** - * This can happen if the prefetch fails (such as due to network issues). - * The query handler will try it again, but if it continues to fail, of - * course we won't have config. - * - * In this case, fail open to avoid blocking users. - */ - if (!config) { - logger.warn('useAgeAssuranceState: missing config') - return { - status: AgeAssuranceStatus.Unknown, - access: AgeAssuranceAccess.Safe, - error: 'config', - } - } - - const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation) - const isAARequired = region.countryCode !== '*' - const isTerminalState = - state?.status === 'assured' || state?.status === 'blocked' - - /* - * If we are in a terminal state and AA is required for this region, - * we can trust the server state completely and avoid recomputing. - */ - if (isTerminalState && isAARequired) { - return { - lastInitiatedAt: state.lastInitiatedAt, - status: parseStatusFromString(state.status), - access: parseAccessFromString(state.access), - } - } - - /* - * Otherwise, we need to compute the access based on the latest data. For - * accounts with an accurate birthdate, our default fallback rules should - * ensure correct access. - */ - const result = computeAgeAssuranceRegionAccess(region, data) - const computed = { - lastInitiatedAt: state?.lastInitiatedAt, - // prefer server state - status: state?.status - ? parseStatusFromString(state?.status) - : AgeAssuranceStatus.Unknown, - // prefer server state - access: result - ? parseAccessFromString(result.access) - : AgeAssuranceAccess.Full, - } - logger.debug('debug useAgeAssuranceState', { - region, - state, - data, - computed, - }) - return computed - }, [hasSession, geolocation, config, state, data]) + return useMemo( + () => + computeAgeAssuranceState({ + hasSession, + config, + geolocation, + state, + data, + }), + [hasSession, geolocation, config, state, data], + ) } export function useOnAgeAssuranceAccessUpdate( diff --git a/src/ageAssurance/util.ts b/src/ageAssurance/util.ts index d55ec61762..310725db8f 100644 --- a/src/ageAssurance/util.ts +++ b/src/ageAssurance/util.ts @@ -2,13 +2,19 @@ import {useMemo} from 'react' import { ageAssuranceRuleIDs as ids, type AppBskyAgeassuranceDefs, + type AtpAgent, getAgeAssuranceRegionConfig, type ModerationPrefs, } from '@atproto/api' import {getAge} from '#/lib/strings/time' +import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings' import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' -import {useAgeAssuranceDataContext} from '#/ageAssurance/data' +import { + getDidFromAgentSession, + getOtherRequiredDataFromCache, + useAgeAssuranceDataContext, +} from '#/ageAssurance/data' import {AgeAssuranceAccess} from '#/ageAssurance/types' import {type Geolocation, useGeolocation} from '#/geolocation' @@ -109,3 +115,16 @@ export const makeAgeRestrictedModerationPrefs = ( adultContentEnabled: false, labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, }) + +/** + * Checks our cache of the actor's chat declaration record, and if it's not + * already restricted, restricts it. + */ +export function maybeRestrictChatSettings({agent}: {agent: AtpAgent}) { + const did = getDidFromAgentSession(agent) + if (!did) return + const data = getOtherRequiredDataFromCache({did}) + // ...update the chat setting record if allowIncoming is not already 'none'. + if (data?.actorDeclaration?.allowIncoming === 'none') return + restrictChatSettings({agent, did}) +} diff --git a/src/components/dialogs/BirthDateSettings.tsx b/src/components/dialogs/BirthDateSettings.tsx index d9de31dfdc..34d1952c70 100644 --- a/src/components/dialogs/BirthDateSettings.tsx +++ b/src/components/dialogs/BirthDateSettings.tsx @@ -1,8 +1,6 @@ import {useCallback, useMemo, useState} from 'react' import {View} from 'react-native' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' import {useCleanError} from '#/lib/hooks/useCleanError' import {isAppPassword} from '#/lib/jwt' @@ -34,7 +32,7 @@ export function BirthDateSettingsDialog({ control: Dialog.DialogControlProps }) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() const {isLoading, error, data: preferences} = usePreferencesQuery() const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed() const {currentAccount} = useSession() @@ -45,11 +43,11 @@ export function BirthDateSettingsDialog({ {isBirthdateUpdateAllowed ? ( - My Birthdate + My birthdate @@ -64,9 +62,7 @@ export function BirthDateSettingsDialog({ @@ -88,7 +84,7 @@ export function BirthDateSettingsDialog({ ) : ( { if (error) { - const {raw, clean} = cleanError(error) - return clean || raw || error.toString() + const e = error as Error + const {raw, clean} = cleanError(e) + return clean || raw || e.toString() } }, [error, cleanError]) @@ -146,7 +143,8 @@ function BirthdayInner({ await setBirthDate({birthDate: date}) } control.close() - } catch (e: any) { + } catch (error) { + const e = error as Error logger.error(`setBirthDate failed`, {message: e.message}) } }, [date, setBirthDate, control, hasChanged]) @@ -158,11 +156,10 @@ function BirthdayInner({ testID="birthdayInput" value={date} onChangeDate={newDate => setDate(new Date(newDate))} - label={_(msg`Birthdate`)} - accessibilityHint={_(msg`Enter your birthdate`)} + label={l`Birthdate`} + accessibilityHint={l`Enter your birthdate`} /> - {isUnder18 && hasChanged && ( @@ -171,30 +168,27 @@ function BirthdayInner({ )} - {isUnder13 && ( You must be at least 13 years old to use Bluesky. Read our{' '} + label={l`Terms of Service`}> Terms of Service {' '} for more information. )} - {errorMessage ? ( ) : undefined} -