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