diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 247217fe2d..cca7b91c52 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -51,4 +51,4 @@ jobs: # NOTE(sfn): we can add a custom system prompt here claude_args: | - --model claude-opus-4-5-20251101 + --model claude-opus-4-7 diff --git a/patches/expo-paste-input+0.1.15.patch b/patches/expo-paste-input+0.1.15.patch new file mode 100644 index 0000000000..329fbe866f --- /dev/null +++ b/patches/expo-paste-input+0.1.15.patch @@ -0,0 +1,136 @@ +diff --git a/node_modules/expo-paste-input/ios/ExpoPasteInputView.swift b/node_modules/expo-paste-input/ios/ExpoPasteInputView.swift +index 2164aec4ec1d..d216db6d2927 100644 +--- a/node_modules/expo-paste-input/ios/ExpoPasteInputView.swift ++++ b/node_modules/expo-paste-input/ios/ExpoPasteInputView.swift +@@ -511,14 +511,17 @@ class ExpoPasteInputView: ExpoView { + var attachmentRanges: [NSRange] = [] + var mediaPayloads: [MediaPayload] = [] + ++ // Only track ranges for attachments we successfully extract a real payload ++ // from. Attachments without a payload (e.g. iOS dictation placeholders) ++ // are left alone — sanitizing them would delete characters the system ++ // manages itself, and emitting "unsupported" would raise a spurious error. + attributedText.enumerateAttribute(.attachment, in: NSRange(location: 0, length: attributedText.length), options: []) { value, range, _ in + guard let attachment = value as? NSTextAttachment else { + return + } + +- attachmentRanges.append(range) +- + if let payload = self.extractMediaPayload(from: attachment, textView: textView, range: range) { ++ attachmentRanges.append(range) + mediaPayloads.append(payload) + } + } +@@ -529,9 +532,8 @@ class ExpoPasteInputView: ExpoView { + return + } + +- attachmentRanges.append(range) +- + if let payload = self.extractMediaPayload(from: adaptiveGlyph) { ++ attachmentRanges.append(range) + mediaPayloads.append(payload) + } + } +@@ -539,17 +541,12 @@ class ExpoPasteInputView: ExpoView { + + attachmentRanges = uniqueRanges(attachmentRanges) + +- guard !attachmentRanges.isEmpty else { +- return +- } +- +- sanitizeAttachments(in: textView, ranges: attachmentRanges) +- + guard !mediaPayloads.isEmpty else { +- handleUnsupportedPaste() + return + } + ++ sanitizeAttachments(in: textView, ranges: attachmentRanges) ++ + emitImagesAsync(for: mediaPayloads) + } + +@@ -651,6 +648,11 @@ class ExpoPasteInputView: ExpoView { + } + + private func extractMediaPayload(from attachment: NSTextAttachment, textView: UITextView, range: NSRange) -> MediaPayload? { ++ // Only accept attachments that carry real image payloads. We intentionally ++ // do not fall back to `image(forBounds:)` or rendering the text view's ++ // hierarchy, because system-inserted attachments (e.g. the iOS dictation ++ // placeholder) draw themselves via those paths and would cause us to ++ // emit a screenshot of the composer as a "pasted image". + if let fileWrapperData = attachment.fileWrapper?.regularFileContents, + let payload = extractMediaPayload(fromData: fileWrapperData) { + return payload +@@ -667,20 +669,6 @@ class ExpoPasteInputView: ExpoView { + return .image(image) + } + +- let attachmentBounds = attachment.bounds.size.width > 0 && attachment.bounds.size.height > 0 +- ? attachment.bounds +- : CGRect(origin: .zero, size: CGSize(width: 128, height: 128)) +- +- if let image = attachment.image(forBounds: attachmentBounds, textContainer: textView.textContainer, characterIndex: range.location), +- image.size.width > 0, +- image.size.height > 0 { +- return .image(image) +- } +- +- if let renderedImage = renderTextAttachment(in: textView, range: range) { +- return .image(renderedImage) +- } +- + return nil + } + +@@ -701,47 +689,6 @@ class ExpoPasteInputView: ExpoView { + return .imageData(data) + } + +- private func renderTextAttachment(in textView: UITextView, range: NSRange) -> UIImage? { +- let glyphRange = textView.layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) +- var rect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textView.textContainer) +- +- rect.origin.x += textView.textContainerInset.left - textView.contentOffset.x +- rect.origin.y += textView.textContainerInset.top - textView.contentOffset.y +- rect = rect.integral +- +- guard rect.width > 1, rect.height > 1 else { +- return nil +- } +- +- let format = UIGraphicsImageRendererFormat.default() +- format.scale = textView.window?.screen.scale ?? UIScreen.main.scale +- format.opaque = false +- +- let image = UIGraphicsImageRenderer(size: rect.size, format: format).image { _ in +- let drawRect = CGRect( +- origin: CGPoint(x: -rect.origin.x, y: -rect.origin.y), +- size: textView.bounds.size +- ) +- +- if textView.window != nil { +- textView.drawHierarchy(in: drawRect, afterScreenUpdates: false) +- } else { +- guard let context = UIGraphicsGetCurrentContext() else { +- return +- } +- +- context.translateBy(x: -rect.origin.x, y: -rect.origin.y) +- textView.layer.render(in: context) +- } +- } +- +- guard image.size.width > 0, image.size.height > 0 else { +- return nil +- } +- +- return image +- } +- + @available(iOS 18.0, *) + private func handleAdaptiveImageGlyphInsertion(_ adaptiveGlyph: NSAdaptiveImageGlyph) -> Bool { + guard let payload = extractMediaPayload(from: adaptiveGlyph) else { diff --git a/patches/expo-paste-input+0.1.15.patch.md b/patches/expo-paste-input+0.1.15.patch.md new file mode 100644 index 0000000000..8fd6644144 --- /dev/null +++ b/patches/expo-paste-input+0.1.15.patch.md @@ -0,0 +1,22 @@ +# Expo Paste Input Patch + +`expo-paste-input` observes `UITextView.textDidChangeNotification` and treats any +`NSTextAttachment` in the text view's `attributedText` as a pasted image. When +it can't find a real image payload on an attachment, it falls back to +`image(forBounds:)` and, failing that, to a `drawHierarchy` screenshot of the +text view at the attachment's glyph rect. + +iOS Dictation inserts its own `NSTextAttachment` (the shimmer/cursor indicator) +into the text view during dictation. Those attachments don't carry real image +data, so the fallbacks would fire — emitting a zoomed-in screenshot of the +composer as if the user had pasted an image at the end of dictation. + +This patch: + +- Removes the `image(forBounds:)` and `renderTextAttachment` fallbacks in + `extractMediaPayload` so the library only accepts attachments carrying a real + payload (`fileWrapper`, `contents`, or `image`). +- Only sanitizes (deletes) attachment ranges that produced a payload, and + skips the "unsupported" toast when an attachment has no payload. Unknown + system attachments like the dictation placeholder are left alone rather + than being ripped out from under iOS. 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} -