Merge branch 'main' into notification-follow-buttons

This commit is contained in:
Alex Benzer
2026-04-22 13:30:08 -07:00
176 changed files with 95934 additions and 45295 deletions
+31 -4
View File
@@ -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<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 = {
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<OtherRequiredData>(
createOtherRequiredDataQueryKey({did}),
next,
)
}
export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) {
const did = getDidFromAgentSession(agent)
+9 -4
View File
@@ -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)
+140 -71
View File
@@ -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(
+20 -1
View File
@@ -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})
}
+2
View File
@@ -12,7 +12,9 @@ export enum Features {
ImageUploadsHighResolution = 'image_uploads:high_resolution',
ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled',
GroupChatsEnable = 'group_chats:enable',
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
KlipyGifProviderEnable = 'klipy_gif_provider:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
+96
View File
@@ -487,6 +487,7 @@ export type Events = {
| 'SeeMoreSuggestedUsers'
| 'ProgressGuide'
location: 'Card' | 'Profile' | 'FollowAll'
recSource?: 'Search'
recId?: number | string
position: number
suggestedDid: string
@@ -514,6 +515,7 @@ export type Events = {
| 'Onboarding'
| 'SeeMoreSuggestedUsers'
| 'ProgressGuide'
recSource?: 'Search'
recId?: number | string
position: number
suggestedDid: string
@@ -798,6 +800,100 @@ export type Events = {
*/
resultSourceLanguage: string
}
'composer:language:suggestLanguage': {
os: Platform['OS']
/**
* The language we detected and suggested to the user as an override for the
* expected target language.
*/
suggestedLanguage: string | undefined
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
/**
* The length of the text being translated. We assume shorter texts are
* more likely to have inaccurate translations.
*/
textLength: number
}
'composer:language:acceptSuggestion': {
os: Platform['OS']
/**
* The language we detected and suggested to the user as an override for the
* expected target language.
*/
suggestedLanguage: string | undefined
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
/**
* The length of the text being translated. We assume shorter texts are
* more likely to have inaccurate translations.
*/
textLength: number
}
'composer:language:declineSuggestion': {
os: Platform['OS']
/**
* The language we detected and suggested to the user as an override for the
* expected target language.
*/
suggestedLanguage: string | undefined
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
/**
* The length of the text being translated. We assume shorter texts are
* more likely to have inaccurate translations.
*/
textLength: number
}
'composer:language:replyNudgeAccept': {
/**
* The language of the post the user is replying to.
*/
replyToLanguage: string
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
}
'composer:language:replyNudgeDecline': {
/**
* The language of the post the user is replying to.
*/
replyToLanguage: string
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
}
'composer:language:nudgeUser': {
os: Platform['OS']
/**
* The language we detected and suggested to the user as an override for the
* expected target language.
*/
suggestedLanguage: string | undefined
/**
* This is the user's current composer languages, which are always defined.
*/
currentTargetLanguages: string[]
/**
* The length of the text being translated. We assume shorter texts are
* more likely to have inaccurate translations.
*/
textLength: number
}
'composer:language:langSelectorPressed': {
/**
* If the user was nudged by our language detection to update their language
*/
wasNudged: boolean
}
'postMenu:openMuteWordsDialog': {
uri: string
+11 -3
View File
@@ -28,7 +28,10 @@ export function AvatarBubbles({
size = 'large',
}: Props) {
const {currentAccount} = useSession()
const profiles = allProfiles.filter(p => p.did !== currentAccount?.did)
const profiles =
allProfiles.length > 2
? allProfiles.filter(p => p.did !== currentAccount?.did)
: allProfiles
const containerSize =
typeof size === 'number'
? size
@@ -45,7 +48,12 @@ export function AvatarBubbles({
: size === 'medium'
? 56 / 120
: 1
const marginOffset = size === 'small' || size === 'medium' ? -2 : 0
const marginOffset =
(typeof size === 'number' && size < 120) ||
size === 'small' ||
size === 'medium'
? -2
: 0
const initialValue = animate ? 0 : 1
const p0 = useSharedValue(initialValue)
@@ -81,7 +89,7 @@ export function AvatarBubbles({
let avatars = (
<>
<AvatarBubble
profile={profiles[0] ?? allProfiles[0]}
profile={profiles[0]}
scale={p0}
size={76}
x={-2}
+18 -7
View File
@@ -54,7 +54,7 @@ import {HITSLOP_10} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {logger} from '#/logger'
import {atoms as a, platform, tokens, useTheme} from '#/alf'
import {atoms as a, flatten, platform, tokens, useTheme} from '#/alf'
import {
Context,
ItemContext,
@@ -527,7 +527,8 @@ export function AuxiliaryView({
}
})
const menuContext = useMemo(() => ({align}), [align])
const xOffset = (flatten(style)?.marginLeft as number) ?? 0
const menuContext = useMemo(() => ({align, xOffset}), [align, xOffset])
const onLayout = useCallback(() => {
if (!measurement) return
@@ -655,7 +656,8 @@ export function Outer({
[context.measurement, frame.height, insets, translationSV],
)
const menuContext = useMemo(() => ({align}), [align])
const xOffset = (flatten(style)?.marginLeft as number) ?? 0
const menuContext = useMemo(() => ({align, xOffset}), [align, xOffset])
if (!context.isOpen || !context.measurement) return null
@@ -763,7 +765,7 @@ export function Item({
onOut: onPressOut,
} = useInteractionState()
const id = useId()
const {align} = useContextMenuMenuContext()
const {align, xOffset: menuXOffset} = useContextMenuMenuContext()
const {close, measurement, registerHoverable} = context
@@ -779,8 +781,8 @@ export function Item({
const xOffset = position
? position.x
: align === 'left'
? measurement.x
: measurement.x + measurement.width - layout.width
? measurement.x + menuXOffset
: measurement.x + measurement.width - layout.width - menuXOffset
registerHoverable(
id,
@@ -796,7 +798,16 @@ export function Item({
},
)
},
[id, measurement, registerHoverable, close, onPress, align, position],
[
id,
measurement,
registerHoverable,
close,
onPress,
align,
menuXOffset,
position,
],
)
const itemContext = useMemo(
+1
View File
@@ -65,6 +65,7 @@ export type ContextType = {
export type MenuContextType = {
align: 'left' | 'right'
xOffset: number
}
export type ItemContextType = {
+1
View File
@@ -167,6 +167,7 @@ export function SuggestedFollowsHome() {
return (
<ProfileGrid
recId={data?.recId}
isSuggestionsLoading={isLoading}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
+13 -2
View File
@@ -1,6 +1,11 @@
import {forwardRef, memo, useContext, useMemo} from 'react'
import {StyleSheet, View, type ViewProps, type ViewStyle} from 'react-native'
import {type StyleProp} from 'react-native'
import {
type StyleProp,
StyleSheet,
View,
type ViewProps,
type ViewStyle,
} from 'react-native'
import {
KeyboardAwareScrollView,
type KeyboardAwareScrollViewProps,
@@ -11,6 +16,7 @@ import Animated, {
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useEnableMinimalShellModeForScreen} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {
atoms as a,
@@ -30,6 +36,7 @@ export * as Header from '#/components/Layout/Header'
export type ScreenProps = React.ComponentProps<typeof View> & {
style?: StyleProp<ViewStyle>
noInsetTop?: boolean
minimalShell?: boolean
}
/**
@@ -38,9 +45,13 @@ export type ScreenProps = React.ComponentProps<typeof View> & {
export const Screen = memo(function Screen({
style,
noInsetTop,
minimalShell = false,
...props
}: ScreenProps) {
const {top} = useSafeAreaInsets()
useEnableMinimalShellModeForScreen({enabled: minimalShell})
return (
<>
{IS_WEB && <WebCenterBorders />}
@@ -1,7 +1,7 @@
import {useImperativeHandle, useRef, useState} from 'react'
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
import {type AppBskyEmbedVideo} from '@atproto/api'
import {BlueskyVideoView} from '@haileyok/bluesky-video'
import {BlueskyVideoView} from '@bsky.app/video'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
@@ -249,6 +249,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
moderationOpts={moderationOpts!}
noBorder={index === 0}
position={index}
recSource={hasSearchText ? 'Search' : undefined}
recId={recIdForLogging}
isGuide={isGuide}
/>
@@ -264,7 +265,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
return null
}
},
[moderationOpts, recIdForLogging, isGuide],
[moderationOpts, hasSearchText, recIdForLogging, isGuide],
)
// Track seen profiles
@@ -286,6 +287,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
)
ax.metric('suggestedUser:seen', {
logContext: isGuide ? 'ProgressGuide' : 'SeeMoreSuggestedUsers',
recSource: hasSearchText ? 'Search' : undefined,
recId: recIdForLogging,
position: position !== -1 ? position : 0,
suggestedDid: item.profile.did,
@@ -548,6 +550,7 @@ let FollowProfileCard = ({
moderationOpts,
noBorder,
position,
recSource,
recId,
isGuide,
}: {
@@ -555,6 +558,7 @@ let FollowProfileCard = ({
moderationOpts: ModerationOpts
noBorder?: boolean
position: number
recSource?: 'Search'
recId?: string
isGuide: boolean
}): React.ReactNode => {
@@ -564,6 +568,7 @@ let FollowProfileCard = ({
moderationOpts={moderationOpts}
noBorder={noBorder}
position={position}
recSource={recSource}
recId={recId}
isGuide={isGuide}
/>
@@ -577,6 +582,7 @@ function FollowProfileCardInner({
onFollow,
noBorder,
position,
recSource,
recId,
isGuide,
}: {
@@ -585,6 +591,7 @@ function FollowProfileCardInner({
onFollow?: () => void
noBorder?: boolean
position: number
recSource?: 'Search'
recId?: string
isGuide: boolean
}) {
@@ -625,6 +632,7 @@ function FollowProfileCardInner({
? 'ProgressGuide'
: 'SeeMoreSuggestedUsers',
location: 'Card',
recSource,
recId,
position,
suggestedDid: profile.did,
+17 -23
View File
@@ -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({
<Dialog.Handle />
{isBirthdateUpdateAllowed ? (
<Dialog.ScrollableInner
label={_(msg`My Birthdate`)}
label={l`My birthdate`}
style={web({maxWidth: 400})}>
<View style={[a.gap_md]}>
<Text style={[a.text_xl, a.font_semi_bold]}>
<Trans>My Birthdate</Trans>
<Trans>My birthdate</Trans>
</Text>
<Text
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
@@ -64,9 +62,7 @@ export function BirthDateSettingsDialog({
<ErrorMessage
message={
error?.toString() ||
_(
msg`We were unable to load your birthdate preferences. Please try again.`,
)
l`We were unable to load your birthdate preferences. Please try again.`
}
style={[a.rounded_sm]}
/>
@@ -88,7 +84,7 @@ export function BirthDateSettingsDialog({
</Dialog.ScrollableInner>
) : (
<Dialog.ScrollableInner
label={_(msg`You recently changed your birthdate`)}
label={l`You recently changed your birthdate`}
style={web({maxWidth: 400})}>
<View style={[a.gap_sm]}>
<Text
@@ -123,15 +119,16 @@ function BirthdayInner({
control: Dialog.DialogControlProps
preferences: UsePreferencesQueryResponse
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const cleanError = useCleanError()
const [date, setDate] = useState(preferences.birthDate || getDateAgo(18))
const {isPending, error, mutateAsync: setBirthDate} = useBirthdateMutation()
const hasChanged = date !== preferences.birthDate
const errorMessage = useMemo(() => {
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`}
/>
</View>
{isUnder18 && hasChanged && (
<Admonition type="info">
<Trans>
@@ -171,30 +168,27 @@ function BirthdayInner({
</Trans>
</Admonition>
)}
{isUnder13 && (
<Admonition type="error">
<Trans>
You must be at least 13 years old to use Bluesky. Read our{' '}
<SimpleInlineLinkText
to="https://bsky.social/about/support/tos"
label={_(msg`Terms of Service`)}>
label={l`Terms of Service`}>
Terms of Service
</SimpleInlineLinkText>{' '}
for more information.
</Trans>
</Admonition>
)}
{errorMessage ? (
<ErrorMessage message={errorMessage} style={[a.rounded_sm]} />
) : undefined}
<View style={IS_WEB && [a.flex_row, a.justify_end]}>
<Button
label={hasChanged ? _(msg`Save birthdate`) : _(msg`Done`)}
label={hasChanged ? l`Save birthdate` : l`Done`}
size="large"
onPress={onSave}
onPress={() => void onSave()}
variant="solid"
color="primary"
disabled={isUnder13}>
+1 -2
View File
@@ -56,12 +56,11 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
}
return (
<View style={[a.w_full, a.my_sm]}>
<View style={[a.w_full, a.mt_md]}>
<Text
style={[
a.text_xs,
a.text_center,
t.atoms.bg,
t.atoms.text_contrast_medium,
a.px_md,
]}>
+6 -3
View File
@@ -33,7 +33,7 @@ export function EmojiReactionPicker({
const t = useTheme()
const isFromSelf = message.sender?.did === currentAccount?.did
const {measurement, close} = useContextMenuContext()
const {align} = useContextMenuMenuContext()
const {align, xOffset} = useContextMenuMenuContext()
const [layout, setLayout] = useState({width: 0, height: 0})
const {width: screenWidth} = useWindowDimensions()
@@ -44,12 +44,15 @@ export function EmojiReactionPicker({
const position = useMemo(() => {
return {
x: align === 'left' ? 12 : screenWidth - layout.width - 12,
x:
align === 'left'
? (measurement?.x ?? 0) + xOffset
: (measurement?.x ?? 0) + (measurement?.width ?? 0) - layout.width,
y: (measurement?.y ?? 0) - tokens.space.xs - layout.height,
height: layout.height,
width: layout.width,
}
}, [measurement, align, screenWidth, layout])
}, [measurement, align, xOffset, screenWidth, layout])
const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
+25 -4
View File
@@ -168,12 +168,18 @@ let MessageItem = ({
isInCluster && !effectiveFirstInCluster && !effectiveLastInCluster
const hasReactions = message.reactions && message.reactions.length > 0
const prevHasReactions =
prevIsMessage &&
prevMessage.reactions != null &&
prevMessage.reactions.length > 0
const squaredBottomCorner =
!hasReactions &&
isInCluster &&
(isInMiddleOfCluster || effectiveFirstInCluster)
const squaredTopCorner =
isInCluster && (isInMiddleOfCluster || effectiveLastInCluster)
!prevHasReactions &&
isInCluster &&
(isInMiddleOfCluster || effectiveLastInCluster)
const pendingColor = t.palette.primary_300
@@ -398,8 +404,7 @@ let MessageItem = ({
<DateDivider date={message.sentAt} />
</Animated.View>
)}
<View
style={[messageInset, isFirstInCluster && !showDateDivider && a.mt_sm]}>
<View style={[messageInset, effectiveFirstInCluster && a.mt_md]}>
<View style={[a.relative]}>
{showAvatar ? (
<View
@@ -474,7 +479,23 @@ let MessageItem = ({
]}>
<RichText
value={rt}
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
style={[
a.text_md,
isFromSelf && {color: t.palette.white},
// Emoji-only: add top leading to avoid clipping the
// glyph, then pull the bottom up by the same amount so
// the glyph bottom-aligns with the avatar instead of
// sitting above its line-box baseline.
isOnlyEmoji(message.text) && [
a.leading_tight,
// Visually align bottom of the emoji with the avatar
!isFromSelf &&
platform({
android: {marginTop: a.mt_2xs.marginTop},
default: {marginBottom: -a.mb_sm.marginBottom},
}),
],
]}
interactiveStyle={a.underline}
enableTags
emojiMultiplier={3}
-1
View File
@@ -29,7 +29,6 @@ let MessageItemEmbed = ({
<View
style={[
!isFromSelf && a.ml_sm,
t.atoms.bg,
native({
flexBasis: 0,
width: Math.min(screen.width, 600) / 1.4,
+1 -1
View File
@@ -357,7 +357,7 @@ function ReactionTab({
accessibilityRole="button"
accessibilityHint={
reaction.key === 'all'
? l`Tap to show all reactions `
? l`Tap to show all reactions`
: l`Tap to show ${reaction.value} reactions`
}
hitSlop={HITSLOP_10}
+44
View File
@@ -0,0 +1,44 @@
import {View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {type ConvoItem} from '#/state/messages/convo/types'
import {atoms as a, useTheme} from '#/alf'
import {getSystemMessageInfo} from '#/components/dms/getSystemMessageInfo'
import {Text} from '#/components/Typography'
export function SystemMessageItem({
item,
}: {
item: ConvoItem & {type: 'system-message'}
}) {
const t = useTheme()
const {i18n} = useLingui()
const info = getSystemMessageInfo(item.message.data, item.relatedProfiles)
if (!info) return null
const {Icon, message} = info
return (
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_center,
a.px_md,
a.mt_md,
]}>
<Icon size="xs" style={[a.mr_2xs, t.atoms.text_contrast_medium]} />
<Text
style={[
a.text_xs,
a.text_center,
t.atoms.text_contrast_medium,
{includeFontPadding: false, textAlignVertical: 'center'},
]}>
{i18n._(message)}
</Text>
</View>
)
}
+93
View File
@@ -0,0 +1,93 @@
import {AppBskyEmbedRecord, ChatBskyConvoDefs} from '@atproto/api'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {
postUriToRelativePath,
toBskyAppUrl,
toShortUrl,
} from '#/lib/strings/url-helpers'
export type UserMessageInfo = {
message: string | null
sentAt: string
reportableMessage?: ChatBskyConvoDefs.MessageView
}
export function getMessageInfo({
convo,
currentAccountDid,
i18n,
}: {
convo: ChatBskyConvoDefs.ConvoView
currentAccountDid: string | undefined
i18n: I18n
}): UserMessageInfo | null {
if (!ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
return null
}
const lastMessage = convo.lastMessage
const isFromMe = lastMessage.sender?.did === currentAccountDid
const senderDid = lastMessage.sender?.did
const sender = convo.members.find(m => m.did === senderDid)
const name = sender ? createSanitizedDisplayName(sender) : null
const isGroup = ChatBskyConvoDefs.isGroupConvo(convo.kind)
const reportableMessage = isFromMe ? undefined : lastMessage
const prefix = (message: string) => {
if (isFromMe) {
return i18n._(
msg({
message: `You: ${message}`,
comment: 'When the last message in a chat was made by you.',
}),
)
} else if (isGroup && name) {
return i18n._(
msg({
message: `${name}: ${message}`,
comment:
'When the last message in a group chat came from someone other than you.',
}),
)
}
return message
}
let message: string | null = null
if (lastMessage.text) {
message = prefix(lastMessage.text)
} else if (lastMessage.embed) {
const defaultEmbeddedContentMessage = i18n._(
msg`(contains embedded content)`,
)
if (AppBskyEmbedRecord.isView(lastMessage.embed)) {
const embed = lastMessage.embed
if (AppBskyEmbedRecord.isViewRecord(embed.record)) {
const record = embed.record
const path = postUriToRelativePath(record.uri, {
handle: record.author.handle,
})
const href = path ? toBskyAppUrl(path) : undefined
const short = href ? toShortUrl(href) : defaultEmbeddedContentMessage
message = prefix(short)
} else {
message = prefix(defaultEmbeddedContentMessage)
}
} else {
message = prefix(defaultEmbeddedContentMessage)
}
}
return {
message,
sentAt: lastMessage.sentAt,
reportableMessage,
}
}
+54
View File
@@ -0,0 +1,54 @@
import {ChatBskyConvoDefs} from '@atproto/api'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
export type UserReactionInfo = {
message: string
createdAt: string
}
export function getReactionInfo({
convo,
currentAccountDid,
i18n,
}: {
convo: ChatBskyConvoDefs.ConvoView
currentAccountDid: string | undefined
i18n: I18n
}): UserReactionInfo | null {
if (!ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) {
return null
}
const {reaction, message: reactedTo} = convo.lastReaction
const isFromMe = reaction.sender.did === currentAccountDid
const senderDid = reaction.sender.did
const sender = convo.members.find(m => m.did === senderDid)
const name = sender ? createSanitizedDisplayName(sender) : null
const lastMessageText = reactedTo.text
const fallbackMessage = i18n._(
msg({
message: 'a message',
comment:
'If last message does not contain text, fall back to "{user} reacted to {a message}"',
}),
)
const target = lastMessageText ? `"${lastMessageText}"` : fallbackMessage
let message: string
if (isFromMe) {
message = i18n._(msg`You reacted ${reaction.value} to ${target}`)
} else if (name) {
message = i18n._(msg`${name} reacted ${reaction.value} to ${target}`)
} else {
message = i18n._(msg`Someone reacted ${reaction.value} to ${target}`)
}
return {
message,
createdAt: reaction.createdAt,
}
}
@@ -0,0 +1,89 @@
import {type ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
import {type MessageDescriptor} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as LeaveIcon} from '#/components/icons/ArrowBoxLeft'
import {ArrowBoxRight_Stroke2_Corner3_Rounded as JoinIcon} from '#/components/icons/ArrowBoxRight'
import {
ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon,
ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon,
} from '#/components/icons/ChainLink'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {
Lock_Stroke2_Corner0_Rounded as LockIcon,
Unlock_Stroke2_Corner2_Rounded as UnlockIcon,
} from '#/components/icons/Lock'
import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil'
export type SystemMessageInfo = {
message: MessageDescriptor
Icon: React.ComponentType<SVGIconProps>
}
function getReferredDisplayName(
user: ChatBskyConvoDefs.SystemMessageReferredUser,
relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[],
): string | null {
const profile = relatedProfiles.find(p => p.did === user.did)
return profile ? createSanitizedDisplayName(profile) : null
}
export function getSystemMessageInfo(
data: ChatBskyConvoDefs.SystemMessageView['data'],
relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[],
): SystemMessageInfo | null {
if (ChatBskyConvoDefs.isSystemMessageDataAddMember(data)) {
const name = getReferredDisplayName(data.member, relatedProfiles)
return {
Icon: JoinIcon,
message: name
? msg`${name} was added to the group`
: msg`Someone was added to the group`,
}
} else if (ChatBskyConvoDefs.isSystemMessageDataRemoveMember(data)) {
const name = getReferredDisplayName(data.member, relatedProfiles)
return {
Icon: LeaveIcon,
message: name
? msg`${name} was removed from the group`
: msg`Someone was removed from the group`,
}
} else if (ChatBskyConvoDefs.isSystemMessageDataMemberJoin(data)) {
const name = getReferredDisplayName(data.member, relatedProfiles)
return {
Icon: JoinIcon,
message: name
? msg`${name} joined the group`
: msg`Someone joined the group`,
}
} else if (ChatBskyConvoDefs.isSystemMessageDataMemberLeave(data)) {
const name = getReferredDisplayName(data.member, relatedProfiles)
return {
Icon: LeaveIcon,
message: name ? msg`${name} left the group` : msg`Someone left the group`,
}
} else if (ChatBskyConvoDefs.isSystemMessageDataLockConvo(data)) {
return {Icon: LockIcon, message: msg`Chat locked`}
} else if (ChatBskyConvoDefs.isSystemMessageDataUnlockConvo(data)) {
return {Icon: UnlockIcon, message: msg`Chat unlocked`}
} else if (ChatBskyConvoDefs.isSystemMessageDataLockConvoPermanently(data)) {
return {Icon: LockIcon, message: msg`Chat locked permanently`}
} else if (ChatBskyConvoDefs.isSystemMessageDataEditGroup(data)) {
return {
Icon: PencilIcon,
message: data.newName
? msg`Chat title changed to ${data.newName}`
: msg`Chat title changed`,
}
} else if (ChatBskyConvoDefs.isSystemMessageDataCreateJoinLink(data)) {
return {Icon: ChainLinkIcon, message: msg`Invite link created`}
} else if (ChatBskyConvoDefs.isSystemMessageDataEditJoinLink(data)) {
return {Icon: ChainLinkIcon, message: msg`Invite link edited`}
} else if (ChatBskyConvoDefs.isSystemMessageDataEnableJoinLink(data)) {
return {Icon: ChainLinkIcon, message: msg`Invite link enabled`}
} else if (ChatBskyConvoDefs.isSystemMessageDataDisableJoinLink(data)) {
return {Icon: ChainLinkBrokenIcon, message: msg`Invite link disabled`}
}
return null
}
+6 -3
View File
@@ -110,14 +110,16 @@ export function parseConvoView(
owner = member as GroupConvoMember
}
} else {
throw new Error(
logger.warn(
'Expected a GroupConvoMember, got an unknown kind of member',
)
return null
}
}
if (!owner) {
throw new Error('No owner found in group convo')
logger.warn('No owner found in group convo')
return null
}
return {
@@ -136,7 +138,8 @@ export function parseConvoView(
const otherUser = convoView.members.find(m => m.did !== ownDid)
if (!otherUser) {
throw new Error('No other user found in direct convo')
logger.warn('No other user found in direct convo')
return null
}
return {
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const ArrowBoxRight_Stroke2_Corner3_Rounded = createSinglePathSVG({
path: 'M17 3a4 4 0 0 1 4 4v10a4 4 0 0 1-4 4h-2a1 1 0 1 1 0-2h2a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2a1 1 0 1 1 0-2h2Zm-6.707 4.793a1 1 0 0 1 1.414 0l3.5 3.5a1 1 0 0 1 0 1.414l-3.5 3.5a1 1 0 1 1-1.414-1.414L12.086 13H4a1 1 0 1 1 0-2h8.086l-1.793-1.793a1 1 0 0 1 0-1.414Z',
})
+4
View File
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
export const ChainLink_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M18.535 5.465a5.003 5.003 0 0 0-7.076 0l-.005.005-.752.742a1 1 0 1 1-1.404-1.424l.749-.74a7.003 7.003 0 0 1 9.904 9.905l-.002.003-.737.746a1 1 0 1 1-1.424-1.404l.747-.757a5.003 5.003 0 0 0 0-7.076ZM6.202 9.288a1 1 0 0 1 .01 1.414l-.747.757a5.003 5.003 0 1 0 7.076 7.076l.005-.005.752-.742a1 1 0 1 1 1.404 1.424l-.746.737-.003.002a7.003 7.003 0 0 1-9.904-9.904l.74-.75a1 1 0 0 1 1.413-.009Zm8.505.005a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414-1.414l4-4a1 1 0 0 1 1.414 0Z',
})
export const ChainLinkBroken_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M14.3 23v-1.1a1 1 0 0 1 2 0V23a1 1 0 1 1-2 0Zm5.243-3.457a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM4.788 9.298a1 1 0 0 1 1.424 1.404l-.742.752-.004.005a5.003 5.003 0 1 0 7.075 7.075l.005-.004.752-.742a1 1 0 0 1 1.404 1.424l-.747.736a7.003 7.003 0 1 1-9.904-9.904l.737-.746ZM23 14.3a1 1 0 0 1 0 2h-1.1a1 1 0 1 1 0-2H23ZM10.044 4.05a7.005 7.005 0 0 1 9.905 9.906h0l-.737.746a1 1 0 0 1-1.424-1.404l.742-.752.004-.005a5.003 5.003 0 1 0-7.075-7.075l-.005.004-.752.742a1 1 0 0 1-1.404-1.424l.746-.737ZM2.1 7.7a1 1 0 1 1 0 2H1a1 1 0 0 1 0-2h1.1Zm-.157-5.757a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM7.7 2.1V1a1 1 0 1 1 2 0v1.1a1 1 0 0 1-2 0Z',
})
+4
View File
@@ -7,3 +7,7 @@ export const Lock_Stroke2_Corner0_Rounded = createSinglePathSVG({
export const Lock_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M7 7a5 5 0 0 1 10 0v2a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7Zm0 4a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7Zm8-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z',
})
export const Unlock_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M12 13a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z"/><path fill="#000" fill-rule="evenodd" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5Zm-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7Z',
})
+17 -3
View File
@@ -13,7 +13,7 @@ import {Trans} from '@lingui/react/macro'
import {type Dimensions} from '#/lib/media/types'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, useTheme, web} from '#/alf'
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {Text} from '#/components/Typography'
@@ -210,12 +210,17 @@ export function AutoSizedImage({
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
style={[
style={({pressed}) => [
a.w_full,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
{aspectRatio: max ?? 1},
web([
a.transition_transform,
{transitionDuration: '200ms'},
pressed && {transform: [{scale: 0.99}]},
]),
]}>
{contents}
</Pressable>
@@ -237,7 +242,16 @@ export function AutoSizedImage({
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
style={[a.h_full]}>
style={({pressed}) => [
a.h_full,
a.rounded_md,
a.overflow_hidden,
web([
a.transition_transform,
{transitionDuration: '200ms'},
pressed && {transform: [{scale: 0.99}]},
]),
]}>
{contents}
</Pressable>
</ConstrainedImage>
+11 -7
View File
@@ -257,7 +257,6 @@ export function Gallery({
horizontal
pagingEnabled={false}
showsHorizontalScrollIndicator={false}
decelerationRate={0.993}
directionalLockEnabled
nestedScrollEnabled
alwaysBounceVertical={false}
@@ -431,15 +430,20 @@ function GalleryImage({
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
style={[
style={({pressed}) => [
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
web({
cursor: 'inherit',
outline: 0,
border: 0,
}),
web([
{
cursor: 'inherit',
outline: 0,
border: 0,
},
a.transition_transform,
{transitionDuration: '200ms'},
pressed && {transform: [{scale: 0.99}]},
]),
]}>
<Image
source={{uri: image.thumb}}
-2
View File
@@ -21,8 +21,6 @@ export type PaletteColor = {
textInverted: string
link: string
border: string
borderDark: string
icon: string
[k: string]: string
}
export type Palette = Record<PaletteColorName, PaletteColor>
+19 -11
View File
@@ -1,17 +1,25 @@
import {useCallback, useInsertionEffect, useRef} from 'react'
// This should be used sparingly. It erases reactivity, i.e. when the inputs
// change, the function itself will remain the same. This means that if you
// use this at a higher level of your tree, and then some state you read in it
// changes, there is no mechanism for anything below in the tree to "react"
// to this change (e.g. by knowing to call your function again).
//
// Also, you should avoid calling the returned function during rendering
// since the values captured by it are going to lag behind.
export function useNonReactiveCallback<T extends Function>(fn: T): T {
const ref = useRef(fn)
const noop = () => {}
/**
* This should be used sparingly. It erases reactivity, i.e. when the inputs
* change, the function itself will remain the same. This means that if you use
* this at a higher level of your tree, and then some state you read in it
* changes, there is no mechanism for anything below in the tree to "react" to
* this change (e.g. by knowing to call your function again).
*
* Also, you should avoid calling the returned function during rendering since
* the values captured by it are going to lag behind.
*
* For objects, see `useNonReactiveObject` instead.
*/
export function useNonReactiveCallback<T extends Function = () => void>(
fn?: T,
): T {
const ref = useRef<T>((fn ?? noop) as T)
useInsertionEffect(() => {
ref.current = fn
ref.current = (fn ?? noop) as T
}, [fn])
return useCallback(
(...args: any) => {
+20
View File
@@ -0,0 +1,20 @@
import {useInsertionEffect, useRef} from 'react'
/**
* This should be used sparingly. It erases reactivity, i.e. when the inputs
* change, the returned object itself will remain the same. This means that if
* you use this at a higher level of your tree, and then some state you read in
* it changes, there is no mechanism for anything below in the tree to "react"
* to this change (e.g. by knowing to call your function again).
*
* For callbacks, see `useNonReactiveCallback` instead.
*/
export function useNonReactiveObject<T extends Record<string, unknown>>(
o: T,
): React.RefObject<T> {
const ref = useRef(o)
useInsertionEffect(() => {
ref.current = o
}, [o])
return ref
}
-8
View File
@@ -13,12 +13,10 @@ export interface UsePaletteValue {
viewLight: ViewStyle
btn: ViewStyle
border: ViewStyle
borderDark: ViewStyle
text: TextStyle
textLight: TextStyle
textInverted: TextStyle
link: TextStyle
icon: TextStyle
}
/**
@@ -42,9 +40,6 @@ export function usePalette(color: PaletteColorName): UsePaletteValue {
border: {
borderColor: palette.border,
},
borderDark: {
borderColor: palette.borderDark,
},
text: {
color: palette.text,
},
@@ -57,9 +52,6 @@ export function usePalette(color: PaletteColorName): UsePaletteValue {
link: {
color: palette.link,
},
icon: {
color: palette.icon,
},
}
}, [theme, color])
}
+11 -4
View File
@@ -1,10 +1,17 @@
import {useMemo} from 'react'
export function Provider({children}: {children: React.ReactNode}) {
return children
}
const noop = () => {}
export function useHotkeysContext() {
return {
enableScope: () => {},
disableScope: () => {},
}
return useMemo(
() => ({
enableScope: noop,
disableScope: noop,
}),
[],
)
}
-2
View File
@@ -54,8 +54,6 @@ export const colors = {
green3: '#20bc07',
green4: '#148203',
green5: '#082b03',
unreadNotifBg: '#ebf6ff',
}
/**
-49
View File
@@ -17,19 +17,6 @@ export const defaultTheme: Theme = {
textInverted: lightPalette.white,
link: lightPalette.primary_500,
border: lightPalette.contrast_100,
borderDark: lightPalette.contrast_200,
icon: lightPalette.contrast_500,
// non-standard
textVeryLight: lightPalette.contrast_400,
replyLine: lightPalette.contrast_100,
replyLineDot: lightPalette.contrast_200,
unreadNotifBg: lightPalette.primary_25,
unreadNotifBorder: lightPalette.primary_100,
postCtrl: lightPalette.contrast_500,
brandText: lightPalette.primary_500,
emptyStateIcon: lightPalette.contrast_300,
borderLinkHover: lightPalette.contrast_300,
},
primary: {
background: colors.blue3,
@@ -39,8 +26,6 @@ export const defaultTheme: Theme = {
textInverted: colors.blue3,
link: colors.blue0,
border: colors.blue4,
borderDark: colors.blue5,
icon: colors.blue4,
},
secondary: {
background: colors.green3,
@@ -50,8 +35,6 @@ export const defaultTheme: Theme = {
textInverted: colors.green4,
link: colors.green1,
border: colors.green4,
borderDark: colors.green5,
icon: colors.green4,
},
inverted: {
background: darkPalette.black,
@@ -61,8 +44,6 @@ export const defaultTheme: Theme = {
textInverted: darkPalette.black,
link: darkPalette.primary_500,
border: darkPalette.contrast_100,
borderDark: darkPalette.contrast_200,
icon: darkPalette.contrast_500,
},
error: {
background: colors.red3,
@@ -72,8 +53,6 @@ export const defaultTheme: Theme = {
textInverted: colors.red3,
link: colors.red1,
border: colors.red4,
borderDark: colors.red5,
icon: colors.red4,
},
},
shapes: {
@@ -303,19 +282,6 @@ export const darkTheme: Theme = {
textInverted: darkPalette.black,
link: darkPalette.primary_500,
border: darkPalette.contrast_100,
borderDark: darkPalette.contrast_200,
icon: darkPalette.contrast_500,
// non-standard
textVeryLight: darkPalette.contrast_400,
replyLine: darkPalette.contrast_200,
replyLineDot: darkPalette.contrast_200,
unreadNotifBg: darkPalette.primary_25,
unreadNotifBorder: darkPalette.primary_100,
postCtrl: darkPalette.contrast_500,
brandText: darkPalette.primary_500,
emptyStateIcon: darkPalette.contrast_300,
borderLinkHover: darkPalette.contrast_300,
},
primary: {
...defaultTheme.palette.primary,
@@ -333,8 +299,6 @@ export const darkTheme: Theme = {
textInverted: darkPalette.white,
link: lightPalette.primary_500,
border: lightPalette.contrast_100,
borderDark: lightPalette.contrast_200,
icon: lightPalette.contrast_500,
},
},
}
@@ -352,19 +316,6 @@ export const dimTheme: Theme = {
textInverted: dimPalette.black,
link: dimPalette.primary_500,
border: dimPalette.contrast_100,
borderDark: dimPalette.contrast_200,
icon: dimPalette.contrast_500,
// non-standard
textVeryLight: dimPalette.contrast_400,
replyLine: dimPalette.contrast_200,
replyLineDot: dimPalette.contrast_200,
unreadNotifBg: dimPalette.primary_25,
unreadNotifBorder: dimPalette.primary_100,
postCtrl: dimPalette.contrast_500,
brandText: dimPalette.primary_500,
emptyStateIcon: dimPalette.contrast_300,
borderLinkHover: dimPalette.contrast_300,
},
},
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -23,7 +23,6 @@ import {
} from '#/lib/routes/types'
import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation'
import {useBookmarksQuery} from '#/state/queries/bookmarks/useBookmarksQuery'
import {useSetMinimalShellMode} from '#/state/shell'
import {Post} from '#/view/com/post/Post'
import {EmptyState} from '#/view/com/util/EmptyState'
import {List} from '#/view/com/util/List'
@@ -43,14 +42,12 @@ import {IS_IOS} from '#/env'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Bookmarks'>
export function BookmarksScreen({}: Props) {
const setMinimalShellMode = useSetMinimalShellMode()
const ax = useAnalytics()
useFocusEffect(
useCallback(() => {
setMinimalShellMode(false)
ax.metric('bookmarks:view', {})
}, [setMinimalShellMode, ax]),
}, [ax]),
)
return (
+3 -8
View File
@@ -1,4 +1,4 @@
import {useCallback, useLayoutEffect, useState} from 'react'
import {useState} from 'react'
import {LayoutAnimationConfig} from 'react-native-reanimated'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
@@ -8,7 +8,7 @@ import {
type AllNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {useSetMinimalShellMode} from '#/state/shell'
import {useEnableMinimalShellMode} from '#/state/shell'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {FindContactsFlow} from '#/components/contacts/FindContactsFlow'
import {useFindContactsFlowState} from '#/components/contacts/state'
@@ -36,12 +36,7 @@ export function FindContactsFlowScreen({navigation}: Props) {
})
})
const setMinimalShellMode = useSetMinimalShellMode()
const effect = useCallback(() => {
setMinimalShellMode(true)
return () => setMinimalShellMode(false)
}, [setMinimalShellMode])
useLayoutEffect(effect)
useEnableMinimalShellMode()
return (
<Layout.Screen>
+3 -16
View File
@@ -4,7 +4,6 @@ import {type AppBskyFeedDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useFocusEffect} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {HITSLOP_10} from '#/lib/constants'
@@ -17,7 +16,6 @@ import {sanitizeHandle} from '#/lib/strings/handles'
import {enforceLen} from '#/lib/strings/helpers'
import {useSearchPostsQuery} from '#/state/queries/search-posts'
import {useSession} from '#/state/session'
import {useSetMinimalShellMode} from '#/state/shell'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {Pager} from '#/view/com/pager/Pager'
@@ -79,21 +77,10 @@ export default function HashtagScreen({
}, [tag, author])
const [activeTab, setActiveTab] = useState(0)
const setMinimalShellMode = useSetMinimalShellMode()
useFocusEffect(
useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
const onPageSelected = useCallback(
(index: number) => {
setMinimalShellMode(false)
setActiveTab(index)
},
[setMinimalShellMode],
)
const onPageSelected = (index: number) => {
setActiveTab(index)
}
const sections = useMemo(() => {
return [
+2 -10
View File
@@ -1,9 +1,8 @@
import {useCallback, useState} from 'react'
import {useState} from 'react'
import {LayoutAnimation, Pressable, View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useFocusEffect} from '@react-navigation/native'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {
@@ -11,7 +10,7 @@ import {
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {getEntries} from '#/logger/logDump'
import {useSetMinimalShellMode, useTickEveryMinute} from '#/state/shell'
import {useTickEveryMinute} from '#/state/shell'
import {atoms as a, useTheme} from '#/alf'
import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon,
@@ -28,17 +27,10 @@ export function LogScreen({}: NativeStackScreenProps<
>) {
const t = useTheme()
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
const [expanded, setExpanded] = useState<string[]>([])
const timeAgo = useGetTimeAgo()
const tick = useTickEveryMinute()
useFocusEffect(
useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
const toggler = (id: string) => () => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
if (expanded.includes(id)) {
+13 -9
View File
@@ -38,6 +38,7 @@ import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
import {IS_NATIVE} from '#/env'
import {ChatListItem} from './components/ChatListItem'
import {InboxPreview} from './components/InboxPreview'
@@ -71,21 +72,24 @@ type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
export function MessagesScreen(props: Props) {
const {_} = useLingui()
const aaCopy = useAgeAssuranceCopy()
const aa = useAgeAssurance()
return (
<AgeRestrictedScreen
screenTitle={_(msg`Chats`)}
infoText={aaCopy.chatsInfoText}
rightHeaderSlot={
<Link
to="/messages/settings"
label={_(msg`Chat settings`)}
size="small"
color="secondary">
<ButtonText>
<Trans>Chat settings</Trans>
</ButtonText>
</Link>
aa.flags.chatDisabled ? null : (
<Link
to="/messages/settings"
label={_(msg`Chat settings`)}
size="small"
color="secondary">
<ButtonText>
<Trans>Chat settings</Trans>
</ButtonText>
</Link>
)
}>
<MessagesScreenInner {...props} />
</AgeRestrictedScreen>
+80 -14
View File
@@ -6,9 +6,7 @@ import {
ScrollEdgeEffect,
ScrollEdgeEffectProvider,
} from '@bsky.app/expo-scroll-edge-effect'
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 {
type RouteProp,
useFocusEffect,
@@ -32,11 +30,11 @@ import {useCurrentConvoId} from '#/state/messages/current-convo-id'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useConvoQuery} from '#/state/queries/messages/conversation'
import {useSession} from '#/state/session'
import {useSetMinimalShellMode} from '#/state/shell'
import {MessagesList} from '#/screens/Messages/components/MessagesList'
import {atoms as a, useTheme, web} from '#/alf'
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import * as Dialog from '#/components/Dialog'
import {
EmailDialogScreenID,
useEmailDialogControl,
@@ -47,6 +45,9 @@ import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
import {Error} from '#/components/Error'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
import {ChatDisabled} from './components/ChatDisabled'
@@ -56,11 +57,11 @@ type Props = NativeStackScreenProps<
>
export function MessagesConversationScreen(props: Props) {
const {_} = useLingui()
const {t: l} = useLingui()
const aaCopy = useAgeAssuranceCopy()
return (
<AgeRestrictedScreen
screenTitle={_(msg`Conversation`)}
screenTitle={l`Conversation`}
infoText={aaCopy.chatsInfoText}>
<MessagesConversationScreenInner {...props} />
</AgeRestrictedScreen>
@@ -68,25 +69,22 @@ export function MessagesConversationScreen(props: Props) {
}
export function MessagesConversationScreenInner({route}: Props) {
const setMinimalShellMode = useSetMinimalShellMode()
const convoId = route.params.conversation
const {setCurrentConvoId} = useCurrentConvoId()
useFocusEffect(
useCallback(() => {
setCurrentConvoId(convoId)
setMinimalShellMode(true)
return () => {
setCurrentConvoId(undefined)
setMinimalShellMode(false)
}
}, [convoId, setCurrentConvoId, setMinimalShellMode]),
}, [convoId, setCurrentConvoId]),
)
return (
<Layout.Screen
minimalShell
testID="convoScreen"
noInsetTop={IS_LIQUID_GLASS}
style={web([{minHeight: 0}, a.flex_1])}>
@@ -102,7 +100,7 @@ export function MessagesConversationScreenInner({route}: Props) {
function Inner({convoId}: {convoId: string}) {
const t = useTheme()
const convoState = useConvo()
const {_} = useLingui()
const {t: l} = useLingui()
const {currentAccount} = useSession()
const isFocused = useIsFocused()
const {top: topInset} = useSafeAreaInsets()
@@ -140,8 +138,8 @@ function Inner({convoId}: {convoId: string}) {
<MessagesListHeader convo={convo} />
</Layout.Center>
<Error
title={_(msg`Something went wrong`)}
message={_(msg`We couldn't load this conversation`)}
title={l`Something went wrong`}
message={l`We couldn't load this conversation`}
onRetry={() => convoState.error.retry()}
sideBorders={false}
/>
@@ -293,6 +291,74 @@ function InnerReady({
}
/>
)}
{convo?.kind === 'group' && <GroupChatGate />}
</>
)
}
function GroupChatGate() {
const {t: l} = useLingui()
const ax = useAnalytics()
const navigation = useNavigation<NavigationProp>()
const groupChatGateDialogControl = Dialog.useDialogControl()
const isGatedGroupChat = !ax.features.enabled(ax.features.GroupChatsEnable)
useEffect(() => {
if (isGatedGroupChat) {
setTimeout(() => groupChatGateDialogControl.open())
}
}, [isGatedGroupChat, groupChatGateDialogControl])
const hasBeenReleased = ax.features.enabled(
ax.features.GroupChatsHasBeenReleased,
)
const onGoBack = () => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.replace('Messages', {animation: 'pop'})
}
}
return (
<Prompt.Outer
control={groupChatGateDialogControl}
nativeOptions={{preventDismiss: true, preventExpansion: true}}
testID="groupChatGateDialog">
<Prompt.Content>
<View style={[a.w_full, a.align_center, a.py_2xl]}>
<Text style={{fontSize: 48}} emoji>
🐴
</Text>
</View>
<Prompt.TitleText>
{hasBeenReleased ? (
<Trans>Group chats are now available</Trans>
) : (
<Trans>Group chats are not yet available</Trans>
)}
</Prompt.TitleText>
<Prompt.DescriptionText>
{hasBeenReleased ? (
<Trans>Update your app to the latest version to join in!</Trans>
) : (
<Trans>
This feature isn't available to you yet. Please check back later.
</Trans>
)}
</Prompt.DescriptionText>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action
cta={l`Go Back`}
onPress={onGoBack}
color="primary_subtle"
/>
</Prompt.Actions>
</Prompt.Outer>
)
}
+42 -17
View File
@@ -24,6 +24,7 @@ import {useEditGroupName} from '#/state/queries/messages/edit-group-name'
import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests'
import {useMuteConvo} from '#/state/queries/messages/mute-conversation'
import {useProfileBlockMutationQueue} from '#/state/queries/profile'
import {useSession} from '#/state/session'
@@ -109,7 +110,7 @@ export function MessagesConversationSettingsScreen({route}: Props) {
<Layout.Header.Slot />
</Layout.Header.Outer>
<ConvoProvider key={convoId} convoId={convoId}>
<SettingsInner />
<SettingsInner convoId={convoId} />
</ConvoProvider>
</Layout.Screen>
)
@@ -119,7 +120,7 @@ function keyExtractor(item: Item) {
return item.type === 'CHAT_MEMBER' ? item.profile.did : item.type
}
function SettingsInner() {
function SettingsInner({convoId}: {convoId: string}) {
const {t: l} = useLingui()
const initialNumToRender = useInitialNumToRender({minItemHeight: 68})
@@ -137,6 +138,17 @@ function SettingsInner() {
const data: bsky.profile.AnyProfileView[] = convo?.members ?? []
const invites: string[] = []
const {data: joinRequestsData, hasNextPage: hasMoreRequests} =
useListJoinRequestsQuery({
convoId,
enabled: isOwner,
})
const requestCount =
joinRequestsData?.pages.reduce(
(sum, page) => sum + page.requests.length,
0,
) ?? 0
const items = [
{
type: 'MEMBERS_AND_REQUESTS',
@@ -172,7 +184,8 @@ function SettingsInner() {
return (
<MembersAndRequests
memberCount={data.length}
requestCount={5}
requestCount={requestCount}
hasMoreRequests={!!hasMoreRequests}
isOwner={isOwner}
/>
)
@@ -229,10 +242,12 @@ function SettingsInner() {
function MembersAndRequests({
memberCount,
requestCount,
hasMoreRequests,
isOwner,
}: {
memberCount: number
requestCount: number
hasMoreRequests: boolean
isOwner: boolean
}) {
const t = useTheme()
@@ -245,21 +260,32 @@ function MembersAndRequests({
<Trans>Members</Trans>{' '}
</Text>
<Text
style={[
a.text_xs,
a.font_medium,
{color: t.palette.contrast_500},
]}>{l`${memberCount}/${MEMBER_LIMIT}`}</Text>
style={[a.text_xs, a.font_medium, {color: t.palette.contrast_500}]}>
{l({
message: `${memberCount}/${MEMBER_LIMIT}`,
comment:
'The number of group chat members out of the total number of permitted users.',
})}
</Text>
</View>
{isOwner && requestCount > 0 ? (
<InlineLinkText
label={l`View incoming group chat requests`}
style={[a.text_sm, a.text_right, a.font_semi_bold]}
to="#">
{l`${plural(requestCount, {
one: '# request',
other: '# requests',
})}`}
{hasMoreRequests
? l({
message: `${requestCount}+ requests`,
comment:
'Displayed when there are more than 50 requests to join a group chat',
})
: l({
message: `${plural(requestCount, {
one: '# request',
other: '# requests',
})}`,
comment: 'The number of requests to join a group chat.',
})}
</InlineLinkText>
) : null}
</View>
@@ -655,7 +681,7 @@ function MemberMenu({
label={l`Message ${displayName}`}
onPress={handleMessageMember}>
<Menu.ItemText>
<Trans>Message</Trans>
<Trans context="action">Message</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={MessageIcon} />
</Menu.Item>
@@ -919,7 +945,6 @@ function SettingsHeader({
function SettingsHeaderPlaceholder() {
const t = useTheme()
const {t: l} = useLingui()
return (
<View style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}>
@@ -928,7 +953,7 @@ function SettingsHeaderPlaceholder() {
</View>
<Text
style={[a.text_2xl, a.font_bold, a.text_center, a.pt_lg, t.atoms.text]}>
{l`…`}
…
</Text>
<Text
style={[
@@ -1015,7 +1040,7 @@ function SettingsButtonPlaceholder() {
a.pt_xs,
t.atoms.text,
]}>
{l`…`}
…
</Text>
</View>
)
@@ -1084,7 +1109,7 @@ function InviteLinkPrompt({
<Prompt.Basic
control={control}
title={l`Invite link`}
description={l`An invite link lets people join this group chat without being added directly. You control who can use the link and whether they need your approval. You can disable the link at any time. Your name, avatar, and the name of the group chat will be visible to everyone`}
description={l`An invite link lets people join this group chat without being added directly. You control who can use the link and whether they need your approval. You can disable the link at any time. Your name, avatar, and the name of the group chat will be visible to everyone.`}
confirmButtonCta={l`Get started`}
cancelButtonCta={l`Cancel`}
onConfirm={onConfirm}
+12 -1
View File
@@ -11,6 +11,8 @@ import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
import * as Layout from '#/components/Layout'
@@ -24,7 +26,16 @@ type AllowIncoming = 'all' | 'none' | 'following'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesSettings'>
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) {
+48 -114
View File
@@ -1,7 +1,6 @@
import {useCallback, useMemo, useState} from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {
AppBskyEmbedRecord,
ChatBskyConvoDefs,
moderateProfile,
type ModerationDecision,
@@ -14,13 +13,7 @@ import {GestureActionView} from '#/lib/custom-animations/GestureActionView'
import {useHaptics} from '#/lib/haptics'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {decrementBadgeCount} from '#/lib/notifications/notifications'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {
postUriToRelativePath,
toBskyAppUrl,
toShortUrl,
} from '#/lib/strings/url-helpers'
import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
@@ -36,6 +29,9 @@ import * as tokens from '#/alf/tokens'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {useDialogControl} from '#/components/Dialog'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {getMessageInfo} from '#/components/dms/getMessageInfo'
import {getReactionInfo} from '#/components/dms/getReactionInfo'
import {getSystemMessageInfo} from '#/components/dms/getSystemMessageInfo'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
@@ -53,12 +49,6 @@ import type * as bsky from '#/types/bsky'
export const ChatListItemPortal = createPortalGroup()
/**
* IMPORTANT NOTE: THIS IS CURRENTLY JANKY AF AND PROBABLY BROKEN, JUST WANTED TO ADD GROUPCHAT SUPPPORT
*
* TAKE A SECOND PASS PLEASE -sfn
*/
export function ChatListItem({
convo: convoView,
showMenu = true,
@@ -93,8 +83,9 @@ export function ChatListItem({
<GroupChatItem
convo={convo}
moderationOpts={moderationOpts}
showMenu={showMenu}
/>
showMenu={showMenu}>
{children}
</GroupChatItem>
)
}
default: {
@@ -188,7 +179,7 @@ function GroupChatItem({
return (
<BaseChatItem
convo={convo.view}
avatar={<AvatarBubbles profiles={convo.members} size="medium" />}
avatar={<AvatarBubbles profiles={convo.members} size={52} />}
title={chatName}
accessibilityHint={l`Go to the group chat named "${chatName}"`}
primaryProfile={groupOwner}
@@ -233,7 +224,7 @@ function BaseChatItem({
}) {
const ax = useAnalytics()
const t = useTheme()
const {t: l} = useLingui()
const {t: l, i18n} = useLingui()
const {currentAccount} = useSession()
const menuControl = useMenuControl()
const leaveConvoControl = useDialogControl()
@@ -242,7 +233,7 @@ function BaseChatItem({
const playHaptic = useHaptics()
const queryClient = useQueryClient()
const isUnread = convo.unreadCount > 0
const hasUnread = convo.unreadCount > 0 && !isDeletedAccount
const blockInfo = useMemo(() => {
const modui = primaryProfileModeration.ui('profileView')
@@ -265,51 +256,7 @@ function BaseChatItem({
let latestReportableMessage: ChatBskyConvoDefs.MessageView | undefined
if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
const isFromMe = convo.lastMessage.sender?.did === currentAccount?.did
if (!isFromMe) {
latestReportableMessage = convo.lastMessage
}
if (convo.lastMessage.text) {
if (isFromMe) {
lastMessage = l`You: ${convo.lastMessage.text}`
} else {
lastMessage = convo.lastMessage.text
}
} else if (convo.lastMessage.embed) {
const defaultEmbeddedContentMessage = l`(contains embedded content)`
if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) {
const embed = convo.lastMessage.embed
if (AppBskyEmbedRecord.isViewRecord(embed.record)) {
const record = embed.record
const path = postUriToRelativePath(record.uri, {
handle: record.author.handle,
})
const href = path ? toBskyAppUrl(path) : undefined
const short = href
? toShortUrl(href)
: defaultEmbeddedContentMessage
if (isFromMe) {
lastMessage = l`You: ${short}`
} else {
lastMessage = short
}
}
} else {
if (isFromMe) {
lastMessage = l`You: ${defaultEmbeddedContentMessage}`
} else {
lastMessage = defaultEmbeddedContentMessage
}
}
}
lastMessageSentAt = convo.lastMessage.sentAt
}
// Deleted message
if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) {
lastMessageSentAt = convo.lastMessage.sentAt
@@ -318,47 +265,43 @@ function BaseChatItem({
: l`Message deleted`
}
if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) {
if (
!lastMessageSentAt ||
new Date(lastMessageSentAt) <
new Date(convo.lastReaction.reaction.createdAt)
) {
const isFromMe =
convo.lastReaction.reaction.sender.did === currentAccount?.did
const lastMessageText = convo.lastReaction.message.text
const fallbackMessage = l({
message: 'a message',
comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`,
})
// Message
if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
const info = getMessageInfo({
convo,
currentAccountDid: currentAccount?.did,
i18n,
})
if (info) {
lastMessage = info.message ?? lastMessage
lastMessageSentAt = info.sentAt
latestReportableMessage = info.reportableMessage
}
}
if (isFromMe) {
lastMessage = l`You reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`
} else {
const senderDid = convo.lastReaction.reaction.sender.did
const sender = convo.members.find(
member => member.did === senderDid,
)
if (sender) {
lastMessage = l`${sanitizeDisplayName(
sender.displayName || sender.handle,
)} reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`
} else {
lastMessage = l`Someone reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`
}
}
// Reaction
if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) {
const info = getReactionInfo({
convo,
currentAccountDid: currentAccount?.did,
i18n,
})
if (
info &&
(!lastMessageSentAt ||
new Date(lastMessageSentAt) < new Date(info.createdAt))
) {
lastMessage = info.message
lastMessageSentAt = info.createdAt
}
}
// System message
if (ChatBskyConvoDefs.isSystemMessageView(convo.lastMessage)) {
const info = getSystemMessageInfo(convo.lastMessage.data, convo.members)
if (info) {
lastMessage = i18n._(info.message)
lastMessageSentAt = convo.lastMessage.sentAt
}
}
@@ -367,14 +310,7 @@ function BaseChatItem({
lastMessageSentAt,
latestReportableMessage,
}
}, [
l,
convo.lastMessage,
convo.lastReaction,
currentAccount?.did,
isDeletedAccount,
convo.members,
])
}, [l, convo, currentAccount?.did, isDeletedAccount, i18n])
const [showActions, setShowActions] = useState(false)
@@ -434,7 +370,7 @@ function BaseChatItem({
},
}
const actions = isUnread
const actions = hasUnread
? {
leftFirst: markReadAction,
leftSecond: deleteAction,
@@ -443,8 +379,6 @@ function BaseChatItem({
leftFirst: deleteAction,
}
const hasUnread = convo.unreadCount > 0 && !isDeletedAccount
return (
<ChatListItemPortal.Provider>
<GestureActionView actions={actions}>
@@ -1,4 +1,4 @@
import {useState} from 'react'
import {useRef, useState} from 'react'
import {Pressable, View} from 'react-native'
import {
useKeyboardHandler,
@@ -19,6 +19,7 @@ import {countGraphemes} from 'unicode-segmenter/grapheme'
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {isBskyPostUrl} from '#/lib/strings/url-helpers'
import {useEmail} from '#/state/email-verification'
import {
@@ -32,7 +33,7 @@ import {GlassView} from '#/components/GlassView'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
import * as Toast from '#/components/Toast'
import {IS_ANDROID, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
const MIN_HEIGHT = 40
@@ -61,9 +62,9 @@ export function MessageComposer({
useSaveMessageDraft(text)
// Android interactive dismiss sometimes doesn't blur the input
const blur = () => {
const blur = useNonReactiveCallback(() => {
composerInternalApiRef.current?.input?.blur()
}
})
useKeyboardHandler({
onEnd: evt => {
@@ -76,10 +77,10 @@ export function MessageComposer({
const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0)
const onSubmit = () => {
const onSubmit = (message: string) => {
if (!editable) return
if (!hasEmbed && text.trim() === '') return
const graphemeCount = countGraphemes(text)
if (!hasEmbed && message.trim() === '') return
const graphemeCount = countGraphemes(message)
if (graphemeCount > MAX_DM_GRAPHEME_LENGTH) {
Toast.show(
l`Message is too long (${graphemeCount}/${MAX_DM_GRAPHEME_LENGTH})`,
@@ -89,7 +90,7 @@ export function MessageComposer({
}
clearDraft()
onSendMessage(text)
onSendMessage(message)
playHaptic()
setEmbed(undefined)
composerInternalApiRef.current?.clear()
@@ -99,6 +100,45 @@ export function MessageComposer({
}
}
const isFlushingAutocorrectSuggestion = useRef(false)
const handleSubmit = () => {
if (IS_IOS) {
// HACKFIX: If there's a pending autocomplete suggestion, iOS will prioritize
// accepting the suggestion over any imperative `.clear()` action on the textinput.
// This means we'll send the message with the typo while the corrected text remains
// in the composer's textinput.
//
// In MessageInput, the previous iteration, we simply sent it, and if another text change
// event came in, we'd clear it again. However, it's nicer UX to actually accept the suggestion.
//
// Thus the solution:
// 1. Set a ref indicating we're flushing the autocorrect suggestion
// 2. Watch for incoming onChange events. If something comes in, it's almost certainly the corrected text,
// so send that
// 3. Meanwhile, race that against a simple timeout. If the timeout fires first, send the original text.
//
// Hopefully, it's delaying the send by no more than a couple frames -sfn
isFlushingAutocorrectSuggestion.current = true
setTimeout(() => {
if (isFlushingAutocorrectSuggestion.current) {
isFlushingAutocorrectSuggestion.current = false
onSubmit(text)
}
}, 20)
} else {
onSubmit(text)
}
}
const handleChange = (nextText: string) => {
if (IS_IOS && isFlushingAutocorrectSuggestion.current) {
isFlushingAutocorrectSuggestion.current = false
onSubmit(nextText)
} else {
setText(nextText)
}
}
return (
<ComposerContainer>
{children}
@@ -180,7 +220,7 @@ export function MessageComposer({
paddingBottom: 10,
paddingRight: 16 + platform({web: 20, default: 0}),
}}
onChange={setText}
onChange={handleChange}
onFacetCommitted={facet => {
if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
setEmbed(facet.value)
@@ -189,11 +229,11 @@ export function MessageComposer({
onRequestSubmit={req => {
if (req.platform === 'web' && req.shiftKey) return
req.nativeEvent.preventDefault()
onSubmit()
handleSubmit()
}}
/>
</GlassView>
<SubmitButton onPress={onSubmit} disabled={submitDisabled} />
<SubmitButton onPress={handleSubmit} disabled={submitDisabled} />
</GlassContainer>
</View>
</ComposerContainer>
@@ -43,6 +43,7 @@ export function MessageListError({item}: {item: ConvoItem & {type: 'error'}}) {
{description}
{item.retry && (
<>
{' '}
&middot;{' '}
<InlineLinkText
label={help}
@@ -58,6 +58,7 @@ import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
import {DateDividerToggleProvider} from '#/components/dms/DateDividerToggle'
import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
@@ -383,6 +384,8 @@ export function MessagesList({
)
} else if (item.type === 'deleted-message') {
return <Text>Deleted message</Text>
} else if (item.type === 'system-message') {
return <SystemMessageItem item={item} />
} else if (item.type === 'error') {
return <MessageListError item={item} />
}
-9
View File
@@ -4,7 +4,6 @@ import {LABELS} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useFocusEffect} from '@react-navigation/native'
import {getLabelingServiceTitle, isAppLabeler} from '#/lib/moderation'
import {
@@ -21,7 +20,6 @@ import {
usePreferencesSetAdultContentMutation,
} from '#/state/queries/preferences'
import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities'
import {useSetMinimalShellMode} from '#/state/shell'
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
import * as Admonition from '#/components/Admonition'
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
@@ -161,7 +159,6 @@ export function ModerationScreenInner({
}) {
const {_} = useLingui()
const t = useTheme()
const setMinimalShellMode = useSetMinimalShellMode()
const {gtMobile} = useBreakpoints()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
const {
@@ -197,12 +194,6 @@ export function ModerationScreenInner({
}
}
useFocusEffect(
useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
const {mutateAsync: setAdultContentPref, variables: optimisticAdultContent} =
usePreferencesSetAdultContentMutation()
let adultContentEnabled = !!(
@@ -105,6 +105,7 @@ export function StepSuggestedAccounts() {
ax.metric('suggestedUser:follow', {
logContext: 'Onboarding',
location: 'FollowAll',
recSource: !useFullExperience ? 'Search' : undefined,
recId: suggestedUsers?.recId,
position: i,
suggestedDid: did,
@@ -155,6 +156,7 @@ export function StepSuggestedAccounts() {
seenProfilesRef.current.add(did)
ax.metric('suggestedUser:seen', {
logContext: 'Onboarding',
recSource: !useFullExperience ? 'Search' : undefined,
recId: suggestedUsers?.recId,
position,
suggestedDid: did,
@@ -162,7 +164,7 @@ export function StepSuggestedAccounts() {
})
}
},
[ax, selectedInterest, suggestedUsers?.recId],
[ax, selectedInterest, suggestedUsers?.recId, useFullExperience],
)
useEffect(() => {
@@ -248,6 +250,7 @@ export function StepSuggestedAccounts() {
position={index}
category={selectedInterest}
onSeen={onProfileSeen}
recSource={!useFullExperience ? 'Search' : undefined}
recId={suggestedUsers.recId}
/>
))}
@@ -358,6 +361,7 @@ function SuggestedProfileCard({
position,
category,
onSeen,
recSource,
recId,
}: {
profile: bsky.profile.AnyProfileView
@@ -365,6 +369,7 @@ function SuggestedProfileCard({
position: number
category: string | null
onSeen: (did: string, position: number) => void
recSource?: 'Search'
recId?: number | string
}) {
const t = useTheme()
@@ -432,6 +437,7 @@ function SuggestedProfileCard({
ax.metric('suggestedUser:follow', {
logContext: 'Onboarding',
location: 'Card',
recSource,
recId,
position,
suggestedDid: profile.did,
-10
View File
@@ -1,6 +1,4 @@
import {useCallback} from 'react'
import {Plural, Trans} from '@lingui/react/macro'
import {useFocusEffect} from '@react-navigation/native'
import {
type CommonNavigatorParams,
@@ -8,13 +6,11 @@ import {
} from '#/lib/routes/types'
import {makeRecordUri} from '#/lib/strings/url-helpers'
import {usePostQuery} from '#/state/queries/post'
import {useSetMinimalShellMode} from '#/state/shell'
import {PostLikedBy as PostLikedByComponent} from '#/view/com/post-thread/PostLikedBy'
import * as Layout from '#/components/Layout'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'>
export const PostLikedByScreen = ({route}: Props) => {
const setMinimalShellMode = useSetMinimalShellMode()
const {name, rkey} = route.params
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
const {data: post} = usePostQuery(uri)
@@ -24,12 +20,6 @@ export const PostLikedByScreen = ({route}: Props) => {
likeCount = post.likeCount
}
useFocusEffect(
useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
return (
<Layout.Screen>
<Layout.Header.Outer>
-10
View File
@@ -1,6 +1,4 @@
import {useCallback} from 'react'
import {Plural, Trans} from '@lingui/react/macro'
import {useFocusEffect} from '@react-navigation/native'
import {
type CommonNavigatorParams,
@@ -8,13 +6,11 @@ import {
} from '#/lib/routes/types'
import {makeRecordUri} from '#/lib/strings/url-helpers'
import {usePostQuery} from '#/state/queries/post'
import {useSetMinimalShellMode} from '#/state/shell'
import {PostQuotes as PostQuotesComponent} from '#/view/com/post-thread/PostQuotes'
import * as Layout from '#/components/Layout'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostQuotes'>
export const PostQuotesScreen = ({route}: Props) => {
const setMinimalShellMode = useSetMinimalShellMode()
const {name, rkey} = route.params
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
const {data: post} = usePostQuery(uri)
@@ -24,12 +20,6 @@ export const PostQuotesScreen = ({route}: Props) => {
quoteCount = post.quoteCount
}
useFocusEffect(
useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
return (
<Layout.Screen>
<Layout.Header.Outer>

Some files were not shown because too many files have changed in this diff Show More