Merge branch 'main' into app-2067
This commit is contained in:
@@ -51,4 +51,4 @@ jobs:
|
|||||||
# NOTE(sfn): we can add a custom system prompt here
|
# NOTE(sfn): we can add a custom system prompt here
|
||||||
|
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--model claude-opus-4-5-20251101
|
--model claude-opus-4-7
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -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.
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type AppBskyAgeassuranceGetConfig,
|
type AppBskyAgeassuranceGetConfig,
|
||||||
type AppBskyAgeassuranceGetState,
|
type AppBskyAgeassuranceGetState,
|
||||||
AtpAgent,
|
AtpAgent,
|
||||||
|
type ChatBskyActorDeclaration,
|
||||||
getAgeAssuranceRegionConfig,
|
getAgeAssuranceRegionConfig,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
hasSnoozedBirthdateUpdateForDid,
|
hasSnoozedBirthdateUpdateForDid,
|
||||||
snoozeBirthdateUpdateAllowedForDid,
|
snoozeBirthdateUpdateAllowedForDid,
|
||||||
} from '#/state/birthdate'
|
} from '#/state/birthdate'
|
||||||
|
import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declaration'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import * as debug from '#/ageAssurance/debug'
|
import * as debug from '#/ageAssurance/debug'
|
||||||
import {logger} from '#/ageAssurance/logger'
|
import {logger} from '#/ageAssurance/logger'
|
||||||
@@ -53,7 +55,7 @@ const [, cacheHydrationPromise] = persistQueryClient({
|
|||||||
persister,
|
persister,
|
||||||
})
|
})
|
||||||
|
|
||||||
function getDidFromAgentSession(agent: AtpAgent) {
|
export function getDidFromAgentSession(agent: AtpAgent) {
|
||||||
const sessionManager = agent.sessionManager
|
const sessionManager = agent.sessionManager
|
||||||
if (!sessionManager || !sessionManager.did) return
|
if (!sessionManager || !sessionManager.did) return
|
||||||
return sessionManager.did
|
return sessionManager.did
|
||||||
@@ -329,19 +331,25 @@ export function useServerStateQuery() {
|
|||||||
|
|
||||||
export type OtherRequiredData = {
|
export type OtherRequiredData = {
|
||||||
birthdate: string | undefined
|
birthdate: string | undefined
|
||||||
|
actorDeclaration?: ChatBskyActorDeclaration.Main
|
||||||
}
|
}
|
||||||
export function createOtherRequiredDataQueryKey({did}: {did: string}) {
|
export function createOtherRequiredDataQueryKey({did}: {did: string}) {
|
||||||
return ['otherRequiredData', did]
|
return ['otherRequiredData', did]
|
||||||
}
|
}
|
||||||
export async function getOtherRequiredData({
|
async function getOtherRequiredData({
|
||||||
agent,
|
agent,
|
||||||
}: {
|
}: {
|
||||||
agent: AtpAgent
|
agent: AtpAgent
|
||||||
}): Promise<OtherRequiredData> {
|
}): Promise<OtherRequiredData> {
|
||||||
if (debug.enabled) return debug.resolve(debug.otherRequiredData)
|
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 = {
|
const data: OtherRequiredData = {
|
||||||
birthdate: prefs.birthDate ? prefs.birthDate.toISOString() : undefined,
|
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 (data && did && birthdateCache.has(did)) {
|
||||||
/*
|
/*
|
||||||
* If birthdate was just set, use the local cache value. On subsequent
|
* If birthdate was just set, use the local cache value. On subsequent
|
||||||
@@ -394,6 +401,26 @@ export function getOtherRequiredDataFromCache({
|
|||||||
createOtherRequiredDataQueryKey({did}),
|
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<OtherRequiredData>(
|
||||||
|
createOtherRequiredDataQueryKey({did}),
|
||||||
|
next,
|
||||||
|
)
|
||||||
|
}
|
||||||
export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) {
|
export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) {
|
||||||
const did = getDidFromAgentSession(agent)
|
const did = getDidFromAgentSession(agent)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
|
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
|
||||||
|
|
||||||
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
|
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
|
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
|
||||||
import {
|
import {
|
||||||
AgeAssuranceDataProvider,
|
AgeAssuranceDataProvider,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
} from '#/ageAssurance/types'
|
} from '#/ageAssurance/types'
|
||||||
import {
|
import {
|
||||||
isUnderAge,
|
isUnderAge,
|
||||||
|
maybeRestrictChatSettings,
|
||||||
MIN_ACCESS_AGE,
|
MIN_ACCESS_AGE,
|
||||||
useAgeAssuranceRegionConfigWithFallback,
|
useAgeAssuranceRegionConfigWithFallback,
|
||||||
} from '#/ageAssurance/util'
|
} from '#/ageAssurance/util'
|
||||||
@@ -78,6 +80,7 @@ export function Provider({children}: {children: React.ReactNode}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function InnerProvider({children}: {children: React.ReactNode}) {
|
function InnerProvider({children}: {children: React.ReactNode}) {
|
||||||
|
const agent = useAgent()
|
||||||
const state = useAgeAssuranceState()
|
const state = useAgeAssuranceState()
|
||||||
const {data} = useAgeAssuranceDataContext()
|
const {data} = useAgeAssuranceDataContext()
|
||||||
const config = useAgeAssuranceRegionConfigWithFallback()
|
const config = useAgeAssuranceRegionConfigWithFallback()
|
||||||
@@ -85,11 +88,13 @@ function InnerProvider({children}: {children: React.ReactNode}) {
|
|||||||
|
|
||||||
const handleAccessUpdate = useCallback(
|
const handleAccessUpdate = useCallback(
|
||||||
(s: AgeAssuranceState) => {
|
(s: AgeAssuranceState) => {
|
||||||
void getAndRegisterPushToken({
|
const isAgeRestricted = s.access !== AgeAssuranceAccess.Full
|
||||||
isAgeRestricted: s.access !== AgeAssuranceAccess.Full,
|
if (isAgeRestricted) {
|
||||||
})
|
void getAndRegisterPushToken({isAgeRestricted})
|
||||||
|
maybeRestrictChatSettings({agent})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[getAndRegisterPushToken],
|
[agent, getAndRegisterPushToken],
|
||||||
)
|
)
|
||||||
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
|
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
|
||||||
|
|
||||||
|
|||||||
+140
-71
@@ -1,8 +1,15 @@
|
|||||||
import {useEffect, useMemo, useState} from 'react'
|
import {useEffect, useMemo, useState} from 'react'
|
||||||
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
|
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
|
||||||
|
|
||||||
|
import {getAge} from '#/lib/strings/time'
|
||||||
import {useSession} from '#/state/session'
|
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 {logger} from '#/ageAssurance/logger'
|
||||||
import {
|
import {
|
||||||
AgeAssuranceAccess,
|
AgeAssuranceAccess,
|
||||||
@@ -12,82 +19,144 @@ import {
|
|||||||
parseStatusFromString,
|
parseStatusFromString,
|
||||||
} from '#/ageAssurance/types'
|
} from '#/ageAssurance/types'
|
||||||
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
|
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 {
|
export function useAgeAssuranceState(): AgeAssuranceState {
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
const geolocation = useGeolocation()
|
const geolocation = useGeolocation()
|
||||||
const {config, state, data} = useAgeAssuranceDataContext()
|
const {config, state, data} = useAgeAssuranceDataContext()
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(
|
||||||
/**
|
() =>
|
||||||
* This is where we control logged-out moderation prefs. It's all
|
computeAgeAssuranceState({
|
||||||
* downstream of AA now.
|
hasSession,
|
||||||
*/
|
config,
|
||||||
if (!hasSession)
|
geolocation,
|
||||||
return {
|
state,
|
||||||
status: AgeAssuranceStatus.Unknown,
|
data,
|
||||||
access: AgeAssuranceAccess.Safe,
|
}),
|
||||||
}
|
[hasSession, geolocation, config, state, data],
|
||||||
|
)
|
||||||
/**
|
|
||||||
* 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])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOnAgeAssuranceAccessUpdate(
|
export function useOnAgeAssuranceAccessUpdate(
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ import {useMemo} from 'react'
|
|||||||
import {
|
import {
|
||||||
ageAssuranceRuleIDs as ids,
|
ageAssuranceRuleIDs as ids,
|
||||||
type AppBskyAgeassuranceDefs,
|
type AppBskyAgeassuranceDefs,
|
||||||
|
type AtpAgent,
|
||||||
getAgeAssuranceRegionConfig,
|
getAgeAssuranceRegionConfig,
|
||||||
type ModerationPrefs,
|
type ModerationPrefs,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {getAge} from '#/lib/strings/time'
|
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 {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 {AgeAssuranceAccess} from '#/ageAssurance/types'
|
||||||
import {type Geolocation, useGeolocation} from '#/geolocation'
|
import {type Geolocation, useGeolocation} from '#/geolocation'
|
||||||
|
|
||||||
@@ -109,3 +115,16 @@ export const makeAgeRestrictedModerationPrefs = (
|
|||||||
adultContentEnabled: false,
|
adultContentEnabled: false,
|
||||||
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
|
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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import {useCallback, useMemo, useState} from 'react'
|
import {useCallback, useMemo, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {Trans} from '@lingui/react/macro'
|
|
||||||
|
|
||||||
import {useCleanError} from '#/lib/hooks/useCleanError'
|
import {useCleanError} from '#/lib/hooks/useCleanError'
|
||||||
import {isAppPassword} from '#/lib/jwt'
|
import {isAppPassword} from '#/lib/jwt'
|
||||||
@@ -34,7 +32,7 @@ export function BirthDateSettingsDialog({
|
|||||||
control: Dialog.DialogControlProps
|
control: Dialog.DialogControlProps
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const {isLoading, error, data: preferences} = usePreferencesQuery()
|
const {isLoading, error, data: preferences} = usePreferencesQuery()
|
||||||
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
@@ -45,11 +43,11 @@ export function BirthDateSettingsDialog({
|
|||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
{isBirthdateUpdateAllowed ? (
|
{isBirthdateUpdateAllowed ? (
|
||||||
<Dialog.ScrollableInner
|
<Dialog.ScrollableInner
|
||||||
label={_(msg`My Birthdate`)}
|
label={l`My birthdate`}
|
||||||
style={web({maxWidth: 400})}>
|
style={web({maxWidth: 400})}>
|
||||||
<View style={[a.gap_md]}>
|
<View style={[a.gap_md]}>
|
||||||
<Text style={[a.text_xl, a.font_semi_bold]}>
|
<Text style={[a.text_xl, a.font_semi_bold]}>
|
||||||
<Trans>My Birthdate</Trans>
|
<Trans>My birthdate</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
|
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||||
@@ -64,9 +62,7 @@ export function BirthDateSettingsDialog({
|
|||||||
<ErrorMessage
|
<ErrorMessage
|
||||||
message={
|
message={
|
||||||
error?.toString() ||
|
error?.toString() ||
|
||||||
_(
|
l`We were unable to load your birthdate preferences. Please try again.`
|
||||||
msg`We were unable to load your birthdate preferences. Please try again.`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
style={[a.rounded_sm]}
|
style={[a.rounded_sm]}
|
||||||
/>
|
/>
|
||||||
@@ -88,7 +84,7 @@ export function BirthDateSettingsDialog({
|
|||||||
</Dialog.ScrollableInner>
|
</Dialog.ScrollableInner>
|
||||||
) : (
|
) : (
|
||||||
<Dialog.ScrollableInner
|
<Dialog.ScrollableInner
|
||||||
label={_(msg`You recently changed your birthdate`)}
|
label={l`You recently changed your birthdate`}
|
||||||
style={web({maxWidth: 400})}>
|
style={web({maxWidth: 400})}>
|
||||||
<View style={[a.gap_sm]}>
|
<View style={[a.gap_sm]}>
|
||||||
<Text
|
<Text
|
||||||
@@ -123,15 +119,16 @@ function BirthdayInner({
|
|||||||
control: Dialog.DialogControlProps
|
control: Dialog.DialogControlProps
|
||||||
preferences: UsePreferencesQueryResponse
|
preferences: UsePreferencesQueryResponse
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const cleanError = useCleanError()
|
const cleanError = useCleanError()
|
||||||
const [date, setDate] = useState(preferences.birthDate || getDateAgo(18))
|
const [date, setDate] = useState(preferences.birthDate || getDateAgo(18))
|
||||||
const {isPending, error, mutateAsync: setBirthDate} = useBirthdateMutation()
|
const {isPending, error, mutateAsync: setBirthDate} = useBirthdateMutation()
|
||||||
const hasChanged = date !== preferences.birthDate
|
const hasChanged = date !== preferences.birthDate
|
||||||
const errorMessage = useMemo(() => {
|
const errorMessage = useMemo(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
const {raw, clean} = cleanError(error)
|
const e = error as Error
|
||||||
return clean || raw || error.toString()
|
const {raw, clean} = cleanError(e)
|
||||||
|
return clean || raw || e.toString()
|
||||||
}
|
}
|
||||||
}, [error, cleanError])
|
}, [error, cleanError])
|
||||||
|
|
||||||
@@ -146,7 +143,8 @@ function BirthdayInner({
|
|||||||
await setBirthDate({birthDate: date})
|
await setBirthDate({birthDate: date})
|
||||||
}
|
}
|
||||||
control.close()
|
control.close()
|
||||||
} catch (e: any) {
|
} catch (error) {
|
||||||
|
const e = error as Error
|
||||||
logger.error(`setBirthDate failed`, {message: e.message})
|
logger.error(`setBirthDate failed`, {message: e.message})
|
||||||
}
|
}
|
||||||
}, [date, setBirthDate, control, hasChanged])
|
}, [date, setBirthDate, control, hasChanged])
|
||||||
@@ -158,11 +156,10 @@ function BirthdayInner({
|
|||||||
testID="birthdayInput"
|
testID="birthdayInput"
|
||||||
value={date}
|
value={date}
|
||||||
onChangeDate={newDate => setDate(new Date(newDate))}
|
onChangeDate={newDate => setDate(new Date(newDate))}
|
||||||
label={_(msg`Birthdate`)}
|
label={l`Birthdate`}
|
||||||
accessibilityHint={_(msg`Enter your birthdate`)}
|
accessibilityHint={l`Enter your birthdate`}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isUnder18 && hasChanged && (
|
{isUnder18 && hasChanged && (
|
||||||
<Admonition type="info">
|
<Admonition type="info">
|
||||||
<Trans>
|
<Trans>
|
||||||
@@ -171,30 +168,27 @@ function BirthdayInner({
|
|||||||
</Trans>
|
</Trans>
|
||||||
</Admonition>
|
</Admonition>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isUnder13 && (
|
{isUnder13 && (
|
||||||
<Admonition type="error">
|
<Admonition type="error">
|
||||||
<Trans>
|
<Trans>
|
||||||
You must be at least 13 years old to use Bluesky. Read our{' '}
|
You must be at least 13 years old to use Bluesky. Read our{' '}
|
||||||
<SimpleInlineLinkText
|
<SimpleInlineLinkText
|
||||||
to="https://bsky.social/about/support/tos"
|
to="https://bsky.social/about/support/tos"
|
||||||
label={_(msg`Terms of Service`)}>
|
label={l`Terms of Service`}>
|
||||||
Terms of Service
|
Terms of Service
|
||||||
</SimpleInlineLinkText>{' '}
|
</SimpleInlineLinkText>{' '}
|
||||||
for more information.
|
for more information.
|
||||||
</Trans>
|
</Trans>
|
||||||
</Admonition>
|
</Admonition>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<ErrorMessage message={errorMessage} style={[a.rounded_sm]} />
|
<ErrorMessage message={errorMessage} style={[a.rounded_sm]} />
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|
||||||
<View style={IS_WEB && [a.flex_row, a.justify_end]}>
|
<View style={IS_WEB && [a.flex_row, a.justify_end]}>
|
||||||
<Button
|
<Button
|
||||||
label={hasChanged ? _(msg`Save birthdate`) : _(msg`Done`)}
|
label={hasChanged ? l`Save birthdate` : l`Done`}
|
||||||
size="large"
|
size="large"
|
||||||
onPress={onSave}
|
onPress={() => void onSave()}
|
||||||
variant="solid"
|
variant="solid"
|
||||||
color="primary"
|
color="primary"
|
||||||
disabled={isUnder13}>
|
disabled={isUnder13}>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {Trans} from '@lingui/react/macro'
|
|||||||
|
|
||||||
import {type Dimensions} from '#/lib/media/types'
|
import {type Dimensions} from '#/lib/media/types'
|
||||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
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 {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
|
||||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
@@ -210,12 +210,17 @@ export function AutoSizedImage({
|
|||||||
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
||||||
foreground: true,
|
foreground: true,
|
||||||
}}
|
}}
|
||||||
style={[
|
style={({pressed}) => [
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.rounded_md,
|
a.rounded_md,
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
{aspectRatio: max ?? 1},
|
{aspectRatio: max ?? 1},
|
||||||
|
web([
|
||||||
|
a.transition_transform,
|
||||||
|
{transitionDuration: '200ms'},
|
||||||
|
pressed && {transform: [{scale: 0.99}]},
|
||||||
|
]),
|
||||||
]}>
|
]}>
|
||||||
{contents}
|
{contents}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -237,7 +242,16 @@ export function AutoSizedImage({
|
|||||||
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
||||||
foreground: true,
|
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}
|
{contents}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</ConstrainedImage>
|
</ConstrainedImage>
|
||||||
|
|||||||
@@ -430,15 +430,20 @@ function GalleryImage({
|
|||||||
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
||||||
foreground: true,
|
foreground: true,
|
||||||
}}
|
}}
|
||||||
style={[
|
style={({pressed}) => [
|
||||||
a.rounded_md,
|
a.rounded_md,
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
web({
|
web([
|
||||||
cursor: 'inherit',
|
{
|
||||||
outline: 0,
|
cursor: 'inherit',
|
||||||
border: 0,
|
outline: 0,
|
||||||
}),
|
border: 0,
|
||||||
|
},
|
||||||
|
a.transition_transform,
|
||||||
|
{transitionDuration: '200ms'},
|
||||||
|
pressed && {transform: [{scale: 0.99}]},
|
||||||
|
]),
|
||||||
]}>
|
]}>
|
||||||
<Image
|
<Image
|
||||||
source={{uri: image.thumb}}
|
source={{uri: image.thumb}}
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ export type PaletteColor = {
|
|||||||
textInverted: string
|
textInverted: string
|
||||||
link: string
|
link: string
|
||||||
border: string
|
border: string
|
||||||
borderDark: string
|
|
||||||
icon: string
|
|
||||||
[k: string]: string
|
[k: string]: string
|
||||||
}
|
}
|
||||||
export type Palette = Record<PaletteColorName, PaletteColor>
|
export type Palette = Record<PaletteColorName, PaletteColor>
|
||||||
|
|||||||
@@ -13,12 +13,10 @@ export interface UsePaletteValue {
|
|||||||
viewLight: ViewStyle
|
viewLight: ViewStyle
|
||||||
btn: ViewStyle
|
btn: ViewStyle
|
||||||
border: ViewStyle
|
border: ViewStyle
|
||||||
borderDark: ViewStyle
|
|
||||||
text: TextStyle
|
text: TextStyle
|
||||||
textLight: TextStyle
|
textLight: TextStyle
|
||||||
textInverted: TextStyle
|
textInverted: TextStyle
|
||||||
link: TextStyle
|
link: TextStyle
|
||||||
icon: TextStyle
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,9 +40,6 @@ export function usePalette(color: PaletteColorName): UsePaletteValue {
|
|||||||
border: {
|
border: {
|
||||||
borderColor: palette.border,
|
borderColor: palette.border,
|
||||||
},
|
},
|
||||||
borderDark: {
|
|
||||||
borderColor: palette.borderDark,
|
|
||||||
},
|
|
||||||
text: {
|
text: {
|
||||||
color: palette.text,
|
color: palette.text,
|
||||||
},
|
},
|
||||||
@@ -57,9 +52,6 @@ export function usePalette(color: PaletteColorName): UsePaletteValue {
|
|||||||
link: {
|
link: {
|
||||||
color: palette.link,
|
color: palette.link,
|
||||||
},
|
},
|
||||||
icon: {
|
|
||||||
color: palette.icon,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}, [theme, color])
|
}, [theme, color])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ export const colors = {
|
|||||||
green3: '#20bc07',
|
green3: '#20bc07',
|
||||||
green4: '#148203',
|
green4: '#148203',
|
||||||
green5: '#082b03',
|
green5: '#082b03',
|
||||||
|
|
||||||
unreadNotifBg: '#ebf6ff',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,19 +17,6 @@ export const defaultTheme: Theme = {
|
|||||||
textInverted: lightPalette.white,
|
textInverted: lightPalette.white,
|
||||||
link: lightPalette.primary_500,
|
link: lightPalette.primary_500,
|
||||||
border: lightPalette.contrast_100,
|
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: {
|
primary: {
|
||||||
background: colors.blue3,
|
background: colors.blue3,
|
||||||
@@ -39,8 +26,6 @@ export const defaultTheme: Theme = {
|
|||||||
textInverted: colors.blue3,
|
textInverted: colors.blue3,
|
||||||
link: colors.blue0,
|
link: colors.blue0,
|
||||||
border: colors.blue4,
|
border: colors.blue4,
|
||||||
borderDark: colors.blue5,
|
|
||||||
icon: colors.blue4,
|
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
background: colors.green3,
|
background: colors.green3,
|
||||||
@@ -50,8 +35,6 @@ export const defaultTheme: Theme = {
|
|||||||
textInverted: colors.green4,
|
textInverted: colors.green4,
|
||||||
link: colors.green1,
|
link: colors.green1,
|
||||||
border: colors.green4,
|
border: colors.green4,
|
||||||
borderDark: colors.green5,
|
|
||||||
icon: colors.green4,
|
|
||||||
},
|
},
|
||||||
inverted: {
|
inverted: {
|
||||||
background: darkPalette.black,
|
background: darkPalette.black,
|
||||||
@@ -61,8 +44,6 @@ export const defaultTheme: Theme = {
|
|||||||
textInverted: darkPalette.black,
|
textInverted: darkPalette.black,
|
||||||
link: darkPalette.primary_500,
|
link: darkPalette.primary_500,
|
||||||
border: darkPalette.contrast_100,
|
border: darkPalette.contrast_100,
|
||||||
borderDark: darkPalette.contrast_200,
|
|
||||||
icon: darkPalette.contrast_500,
|
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
background: colors.red3,
|
background: colors.red3,
|
||||||
@@ -72,8 +53,6 @@ export const defaultTheme: Theme = {
|
|||||||
textInverted: colors.red3,
|
textInverted: colors.red3,
|
||||||
link: colors.red1,
|
link: colors.red1,
|
||||||
border: colors.red4,
|
border: colors.red4,
|
||||||
borderDark: colors.red5,
|
|
||||||
icon: colors.red4,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
shapes: {
|
shapes: {
|
||||||
@@ -303,19 +282,6 @@ export const darkTheme: Theme = {
|
|||||||
textInverted: darkPalette.black,
|
textInverted: darkPalette.black,
|
||||||
link: darkPalette.primary_500,
|
link: darkPalette.primary_500,
|
||||||
border: darkPalette.contrast_100,
|
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: {
|
primary: {
|
||||||
...defaultTheme.palette.primary,
|
...defaultTheme.palette.primary,
|
||||||
@@ -333,8 +299,6 @@ export const darkTheme: Theme = {
|
|||||||
textInverted: darkPalette.white,
|
textInverted: darkPalette.white,
|
||||||
link: lightPalette.primary_500,
|
link: lightPalette.primary_500,
|
||||||
border: lightPalette.contrast_100,
|
border: lightPalette.contrast_100,
|
||||||
borderDark: lightPalette.contrast_200,
|
|
||||||
icon: lightPalette.contrast_500,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -352,19 +316,6 @@ export const dimTheme: Theme = {
|
|||||||
textInverted: dimPalette.black,
|
textInverted: dimPalette.black,
|
||||||
link: dimPalette.primary_500,
|
link: dimPalette.primary_500,
|
||||||
border: dimPalette.contrast_100,
|
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,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+203
-184
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,7 @@ import * as Layout from '#/components/Layout'
|
|||||||
import {Link} from '#/components/Link'
|
import {Link} from '#/components/Link'
|
||||||
import {ListFooter} from '#/components/Lists'
|
import {ListFooter} from '#/components/Lists'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
import {IS_NATIVE} from '#/env'
|
import {IS_NATIVE} from '#/env'
|
||||||
import {ChatListItem} from './components/ChatListItem'
|
import {ChatListItem} from './components/ChatListItem'
|
||||||
import {InboxPreview} from './components/InboxPreview'
|
import {InboxPreview} from './components/InboxPreview'
|
||||||
@@ -71,21 +72,24 @@ type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
|
|||||||
export function MessagesScreen(props: Props) {
|
export function MessagesScreen(props: Props) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const aaCopy = useAgeAssuranceCopy()
|
const aaCopy = useAgeAssuranceCopy()
|
||||||
|
const aa = useAgeAssurance()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AgeRestrictedScreen
|
<AgeRestrictedScreen
|
||||||
screenTitle={_(msg`Chats`)}
|
screenTitle={_(msg`Chats`)}
|
||||||
infoText={aaCopy.chatsInfoText}
|
infoText={aaCopy.chatsInfoText}
|
||||||
rightHeaderSlot={
|
rightHeaderSlot={
|
||||||
<Link
|
aa.flags.chatDisabled ? null : (
|
||||||
to="/messages/settings"
|
<Link
|
||||||
label={_(msg`Chat settings`)}
|
to="/messages/settings"
|
||||||
size="small"
|
label={_(msg`Chat settings`)}
|
||||||
color="secondary">
|
size="small"
|
||||||
<ButtonText>
|
color="secondary">
|
||||||
<Trans>Chat settings</Trans>
|
<ButtonText>
|
||||||
</ButtonText>
|
<Trans>Chat settings</Trans>
|
||||||
</Link>
|
</ButtonText>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
}>
|
}>
|
||||||
<MessagesScreenInner {...props} />
|
<MessagesScreenInner {...props} />
|
||||||
</AgeRestrictedScreen>
|
</AgeRestrictedScreen>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {useProfileQuery} from '#/state/queries/profile'
|
|||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
|
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
|
||||||
|
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
import * as Toggle from '#/components/forms/Toggle'
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
@@ -24,7 +26,16 @@ type AllowIncoming = 'all' | 'none' | 'following'
|
|||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesSettings'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesSettings'>
|
||||||
|
|
||||||
export function MessagesSettingsScreen(props: Props) {
|
export function MessagesSettingsScreen(props: Props) {
|
||||||
return <MessagesSettingsScreenInner {...props} />
|
const {_} = useLingui()
|
||||||
|
const aaCopy = useAgeAssuranceCopy()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AgeRestrictedScreen
|
||||||
|
screenTitle={_(msg`Chat settings`)}
|
||||||
|
infoText={aaCopy.chatsInfoText}>
|
||||||
|
<MessagesSettingsScreenInner {...props} />
|
||||||
|
</AgeRestrictedScreen>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessagesSettingsScreenInner({}: Props) {
|
export function MessagesSettingsScreenInner({}: Props) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
|
|||||||
import {preferencesQueryKey} from '#/state/queries/preferences'
|
import {preferencesQueryKey} from '#/state/queries/preferences'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import {usePatchAgeAssuranceOtherRequiredData} from '#/ageAssurance'
|
import {usePatchAgeAssuranceOtherRequiredData} from '#/ageAssurance'
|
||||||
|
import {isUnderAge, maybeRestrictChatSettings} from '#/ageAssurance/util'
|
||||||
import {IS_DEV} from '#/env'
|
import {IS_DEV} from '#/env'
|
||||||
import {account} from '#/storage'
|
import {account} from '#/storage'
|
||||||
|
|
||||||
@@ -63,6 +64,11 @@ export function useBirthdateMutation() {
|
|||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: preferencesQueryKey,
|
queryKey: preferencesQueryKey,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (isUnderAge(birthDate.toISOString(), 18)) {
|
||||||
|
maybeRestrictChatSettings({agent})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Also patch the age assurance other required data with the new
|
* Also patch the age assurance other required data with the new
|
||||||
* birthdate, which may change the user's age assurance access level.
|
* birthdate, which may change the user's age assurance access level.
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import {type AppBskyActorDefs} from '@atproto/api'
|
import type AtpAgent from '@atproto/api'
|
||||||
|
import {
|
||||||
|
type AppBskyActorDefs,
|
||||||
|
type ChatBskyActorDeclaration,
|
||||||
|
} from '@atproto/api'
|
||||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
@@ -78,3 +82,21 @@ export function useDeleteActorDeclaration() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchActorDeclarationRecord({
|
||||||
|
agent,
|
||||||
|
did,
|
||||||
|
}: {
|
||||||
|
agent: AtpAgent
|
||||||
|
did?: string
|
||||||
|
}) {
|
||||||
|
if (!did) return
|
||||||
|
const res = await agent.com.atproto.repo
|
||||||
|
.getRecord({
|
||||||
|
repo: did,
|
||||||
|
collection: 'chat.bsky.actor.declaration',
|
||||||
|
rkey: 'self',
|
||||||
|
})
|
||||||
|
.catch(_e => undefined)
|
||||||
|
return res?.data.value as ChatBskyActorDeclaration.Main
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type AtpAgent from '@atproto/api'
|
||||||
|
import {type ChatBskyActorDeclaration} from '@atproto/api'
|
||||||
|
|
||||||
|
import {networkRetry} from '#/lib/async/retry'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {setOtherRequiredDataActorDeclarationCache} from '#/ageAssurance/data'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to update the chat settings record.
|
||||||
|
*/
|
||||||
|
export async function restrictChatSettings({
|
||||||
|
agent,
|
||||||
|
did,
|
||||||
|
}: {
|
||||||
|
agent: AtpAgent
|
||||||
|
did: string
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const record: ChatBskyActorDeclaration.Main = {
|
||||||
|
$type: 'chat.bsky.actor.declaration',
|
||||||
|
allowIncoming: 'none',
|
||||||
|
}
|
||||||
|
await networkRetry(3, () =>
|
||||||
|
agent.com.atproto.repo.putRecord({
|
||||||
|
repo: did,
|
||||||
|
collection: 'chat.bsky.actor.declaration',
|
||||||
|
rkey: 'self',
|
||||||
|
record,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// important, update local cache to avoid running this again
|
||||||
|
setOtherRequiredDataActorDeclarationCache({
|
||||||
|
did,
|
||||||
|
actorDeclaration: record,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
logger.error(`restrictChatSettings: failed to set chat declaration`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ jest.mock('jwt-decode', () => ({
|
|||||||
|
|
||||||
jest.mock('../../birthdate')
|
jest.mock('../../birthdate')
|
||||||
jest.mock('../../../ageAssurance/data')
|
jest.mock('../../../ageAssurance/data')
|
||||||
|
jest.mock('../../../ageAssurance/state', () => ({
|
||||||
|
getAndComputeAgeAssuranceState: () => ({}),
|
||||||
|
}))
|
||||||
jest.mock('#/lib/notifications/notifications', () => ({
|
jest.mock('#/lib/notifications/notifications', () => ({
|
||||||
unregisterPushToken(_agents: BskyAgent[]) {
|
unregisterPushToken(_agents: BskyAgent[]) {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
|
|||||||
+10
-21
@@ -22,15 +22,17 @@ import {
|
|||||||
PUBLIC_BSKY_SERVICE,
|
PUBLIC_BSKY_SERVICE,
|
||||||
TIMELINE_SAVED_FEED,
|
TIMELINE_SAVED_FEED,
|
||||||
} from '#/lib/constants'
|
} from '#/lib/constants'
|
||||||
import {getAge} from '#/lib/strings/time'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
|
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
|
||||||
|
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
|
||||||
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
|
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
|
||||||
import {
|
import {
|
||||||
prefetchAgeAssuranceData,
|
prefetchAgeAssuranceData,
|
||||||
setBirthdateForDid,
|
setBirthdateForDid,
|
||||||
setCreatedAtForDid,
|
setCreatedAtForDid,
|
||||||
} from '#/ageAssurance/data'
|
} from '#/ageAssurance/data'
|
||||||
|
import {getAndComputeAgeAssuranceState} from '#/ageAssurance/state'
|
||||||
|
import {AgeAssuranceAccess} from '#/ageAssurance/types'
|
||||||
import {features} from '#/analytics'
|
import {features} from '#/analytics'
|
||||||
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
|
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
|
||||||
import {addSessionErrorLog} from './logging'
|
import {addSessionErrorLog} from './logging'
|
||||||
@@ -218,26 +220,13 @@ export async function createAgentAndCreateAccount(
|
|||||||
logger.info(`createAgentAndCreateAccount: failed to set initial feeds`)
|
logger.info(`createAgentAndCreateAccount: failed to set initial feeds`)
|
||||||
throw e
|
throw e
|
||||||
}),
|
}),
|
||||||
...(getAge(birthDate) < 18
|
// wait for AA data to load first, then check state
|
||||||
? [
|
aa.then(async () => {
|
||||||
networkRetry(3, () => {
|
const state = getAndComputeAgeAssuranceState({did: account.did})
|
||||||
return agent.com.atproto.repo.putRecord({
|
if (state.access !== AgeAssuranceAccess.Full) {
|
||||||
repo: account.did,
|
restrictChatSettings({agent, did: account.did})
|
||||||
collection: 'chat.bsky.actor.declaration',
|
}
|
||||||
rkey: 'self',
|
}),
|
||||||
record: {
|
|
||||||
$type: 'chat.bsky.actor.declaration',
|
|
||||||
allowIncoming: 'none',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}).catch(e => {
|
|
||||||
logger.info(
|
|
||||||
`createAgentAndCreateAccount: failed to set chat declaration`,
|
|
||||||
)
|
|
||||||
throw e
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]).then(promises => {
|
]).then(promises => {
|
||||||
const rejected = promises.filter(p => p.status === 'rejected')
|
const rejected = promises.filter(p => p.status === 'rejected')
|
||||||
if (rejected.length > 0) {
|
if (rejected.length > 0) {
|
||||||
|
|||||||
@@ -207,6 +207,8 @@ export const ComposePost = ({
|
|||||||
const setLangPrefs = useLanguagePrefsApi()
|
const setLangPrefs = useLanguagePrefsApi()
|
||||||
const textInputRef = useRef<TextInputRef>(null)
|
const textInputRef = useRef<TextInputRef>(null)
|
||||||
const discardPromptControl = Prompt.usePromptControl()
|
const discardPromptControl = Prompt.usePromptControl()
|
||||||
|
const emptyPostsPromptControl = Prompt.usePromptControl()
|
||||||
|
const skipEmptyConfirmedRef = useRef(false)
|
||||||
const {mutateAsync: saveDraft, isPending: _isSavingDraft} =
|
const {mutateAsync: saveDraft, isPending: _isSavingDraft} =
|
||||||
useSaveDraftMutation()
|
useSaveDraftMutation()
|
||||||
const {mutate: cleanupPublishedDraft} = useCleanupPublishedDraftMutation()
|
const {mutate: cleanupPublishedDraft} = useCleanupPublishedDraftMutation()
|
||||||
@@ -783,16 +785,47 @@ export const ComposePost = ({
|
|||||||
|
|
||||||
const canPost =
|
const canPost =
|
||||||
!missingAltError &&
|
!missingAltError &&
|
||||||
|
thread.posts.some(post => !isEmptyPost(post)) &&
|
||||||
thread.posts.every(
|
thread.posts.every(
|
||||||
post =>
|
post =>
|
||||||
post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH &&
|
isEmptyPost(post) ||
|
||||||
!isEmptyPost(post) &&
|
(post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH &&
|
||||||
!(
|
!(
|
||||||
post.embed.media?.type === 'video' &&
|
post.embed.media?.type === 'video' &&
|
||||||
post.embed.media.video.status === 'error'
|
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 () => {
|
const onPressPublish = useCallback(async () => {
|
||||||
if (isPublishing) {
|
if (isPublishing) {
|
||||||
return
|
return
|
||||||
@@ -802,8 +835,15 @@ export const ComposePost = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const {type: emptyType, filteredThread} = getFilteredThread()
|
||||||
|
|
||||||
|
if (emptyType === 'non-trailing' && !skipEmptyConfirmedRef.current) {
|
||||||
|
emptyPostsPromptControl.open()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
thread.posts.some(
|
filteredThread.posts.some(
|
||||||
post =>
|
post =>
|
||||||
post.embed.media?.type === 'video' &&
|
post.embed.media?.type === 'video' &&
|
||||||
post.embed.media.video.asset &&
|
post.embed.media.video.asset &&
|
||||||
@@ -814,6 +854,7 @@ export const ComposePost = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
skipEmptyConfirmedRef.current = false
|
||||||
setError('')
|
setError('')
|
||||||
setIsPublishing(true)
|
setIsPublishing(true)
|
||||||
|
|
||||||
@@ -826,7 +867,7 @@ export const ComposePost = ({
|
|||||||
agent,
|
agent,
|
||||||
queryClient,
|
queryClient,
|
||||||
{
|
{
|
||||||
thread,
|
thread: filteredThread,
|
||||||
replyTo: replyTo?.uri,
|
replyTo: replyTo?.uri,
|
||||||
onStateChange: setPublishingStage,
|
onStateChange: setPublishingStage,
|
||||||
langs: currentLanguages,
|
langs: currentLanguages,
|
||||||
@@ -857,10 +898,10 @@ export const ComposePost = ({
|
|||||||
const res = await agent.app.bsky.unspecced.getPostThreadV2({
|
const res = await agent.app.bsky.unspecced.getPostThreadV2({
|
||||||
anchor: postUri!,
|
anchor: postUri!,
|
||||||
above: false,
|
above: false,
|
||||||
below: thread.posts.length - 1,
|
below: filteredThread.posts.length - 1,
|
||||||
branchingFactor: 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`)
|
throw new Error(`composer: app view is not ready`)
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -887,7 +928,9 @@ export const ComposePost = ({
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error(e, {
|
logger.error(e, {
|
||||||
message: `Composer: create post failed`,
|
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)
|
let err = cleanError(e.message)
|
||||||
@@ -902,14 +945,14 @@ export const ComposePost = ({
|
|||||||
} finally {
|
} finally {
|
||||||
if (postUri) {
|
if (postUri) {
|
||||||
let index = 0
|
let index = 0
|
||||||
for (let post of thread.posts) {
|
for (let post of filteredThread.posts) {
|
||||||
ax.metric('post:create', {
|
ax.metric('post:create', {
|
||||||
imageCount:
|
imageCount:
|
||||||
post.embed.media?.type === 'images'
|
post.embed.media?.type === 'images'
|
||||||
? post.embed.media.images.length
|
? post.embed.media.images.length
|
||||||
: 0,
|
: 0,
|
||||||
isReply: index > 0 || !!replyTo,
|
isReply: index > 0 || !!replyTo,
|
||||||
isPartOfThread: thread.posts.length > 1,
|
isPartOfThread: filteredThread.posts.length > 1,
|
||||||
hasLink: !!post.embed.link,
|
hasLink: !!post.embed.link,
|
||||||
hasQuote: !!post.embed.quote,
|
hasQuote: !!post.embed.quote,
|
||||||
langs: fromPostLanguages(currentLanguages),
|
langs: fromPostLanguages(currentLanguages),
|
||||||
@@ -918,9 +961,9 @@ export const ComposePost = ({
|
|||||||
index++
|
index++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (thread.posts.length > 1) {
|
if (filteredThread.posts.length > 1) {
|
||||||
ax.metric('thread:create', {
|
ax.metric('thread:create', {
|
||||||
postCount: thread.posts.length,
|
postCount: filteredThread.posts.length,
|
||||||
isReply: !!replyTo,
|
isReply: !!replyTo,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -973,7 +1016,7 @@ export const ComposePost = ({
|
|||||||
<Toast.Outer>
|
<Toast.Outer>
|
||||||
<Toast.Icon />
|
<Toast.Icon />
|
||||||
<Toast.Text>
|
<Toast.Text>
|
||||||
{thread.posts.length > 1
|
{filteredThread.posts.length > 1
|
||||||
? l`Your posts were sent`
|
? l`Your posts were sent`
|
||||||
: replyTo
|
: replyTo
|
||||||
? l`Your reply was sent`
|
? l`Your reply was sent`
|
||||||
@@ -1016,8 +1059,14 @@ export const ComposePost = ({
|
|||||||
composerState.isDirty,
|
composerState.isDirty,
|
||||||
cleanupPublishedDraft,
|
cleanupPublishedDraft,
|
||||||
loadedDraftCreatedAt,
|
loadedDraftCreatedAt,
|
||||||
|
emptyPostsPromptControl,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const handleConfirmSkipEmpty = () => {
|
||||||
|
skipEmptyConfirmedRef.current = true
|
||||||
|
void onPressPublish()
|
||||||
|
}
|
||||||
|
|
||||||
// Preserves the referential identity passed to each post item.
|
// Preserves the referential identity passed to each post item.
|
||||||
// Avoids re-rendering all posts on each keystroke.
|
// Avoids re-rendering all posts on each keystroke.
|
||||||
const onComposerPostPublish = useNonReactiveCallback(() => {
|
const onComposerPostPublish = useNonReactiveCallback(() => {
|
||||||
@@ -1029,6 +1078,7 @@ export const ComposePost = ({
|
|||||||
let erroredVideos = 0
|
let erroredVideos = 0
|
||||||
let uploadingVideos = 0
|
let uploadingVideos = 0
|
||||||
for (let post of thread.posts) {
|
for (let post of thread.posts) {
|
||||||
|
if (isEmptyPost(post)) continue
|
||||||
if (post.embed.media?.type === 'video') {
|
if (post.embed.media?.type === 'video') {
|
||||||
const video = post.embed.media.video
|
const video = post.embed.media.video
|
||||||
if (video.status === 'error') {
|
if (video.status === 'error') {
|
||||||
@@ -1268,6 +1318,15 @@ export const ComposePost = ({
|
|||||||
</Prompt.Actions>
|
</Prompt.Actions>
|
||||||
</Prompt.Outer>
|
</Prompt.Outer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Prompt.Basic
|
||||||
|
control={emptyPostsPromptControl}
|
||||||
|
title={l`Skip empty posts?`}
|
||||||
|
description={l`Your thread has empty posts that will be skipped. The remaining posts will be published as a thread.`}
|
||||||
|
confirmButtonCta={l`Post anyway`}
|
||||||
|
cancelButtonCta={l`Keep editing`}
|
||||||
|
onConfirm={handleConfirmSkipEmpty}
|
||||||
|
/>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</BottomSheetPortalProvider>
|
</BottomSheetPortalProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {useQueryClient} from '@tanstack/react-query'
|
|||||||
|
|
||||||
import {DM_SERVICE_HEADERS, MAX_POST_LINES} from '#/lib/constants'
|
import {DM_SERVICE_HEADERS, MAX_POST_LINES} from '#/lib/constants'
|
||||||
import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue'
|
import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue'
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {forceLTR} from '#/lib/strings/bidi'
|
import {forceLTR} from '#/lib/strings/bidi'
|
||||||
@@ -92,10 +91,9 @@ let NotificationFeedItem = ({
|
|||||||
hideTopBorder?: boolean
|
hideTopBorder?: boolean
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const pal = usePalette('default')
|
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_, i18n} = useLingui()
|
const {_, i18n} = useLingui()
|
||||||
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
|
const [isAuthorsExpanded, setIsAuthorsExpanded] = useState<boolean>(false)
|
||||||
const itemHref = useMemo(() => {
|
const itemHref = useMemo(() => {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case 'post-like':
|
case 'post-like':
|
||||||
@@ -145,7 +143,7 @@ let NotificationFeedItem = ({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}
|
}
|
||||||
setAuthorsExpanded(currentlyExpanded => !currentlyExpanded)
|
setIsAuthorsExpanded(currentlyExpanded => !currentlyExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onBeforePress = useCallback(() => {
|
const onBeforePress = useCallback(() => {
|
||||||
@@ -222,8 +220,8 @@ let NotificationFeedItem = ({
|
|||||||
post={item.subject}
|
post={item.subject}
|
||||||
style={
|
style={
|
||||||
isHighlighted && {
|
isHighlighted && {
|
||||||
backgroundColor: pal.colors.unreadNotifBg,
|
backgroundColor: t.palette.primary_25,
|
||||||
borderColor: pal.colors.unreadNotifBorder,
|
borderColor: t.palette.primary_100,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hideTopBorder={hideTopBorder}
|
hideTopBorder={hideTopBorder}
|
||||||
@@ -577,8 +575,8 @@ let NotificationFeedItem = ({
|
|||||||
item.notification.isRead
|
item.notification.isRead
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
backgroundColor: pal.colors.unreadNotifBg,
|
backgroundColor: t.palette.primary_25,
|
||||||
borderColor: pal.colors.unreadNotifBorder,
|
borderColor: t.palette.primary_100,
|
||||||
},
|
},
|
||||||
!hideTopBorder && a.border_t,
|
!hideTopBorder && a.border_t,
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ import {useQueryClient} from '@tanstack/react-query'
|
|||||||
|
|
||||||
import {MAX_POST_LINES} from '#/lib/constants'
|
import {MAX_POST_LINES} from '#/lib/constants'
|
||||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {countLines} from '#/lib/strings/helpers'
|
import {countLines} from '#/lib/strings/helpers'
|
||||||
import {colors} from '#/lib/styles'
|
|
||||||
import {
|
import {
|
||||||
POST_TOMBSTONE,
|
POST_TOMBSTONE,
|
||||||
type Shadow,
|
type Shadow,
|
||||||
@@ -26,7 +24,7 @@ import {unstableCacheProfileView} from '#/state/queries/profile'
|
|||||||
import {Link} from '#/view/com/util/Link'
|
import {Link} from '#/view/com/util/Link'
|
||||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a, select, useTheme} from '#/alf'
|
||||||
import {
|
import {
|
||||||
GalleryBleed,
|
GalleryBleed,
|
||||||
maybeApplyGalleryOffsetStyles,
|
maybeApplyGalleryOffsetStyles,
|
||||||
@@ -119,7 +117,7 @@ function PostInner({
|
|||||||
onBeforePress?: () => void
|
onBeforePress?: () => void
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const pal = usePalette('default')
|
const t = useTheme()
|
||||||
const {openComposer} = useOpenComposer()
|
const {openComposer} = useOpenComposer()
|
||||||
const [limitLines, setLimitLines] = useState(
|
const [limitLines, setLimitLines] = useState(
|
||||||
() => countLines(richText?.text) >= MAX_POST_LINES,
|
() => countLines(richText?.text) >= MAX_POST_LINES,
|
||||||
@@ -164,8 +162,8 @@ function PostInner({
|
|||||||
href={itemHref}
|
href={itemHref}
|
||||||
style={[
|
style={[
|
||||||
styles.outer,
|
styles.outer,
|
||||||
pal.border,
|
t.atoms.border_contrast_low,
|
||||||
!hideTopBorder && {borderTopWidth: StyleSheet.hairlineWidth},
|
!hideTopBorder && a.border_t,
|
||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
onBeforePress={onBeforePress}
|
onBeforePress={onBeforePress}
|
||||||
@@ -176,7 +174,20 @@ function PostInner({
|
|||||||
setHover(false)
|
setHover(false)
|
||||||
}}>
|
}}>
|
||||||
<SubtleHover hover={hover} />
|
<SubtleHover hover={hover} />
|
||||||
{showReplyLine && <View style={styles.replyLine} />}
|
{showReplyLine && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.replyLine,
|
||||||
|
{
|
||||||
|
backgroundColor: select(t.name, {
|
||||||
|
light: t.palette.contrast_100,
|
||||||
|
dim: t.palette.contrast_200,
|
||||||
|
dark: t.palette.contrast_200,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<View style={styles.layout}>
|
<View style={styles.layout}>
|
||||||
<View style={styles.layoutAvi}>
|
<View style={styles.layoutAvi}>
|
||||||
<PreviewableUserAvatar
|
<PreviewableUserAvatar
|
||||||
@@ -290,7 +301,6 @@ const styles = StyleSheet.create({
|
|||||||
top: 70,
|
top: 70,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
borderLeftWidth: 2,
|
borderLeftWidth: 2,
|
||||||
borderLeftColor: colors.gray2,
|
|
||||||
},
|
},
|
||||||
contentHider: {
|
contentHider: {
|
||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
import {Link} from '#/view/com/util/Link'
|
import {Link} from '#/view/com/util/Link'
|
||||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a, select, useTheme} from '#/alf'
|
||||||
import {
|
import {
|
||||||
GalleryBleed,
|
GalleryBleed,
|
||||||
maybeApplyGalleryOffsetStyles,
|
maybeApplyGalleryOffsetStyles,
|
||||||
@@ -167,6 +167,7 @@ let FeedItemInner = ({
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const {openComposer} = useOpenComposer()
|
const {openComposer} = useOpenComposer()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
const t = useTheme()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
const [hover, setHover] = useState(false)
|
const [hover, setHover] = useState(false)
|
||||||
@@ -346,8 +347,11 @@ let FeedItemInner = ({
|
|||||||
style={[
|
style={[
|
||||||
styles.replyLine,
|
styles.replyLine,
|
||||||
{
|
{
|
||||||
flexGrow: 1,
|
backgroundColor: select(t.name, {
|
||||||
backgroundColor: pal.colors.replyLine,
|
light: t.palette.contrast_100,
|
||||||
|
dim: t.palette.contrast_200,
|
||||||
|
dark: t.palette.contrast_200,
|
||||||
|
}),
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -381,8 +385,11 @@ let FeedItemInner = ({
|
|||||||
style={[
|
style={[
|
||||||
styles.replyLine,
|
styles.replyLine,
|
||||||
{
|
{
|
||||||
flexGrow: 1,
|
backgroundColor: select(t.name, {
|
||||||
backgroundColor: pal.colors.replyLine,
|
light: t.palette.contrast_100,
|
||||||
|
dim: t.palette.contrast_200,
|
||||||
|
dark: t.palette.contrast_200,
|
||||||
|
}),
|
||||||
marginTop: live ? 8 : 4,
|
marginTop: live ? 8 : 4,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -536,6 +543,7 @@ const styles = StyleSheet.create({
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
},
|
},
|
||||||
replyLine: {
|
replyLine: {
|
||||||
|
flexGrow: 1,
|
||||||
width: 2,
|
width: 2,
|
||||||
marginLeft: 'auto',
|
marginLeft: 'auto',
|
||||||
marginRight: 'auto',
|
marginRight: 'auto',
|
||||||
|
|||||||
@@ -1,75 +1,70 @@
|
|||||||
import {useMemo} from 'react'
|
import {useMemo} from 'react'
|
||||||
import {StyleSheet, View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import Svg, {Circle, Line} from 'react-native-svg'
|
import Svg, {Circle, Line} from 'react-native-svg'
|
||||||
import {AtUri} from '@atproto/api'
|
import {AtUri} from '@atproto/api'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
|
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {atoms as a, select, useTheme} from '#/alf'
|
||||||
|
import {Link} from '#/components/Link'
|
||||||
import {SubtleHover} from '#/components/SubtleHover'
|
import {SubtleHover} from '#/components/SubtleHover'
|
||||||
import {Link} from '../util/Link'
|
import {Text} from '#/components/Typography'
|
||||||
import {Text} from '../util/text/Text'
|
|
||||||
|
|
||||||
export function ViewFullThread({uri}: {uri: string}) {
|
export function ViewFullThread({uri}: {uri: string}) {
|
||||||
const {
|
const t = useTheme()
|
||||||
state: hover,
|
|
||||||
onIn: onHoverIn,
|
|
||||||
onOut: onHoverOut,
|
|
||||||
} = useInteractionState()
|
|
||||||
const pal = usePalette('default')
|
|
||||||
const itemHref = useMemo(() => {
|
const itemHref = useMemo(() => {
|
||||||
const urip = new AtUri(uri)
|
const urip = new AtUri(uri)
|
||||||
return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey)
|
return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey)
|
||||||
}, [uri])
|
}, [uri])
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
style={[styles.viewFullThread]}
|
style={[
|
||||||
href={itemHref}
|
a.flex_row,
|
||||||
asAnchor
|
{
|
||||||
noFeedback
|
gap: 10,
|
||||||
onPointerEnter={onHoverIn}
|
paddingLeft: 18,
|
||||||
onPointerLeave={onHoverOut}>
|
},
|
||||||
<SubtleHover
|
]}
|
||||||
hover={hover}
|
to={itemHref}
|
||||||
// adjust position for visual alignment - the actual box has lots of top padding and not much bottom padding -sfn
|
label={l`View full thread`}>
|
||||||
style={{top: 8, bottom: -5}}
|
{({hovered}) => (
|
||||||
/>
|
<>
|
||||||
<View style={styles.viewFullThreadDots}>
|
<SubtleHover
|
||||||
<Svg width="4" height="40">
|
hover={hovered}
|
||||||
<Line
|
// adjust position for visual alignment - the actual box has lots of top padding and not much bottom padding -sfn
|
||||||
x1="2"
|
style={{top: 8, bottom: -5}}
|
||||||
y1="0"
|
|
||||||
x2="2"
|
|
||||||
y2="15"
|
|
||||||
stroke={pal.colors.replyLine}
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
/>
|
||||||
<Circle cx="2" cy="22" r="1.5" fill={pal.colors.replyLineDot} />
|
<View style={[a.align_center, {width: 42}]}>
|
||||||
<Circle cx="2" cy="28" r="1.5" fill={pal.colors.replyLineDot} />
|
<Svg width="4" height="40">
|
||||||
<Circle cx="2" cy="34" r="1.5" fill={pal.colors.replyLineDot} />
|
<Line
|
||||||
</Svg>
|
x1="2"
|
||||||
</View>
|
y1="0"
|
||||||
|
x2="2"
|
||||||
<Text type="md" style={[pal.link, {paddingTop: 18, paddingBottom: 4}]}>
|
y2="15"
|
||||||
{/* HACKFIX: Trans isn't working after SDK 53 upgrade -sfn */}
|
stroke={select(t.name, {
|
||||||
{_(msg`View full thread`)}
|
light: t.palette.contrast_100,
|
||||||
</Text>
|
dim: t.palette.contrast_200,
|
||||||
|
dark: t.palette.contrast_200,
|
||||||
|
})}
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
<Circle cx="2" cy="22" r="1.5" fill={t.palette.contrast_200} />
|
||||||
|
<Circle cx="2" cy="28" r="1.5" fill={t.palette.contrast_200} />
|
||||||
|
<Circle cx="2" cy="34" r="1.5" fill={t.palette.contrast_200} />
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
{color: t.palette.primary_500, paddingTop: 18, paddingBottom: 4},
|
||||||
|
]}>
|
||||||
|
{/* HACKFIX: Trans isn't working after SDK 53 upgrade -sfn */}
|
||||||
|
{l`View full thread`}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
viewFullThread: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 10,
|
|
||||||
paddingLeft: 18,
|
|
||||||
},
|
|
||||||
viewFullThreadDots: {
|
|
||||||
width: 42,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
Reference in New Issue
Block a user