Merge remote-tracking branch 'origin/main' into app-d9-oom-fixes

This commit is contained in:
vineyardbovines
2026-07-01 10:34:19 -04:00
16 changed files with 409 additions and 431 deletions
+5 -2
View File
@@ -134,6 +134,7 @@ jobs:
- name: 📷 Check fingerprint and install dependencies
id: fingerprint
timeout-minutes: 5
uses: bluesky-social/github-actions/fingerprint-native@b5556913e4aef3964cfd5936d0add3fc0d809bdb # v0.1.0
with:
profile: pull-request
@@ -168,11 +169,13 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --add-label "bot: fingerprint changed" || true
run: |
gh pr edit "$PR_NUMBER" --add-label "bot: fingerprint changed" || true
- name: 🏷️ Remove fingerprint changed label
if: ${{ !steps.fingerprint.outputs.includes-changes }}
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --remove-label "bot: fingerprint changed" || true
run: |
gh pr edit "$PR_NUMBER" --remove-label "bot: fingerprint changed" || true
+2 -1
View File
@@ -10,7 +10,7 @@ Bluesky Social is a cross-platform social media application built with React Nat
- React 19.1
- React Native 0.81 with Expo 54
- TypeScript 6
- TypeScript 7
- React Navigation 7 for routing
- TanStack Query (React Query) for data fetching
- Lingui 5 for internationalization
@@ -32,6 +32,7 @@ pnpm ios # Run on iOS
pnpm test # Run Jest tests
pnpm lint # Run ESLint
pnpm typecheck # Run TypeScript type checking
pnpm prettier # Run Prettier for code formatting
# Internationalization
# DO NOT run these commands - extraction and compilation are handled by CI
-9
View File
@@ -1940,15 +1940,6 @@
}
},
"src/view/com/composer/Composer.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-misused-promises": {
"count": 4
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 2
},
"react-hooks/immutability": {
"count": 2
},
@@ -1,10 +1,6 @@
import {useMemo, useState} from 'react'
import {View} from 'react-native'
import {
type AppBskyActorDefs,
AppBskyActorStatus,
type AppBskyEmbedExternal,
} from '@atproto/api'
import {type AppBskyActorDefs, type AppBskyEmbedExternal} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
@@ -24,6 +20,7 @@ import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {
displayDuration,
getValidLiveStatusRecord,
useLiveLinkMetaQuery,
useRemoveLiveStatusMutation,
useUpsertLiveStatusMutation,
@@ -74,14 +71,10 @@ function DialogInner({
error: linkMetaError,
} = useLiveLinkMetaQuery(debouncedUrl)
const record = useMemo(() => {
if (!AppBskyActorStatus.isRecord(status.record)) return null
const validation = AppBskyActorStatus.validateRecord(status.record)
if (validation.success) {
return validation.value
}
return null
}, [status])
const record = useMemo(
() => getValidLiveStatusRecord(status.record),
[status],
)
const {
mutate: goLive,
@@ -20,7 +20,9 @@ import * as Select from '#/components/Select'
import {Text} from '#/components/Typography'
import {
displayDuration,
getLiveLinkFromStatusRecord,
getLiveServiceNames,
useActorStatus,
useLiveLinkMetaQuery,
useLiveNowConfig,
useUpsertLiveStatusMutation,
@@ -50,16 +52,20 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
const control = Dialog.useDialogContext()
const {_, i18n} = useLingui()
const t = useTheme()
const [liveLink, setLiveLink] = useState('')
const [liveLinkError, setLiveLinkError] = useState('')
const [duration, setDuration] = useState(60)
const moderationOpts = useModerationOpts()
const tick = useTickEveryMinute()
const liveNowConfig = useLiveNowConfig()
const status = useActorStatus(profile)
const {formatted: allowedServices} = getLiveServiceNames(
liveNowConfig.currentAccountAllowedHosts,
)
const [liveLink, setLiveLink] = useState(() =>
getLiveLinkFromStatusRecord(status.record),
)
const time = useCallback(
(offset: number) => {
void tick
+25
View File
@@ -1,7 +1,32 @@
import {AppBskyActorStatus, AppBskyEmbedExternal} from '@atproto/api'
import {type I18n} from '@lingui/core'
import {plural} from '@lingui/core/macro'
import psl from 'psl'
/**
* Validates a raw status record and returns the typed record, or null if the
* value is not a valid `app.bsky.actor.status` record.
*/
export function getValidLiveStatusRecord(
statusRecord: unknown,
): AppBskyActorStatus.Record | null {
if (!AppBskyActorStatus.isRecord(statusRecord)) return null
const validation = AppBskyActorStatus.validateRecord(statusRecord)
if (!validation.success) return null
return validation.value
}
/**
* Extracts the external link URI from a status record, if present. Returns an
* empty string when the record is invalid or has no external embed.
*/
export function getLiveLinkFromStatusRecord(statusRecord: unknown): string {
const record = getValidLiveStatusRecord(statusRecord)
if (!record) return ''
if (!AppBskyEmbedExternal.isMain(record.embed)) return ''
return record.embed.external.uri
}
export function displayDuration(i18n: I18n, durationInMinutes: number) {
const roundedDurationInMinutes = Math.round(durationInMinutes)
const hours = Math.floor(roundedDurationInMinutes / 60)
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -121,7 +121,9 @@ export default function HashtagScreen({
<Layout.Header.TitleText>{headerTitle}</Layout.Header.TitleText>
{author && (
<Layout.Header.SubtitleText>
{_(msg`From @${sanitizedAuthor}`)}
{author.startsWith('did:')
? _(msg`From ${sanitizedAuthor}`)
: _(msg`From @${sanitizedAuthor}`)}
</Layout.Header.SubtitleText>
)}
</Layout.Header.Content>
@@ -172,11 +174,9 @@ function HashtagScreenTab({
const isCashtag = fullTag.startsWith('$')
const queryParam = useMemo(() => {
// Cashtags need # prefix for search: "#$BTC" or "#$BTC from:author"
const searchTag = isCashtag ? `#${fullTag}` : fullTag
if (!author) return searchTag
return `${searchTag} from:${author}`
}, [fullTag, author, isCashtag])
// Cashtags need # prefix for search: "#$BTC"
return isCashtag ? `#${fullTag}` : fullTag
}, [fullTag, isCashtag])
const {
data,
@@ -188,7 +188,7 @@ function HashtagScreenTab({
refetch,
fetchNextPage,
hasNextPage,
} = useSearchPostsQuery({query: queryParam, sort, enabled: active})
} = useSearchPostsQuery({query: queryParam, sort, enabled: active, author})
const posts = useMemo(() => {
return data?.pages.flatMap(page => page.posts) || []
+1 -5
View File
@@ -78,11 +78,7 @@ export function LogScreen({}: NativeStackScreenProps<
) : (
<CircleInfoIcon size="sm" />
)}
<View
style={[
a.flex_1,
a.gap_sm,
]}>
<View style={[a.flex_1, a.gap_sm]}>
{entry.context && (
<Text style={[t.atoms.text_contrast_medium]}>
({String(entry.context)})
+7 -2
View File
@@ -4,7 +4,8 @@ import {type AppBskyActorDefs, type ModerationDecision} from '@atproto/api'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {type Shadow} from '#/state/cache/types'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
export function ProfileHeaderDisplayName({
@@ -18,7 +19,7 @@ export function ProfileHeaderDisplayName({
const {gtMobile} = useBreakpoints()
return (
<View pointerEvents="none">
<View>
<Text
emoji
testID="profileHeaderDisplayName"
@@ -27,11 +28,15 @@ export function ProfileHeaderDisplayName({
gtMobile ? a.text_4xl : a.text_3xl,
a.self_start,
a.font_bold,
a.leading_tight,
]}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)}
<View style={[a.pl_xs, {marginTop: platform({ios: 2})}]}>
<ProfileBadges profile={profile} size="lg" interactive />
</View>
</Text>
</View>
)
@@ -13,7 +13,6 @@ import {Trans} from '@lingui/react/macro'
import {useHaptics} from '#/lib/haptics'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
import {
@@ -22,7 +21,7 @@ import {
} from '#/state/queries/profile'
import {useRequireAuth, useSession} from '#/state/session'
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, platform} from '#/alf'
import {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
@@ -34,16 +33,15 @@ import {
KnownFollowers,
shouldShowKnownFollowers,
} from '#/components/KnownFollowers'
import {ProfileBadges} from '#/components/ProfileBadges'
import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_IOS, IS_NATIVE} from '#/env'
import {InviteFriendsDialog} from '#/features/inviteFriends'
import {useActorStatus} from '#/features/liveNow'
import {GermButton} from '../components/GermButton'
import {ProfileHeaderDisplayName} from './DisplayName'
import {EditProfileDialog} from './EditProfileDialog'
import {ProfileHeaderHandle} from './Handle'
import {ProfileHeaderMetrics} from './Metrics'
@@ -65,8 +63,6 @@ let ProfileHeaderStandard = ({
hideBackButton = false,
isPlaceholderProfile,
}: Props): React.ReactNode => {
const t = useTheme()
const {gtMobile} = useBreakpoints()
const profile =
useProfileShadow<AppBskyActorDefs.ProfileViewDetailed>(profileUnshadowed)
const {currentAccount} = useSession()
@@ -138,26 +134,10 @@ let ProfileHeaderStandard = ({
</View>
<View
style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}>
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
<Text
emoji
testID="profileHeaderDisplayName"
style={[
t.atoms.text,
gtMobile ? a.text_4xl : a.text_3xl,
a.self_start,
a.font_bold,
a.leading_tight,
]}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)}
<View style={[a.pl_xs, {marginTop: platform({ios: 2})}]}>
<ProfileBadges profile={profile} size="lg" interactive />
</View>
</Text>
</View>
<ProfileHeaderDisplayName
profile={profile}
moderation={moderation}
/>
<ProfileHeaderHandle profile={profile} />
</View>
{!isPlaceholderProfile && !isBlockedUser && (
@@ -154,6 +154,24 @@ function DialogInner({
const scrollRef = useRef<ScrollView>(null)
const filtersSectionRef = useRef<View>(null)
const suggestions = [
{
all: l({
message: 'cats dogs',
comment: 'Advanced search: Example of an “all of these words” search',
}),
none: l({
message: 'cows pigs',
comment: 'Advanced search: Example of a “none of these words” search',
}),
},
]
// eslint-disable-next-line react/hook-use-state
const [suggestion] = useState(() =>
Math.floor(Math.random() * suggestions.length),
)
function addFilter() {
if (filters.length >= MAX_FILTERS) return
/*
@@ -261,11 +279,7 @@ function DialogInner({
<ClearableInput
label={l`Search query`}
defaultValue={query}
placeholder={l({
message: 'bluesky atproto',
comment:
'Advanced search: Example of an “all of these words” search',
})}
placeholder={suggestions[suggestion].all}
onChangeText={setQuery}
onSubmitEditing={handlePressSearch}
/>
@@ -295,11 +309,7 @@ function DialogInner({
<ClearableInput
label={l`None of these words`}
defaultValue={negatedWords}
placeholder={l({
message: 'cows pigs',
comment:
'Advanced search: Example of an “none of these words” search',
})}
placeholder={suggestions[suggestion].none}
onChangeText={setNegatedWords}
onSubmitEditing={handlePressSearch}
/>
+1 -1
View File
@@ -454,7 +454,7 @@ export function usePinnedFeedsInfos() {
pinnedItems.map(f => f.value),
),
gcTime: GCTIME.INFINITY,
staleTime: STALE.INFINITY,
staleTime: STALE.MINUTES.FIFTEEN,
enabled: !isLoadingPrefs,
queryFn: async () => {
if (!hasSession) {
+1
View File
@@ -11,6 +11,7 @@ export const STALE = {
ONE: MINUTE,
THREE: 3 * MINUTE,
FIVE: 5 * MINUTE,
FIFTEEN: 15 * MINUTE,
THIRTY: 30 * MINUTE,
},
HOURS: {
+13 -6
View File
@@ -22,29 +22,35 @@ import {
} from './util'
const searchPostsQueryKeyRoot = 'search-posts'
const searchPostsQueryKey = ({query, sort}: {query: string; sort?: string}) => [
searchPostsQueryKeyRoot,
const searchPostsQueryKey = ({
query,
sort,
]
author,
}: {
query: string
sort?: string
author?: string
}) => [searchPostsQueryKeyRoot, query, sort, author]
export function useSearchPostsQuery({
query,
sort,
enabled,
author,
}: {
query: string
sort?: 'top' | 'latest'
enabled?: boolean
author?: string
}) {
const agent = useAgent()
const moderationOpts = useModerationOpts()
const selectArgs = useMemo(
() => ({
isSearchingSpecificUser: /from:(\w+)/.test(query),
isSearchingSpecificUser: !!author || /from:(\w+)/.test(query),
moderationOpts,
}),
[query, moderationOpts],
[query, author, moderationOpts],
)
const lastRun = useRef<{
data: InfiniteData<AppBskyFeedSearchPosts.OutputSchema>
@@ -59,13 +65,14 @@ export function useSearchPostsQuery({
QueryKey,
string | undefined
>({
queryKey: searchPostsQueryKey({query, sort}),
queryKey: searchPostsQueryKey({query, sort, author}),
queryFn: async ({pageParam}) => {
const res = await agent.app.bsky.feed.searchPosts({
q: query,
limit: 25,
cursor: pageParam,
sort,
author,
})
return res.data
},
+34 -37
View File
@@ -13,7 +13,6 @@ import {
ActivityIndicator,
BackHandler,
Keyboard,
KeyboardAvoidingView,
type LayoutChangeEvent,
ScrollView,
type StyleProp,
@@ -21,6 +20,7 @@ import {
View,
type ViewStyle,
} from 'react-native'
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
// @ts-expect-error no type definition
import ProgressCircle from 'react-native-progress/Circle'
import Animated, {
@@ -72,7 +72,6 @@ import {
type SupportedMimeTypes,
VIDEO_MAX_DURATION_MS,
} from '#/lib/constants'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {createVideoTelemetry} from '#/lib/media/video/telemetry'
import {mimeToExt} from '#/lib/media/video/util'
@@ -287,7 +286,6 @@ export const ComposePost = ({
const {data: preferences} = usePreferencesQuery()
const navigation = useNavigation<NavigationProp>()
const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
const [isPublishing, setIsPublishing] = useState(false)
const [publishingStage, setPublishingStage] = useState('')
const [error, setError] = useState('')
@@ -856,17 +854,9 @@ export const ComposePost = ({
const viewStyles = useMemo(
() => ({
paddingTop: IS_ANDROID ? insets.top : 0,
paddingBottom:
// iOS - when keyboard is closed, keep the bottom bar in the safe area
(IS_IOS && !isKeyboardVisible) ||
// Android - Android >=35 KeyboardAvoidingView adds double padding when
// keyboard is closed, so we subtract that in the offset and add it back
// here when the keyboard is open
(IS_ANDROID && isKeyboardVisible)
? insets.bottom
: 0,
paddingBottom: insets.bottom,
}),
[insets, isKeyboardVisible],
[insets.top, insets.bottom],
)
const onPressCancel = useCallback(() => {
@@ -1100,13 +1090,13 @@ export const ComposePost = ({
posts,
}
}
} catch (waitErr: any) {
} catch (waitErr) {
logger.info(`composer: waiting for app view failed`, {
safeMessage: waitErr,
})
}
} catch (e: any) {
logger.error(e, {
} catch (e) {
logger.error(e instanceof Error ? e : String(e), {
message: `Composer: create post failed`,
hasImages: filteredThread.posts.some(
p =>
@@ -1115,7 +1105,7 @@ export const ComposePost = ({
),
})
let err = cleanError(e.message)
let err = e instanceof Error ? cleanError(e.message) : String(e)
if (
e instanceof apilib.ReplyDeletedError ||
err.includes('not locate record')
@@ -1417,8 +1407,8 @@ export const ComposePost = ({
publishingStage={publishingStage}
topBarAnimatedStyle={topBarAnimatedStyle}
onCancel={onPressCancel}
onPublish={onPressPublish}
onSelectDraft={handleSelectDraft}
onPublish={() => void onPressPublish()}
onSelectDraft={draft => void handleSelectDraft(draft)}
onSaveDraft={saveCurrentDraft}
onDiscard={handleClearComposer}
isEmpty={isComposerEmpty}
@@ -1531,7 +1521,7 @@ export const ComposePost = ({
{allPostsWithinLimit && (
<Prompt.Action
cta={composerState.draftId ? l`Save changes` : l`Save draft`}
onPress={handleSaveDraft}
onPress={() => void handleSaveDraft()}
color="primary"
/>
)}
@@ -1694,7 +1684,7 @@ let ComposerPost = memo(function ComposerPost({
postId: post.id,
})
}}
onPhotoPasted={onPhotoPasted}
onPhotoPasted={uri => void onPhotoPasted(uri)}
onNewLink={onNewLink}
onError={onError}
onPressPublish={onPublish}
@@ -2188,7 +2178,7 @@ function ComposerFooter({
}),
).catch(e => {
logger.error(`createComposerImage failed`, {
safeMessage: e.message,
safeMessage: e instanceof Error ? e.message : String(e),
})
})
@@ -2425,24 +2415,31 @@ function useScrollTracker({
}
function useKeyboardVerticalOffset() {
const {top, bottom} = useSafeAreaInsets()
const insets = useSafeAreaInsets()
// Android etc
if (!IS_IOS) {
// need to account for the edge-to-edge nav bar
return bottom * -1
// the keyboardavoidingview has bottom padding to avoid being obscured by the safe area when keyboard is closed.
// however, this leads to a gap when the keyboard is open. we account for that by subtracting the bottom inset when open.
let keyboardVerticalOffset = insets.bottom * -1
// iOS requires a bit of extra offset to account for the native sheet not being at the top of the screen
if (IS_IOS) {
// they ditched the gap behaviour on 26
if (IS_LIQUID_GLASS) {
keyboardVerticalOffset += insets.top
}
// iPhone SE
else if (insets.top === 20) {
keyboardVerticalOffset += 40
}
// all other iPhones on <26
else {
keyboardVerticalOffset += insets.top + 10
}
}
// they ditched the gap behaviour on 26
if (IS_LIQUID_GLASS) {
return top
}
// iPhone SE
if (top === 20) return 40
// all other iPhones on <26
return top + 10
return keyboardVerticalOffset
}
async function whenAppViewReady(