Merge branch 'main' into app-2288

Resolve conflict in src/components/dms/MessageOverlays.tsx: app-2288's
liveReactionsMessage/displayReactionsMessage already covers main's
reactionsMessage live-read for optimistic reaction updates, and adds a
last-known snapshot so the dialog can finish its close animation. Keep
app-2288's memoized reportSubject (with extracted convoId) over main's
unmemoized inline version.
This commit is contained in:
vineyardbovines
2026-06-05 14:09:12 -04:00
32 changed files with 921 additions and 583 deletions
+7 -1
View File
@@ -9,7 +9,12 @@ import {
setFontScale as persistFontScale, setFontScale as persistFontScale,
} from '#/alf/fonts' } from '#/alf/fonts'
import {themes} from '#/alf/themes' import {themes} from '#/alf/themes'
import {darken, lighten, rgbToHex} from '#/alf/util/colorGeneration' import {
contrastRatio,
darken,
lighten,
rgbToHex,
} from '#/alf/util/colorGeneration'
import {type Device} from '#/storage' import {type Device} from '#/storage'
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf' export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
@@ -26,6 +31,7 @@ export const utils = {
rgbToHex, rgbToHex,
lighten, lighten,
darken, darken,
contrastRatio,
} }
export type Alf = { export type Alf = {
+37 -1
View File
@@ -1,4 +1,10 @@
import {darken, hexToRgb, lighten, rgbToHex} from './colorGeneration' import {
contrastRatio,
darken,
hexToRgb,
lighten,
rgbToHex,
} from './colorGeneration'
describe('hexToRgb', () => { describe('hexToRgb', () => {
it('parses 6-digit hex', () => { it('parses 6-digit hex', () => {
@@ -92,3 +98,33 @@ describe('lighten / darken', () => {
expect(darken('#zzz', 10)).toBe('#zzz') expect(darken('#zzz', 10)).toBe('#zzz')
}) })
}) })
describe('contrastRatio', () => {
it('returns 21 for black on white', () => {
expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 5)
})
it('returns 1 for identical colors', () => {
expect(contrastRatio('#abcdef', '#abcdef')).toBeCloseTo(1, 5)
})
it('is symmetric regardless of argument order', () => {
expect(contrastRatio('#123456', '#fedcba')).toBeCloseTo(
contrastRatio('#fedcba', '#123456')!,
5,
)
})
it('clears AAA large text (4.5:1) for a high-contrast pairing', () => {
expect(contrastRatio('#1d3a5f', '#ffffff')!).toBeGreaterThanOrEqual(4.5)
})
it('fails AAA large text (4.5:1) for a low-contrast pairing', () => {
expect(contrastRatio('#777777', '#888888')!).toBeLessThan(4.5)
})
it('returns null for invalid hex input', () => {
expect(contrastRatio('not-a-color', '#ffffff')).toBeNull()
expect(contrastRatio('#ffffff', '#zzz')).toBeNull()
})
})
+42
View File
@@ -72,6 +72,48 @@ export function rgbToHex(r: number, g: number, b: number): string {
.slice(1)}` .slice(1)}`
} }
/**
* Computes the WCAG contrast ratio between two colors, ranging from 1 (no
* contrast) to 21 (maximum contrast, i.e. black on white). Returns null if
* either argument is not a valid hex color.
*
* @see https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio
*/
export function contrastRatio(hexA: string, hexB: string): number | null {
const rgbA = hexToRgb(hexA)
const rgbB = hexToRgb(hexB)
if (!rgbA || !rgbB) return null
const luminanceA = relativeLuminance(rgbA)
const luminanceB = relativeLuminance(rgbB)
const lighter = Math.max(luminanceA, luminanceB)
const darker = Math.min(luminanceA, luminanceB)
return (lighter + 0.05) / (darker + 0.05)
}
/**
* Computes the WCAG relative luminance of an RGB color, ranging from 0 (black)
* to 1 (white).
*
* @see https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
*/
function relativeLuminance({
r,
g,
b,
}: {
r: number
g: number
b: number
}): number {
const toLinear = (channel: number) => {
const normalized = channel / 255
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4
}
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b)
}
function rgbToHsl( function rgbToHsl(
r: number, r: number,
g: number, g: number,
@@ -23,7 +23,7 @@ export function ChatInviteEmbed({
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
return ( return (
<ChatInvite.Root code={code}> <ChatInvite.Root code={code} hasFixedHeight>
<ChatInviteEmbedBody link={link} onOpen={onOpen} style={style} /> <ChatInviteEmbedBody link={link} onOpen={onOpen} style={style} />
</ChatInvite.Root> </ChatInvite.Root>
) )
@@ -0,0 +1,99 @@
import {Linking, View} from 'react-native'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Sparkle_Stroke2_Corner0_Rounded as Sparkle} from '#/components/icons/Sparkle'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
/**
* OTA-able fallback that ships to native builds which don't yet know how to
* render the new gallery embed (>4 images, Photos v2). Final copy and visual
* treatment pending design from Darrin/Danielle/Alex.
*
* Native-only per APP-2308 - web builds receive the new gallery support in
* the same release that adds it.
*/
export function GalleryFallbackEmbed({count}: {count?: number}) {
const t = useTheme()
const {t: l} = useLingui()
const bodyStyle = [
a.text_sm,
a.text_center,
a.leading_snug,
t.atoms.text_contrast_high,
]
return (
<View
style={[
a.mt_sm,
a.rounded_md,
a.border,
a.p_lg,
a.pb_2xl,
a.gap_sm,
a.align_center,
{
borderColor: t.palette.primary_200,
backgroundColor: t.palette.primary_25,
},
]}>
<Sparkle size="lg" fill={t.palette.primary_500} />
<Text style={[a.text_md, a.font_bold, a.text_center, t.atoms.text]}>
<Trans>Something new is here</Trans>
</Text>
{count ? (
<View>
<Text style={bodyStyle}>
{plural(count, {
one: 'This post has # photo.',
other: 'This post has # photos.',
})}
</Text>
{IS_NATIVE ? (
<Text style={bodyStyle}>
{plural(count, {
one: 'Update your app to see it.',
other: 'Update your app to see them all.',
})}
</Text>
) : (
<Text style={bodyStyle}>
{plural(count, {
one: 'Refresh the page to see it.',
other: 'Refresh the page to see them all.',
})}
</Text>
)}
</View>
) : IS_NATIVE ? (
<Text style={bodyStyle}>
<Trans>Update your app to see it.</Trans>
</Text>
) : (
<Text style={bodyStyle}>
<Trans>Refresh the page to see it.</Trans>
</Text>
)}
{IS_NATIVE && (
<Button
label={l`Update your app`}
size="small"
color="primary"
onPress={() => {
void Linking.openURL(BSKY_DOWNLOAD_URL)
}}
style={[a.mt_xs]}>
<ButtonText>
<Trans>Update app</Trans>
</ButtonText>
</Button>
)}
</View>
)
}
@@ -31,7 +31,10 @@ export function JoinRequestEmbed({
if (!resolvedCode) return null if (!resolvedCode) return null
return ( return (
<ChatInvite.Root code={resolvedCode} initialPreview={preview}> <ChatInvite.Root
code={resolvedCode}
initialPreview={preview}
hasFixedHeight>
<JoinRequestEmbedBody style={style} onOpen={onOpen} /> <JoinRequestEmbedBody style={style} onOpen={onOpen} />
</ChatInvite.Root> </ChatInvite.Root>
) )
@@ -5,7 +5,6 @@ import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {useCallOnce} from '#/lib/once'
import {shareUrl} from '#/lib/sharing' import {shareUrl} from '#/lib/sharing'
import {niceDate} from '#/lib/strings/time' import {niceDate} from '#/lib/strings/time'
import {toNiceDomain} from '#/lib/strings/url-helpers' import {toNiceDomain} from '#/lib/strings/url-helpers'
@@ -104,12 +103,6 @@ export const StandardSiteEmbed = ({
} }
} }
useCallOnce(() => {
if (!preview) {
ax.metric('embed:standardSite:view', {url: view.uri})
}
})()
if (isStandardPublication) { if (isStandardPublication) {
return ( return (
<PublicationCard <PublicationCard
@@ -427,6 +420,26 @@ export function SubscribeButton({
? l`Subscribe on ${highlightedPublisher.name}` ? l`Subscribe on ${highlightedPublisher.name}`
: l`View publication` : l`View publication`
/*
* The custom site theme paints the button background with `accent` and the
* text with `accentForeground`. Only honor it when that pairing clears WCAG
* AAA (4.5:1) for large text, which the button's bold label qualifies as.
* Otherwise we fall through to the default `secondary_inverted` styling,
* which is guaranteed to be legible.
*/
const {accentRGB, accentForegroundRGB} = view.source?.theme || {}
let useCustomTheme = false
if (accentRGB && accentForegroundRGB) {
const accent = utils.rgbToHex(accentRGB.r, accentRGB.g, accentRGB.b)
const accentForeground = utils.rgbToHex(
accentForegroundRGB.r,
accentForegroundRGB.g,
accentForegroundRGB.b,
)
const ratio = utils.contrastRatio(accent, accentForeground)
useCustomTheme = ratio !== null && ratio >= 4.5
}
if (!view.source) return null if (!view.source) return null
const publicationTitle = view.source.title const publicationTitle = view.source.title
@@ -468,8 +481,7 @@ export function SubscribeButton({
} }
} }
return ( const button = (
<StandardSiteThemeProvider view={view}>
<Link <Link
shouldProxy shouldProxy
to={view.source.uri} to={view.source.uri}
@@ -497,7 +509,14 @@ export function SubscribeButton({
</> </>
)} )}
</Link> </Link>
</StandardSiteThemeProvider> )
if (!useCustomTheme) {
return button
}
return (
<StandardSiteThemeProvider view={view}>{button}</StandardSiteThemeProvider>
) )
} }
+8 -4
View File
@@ -31,10 +31,12 @@ export function ProfileBadges({
interactive = false, interactive = false,
size, size,
style, style,
allowFontScaling = true,
}: ViewStyleProp & { }: ViewStyleProp & {
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
interactive?: boolean interactive?: boolean
size: Size size: Size
allowFontScaling?: boolean
}) { }) {
const shadowed = useProfileShadow(profile) const shadowed = useProfileShadow(profile)
const verification = useSimpleVerificationState({profile}) const verification = useSimpleVerificationState({profile})
@@ -48,10 +50,12 @@ export function ProfileBadges({
const isOnTheSmallSide = size === 'xs' || size === 'sm' const isOnTheSmallSide = size === 'xs' || size === 'sm'
const verificationIconWidth = const scaleMultiplier = allowFontScaling
verificationIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier ? nativeScaleMultiplier * alfScaleMultiplier
const botIconWidth = : 1
botIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
const verificationIconWidth = verificationIconSizes[size] * scaleMultiplier
const botIconWidth = botIconSizes[size] * scaleMultiplier
return ( return (
<View <View
+3 -1
View File
@@ -23,6 +23,7 @@ export function Text({
title, title,
dataSet, dataSet,
numberOfLines, numberOfLines,
allowFontScaling = true,
...rest ...rest
}: TextProps) { }: TextProps) {
const {fonts, flags} = useAlf() const {fonts, flags} = useAlf()
@@ -36,7 +37,7 @@ export function Text({
style, style,
], ],
{ {
fontScale: fonts.scaleMultiplier, fontScale: allowFontScaling ? fonts.scaleMultiplier : 1,
fontFamily: fonts.family, fontFamily: fonts.family,
flags, flags,
}, },
@@ -57,6 +58,7 @@ export function Text({
numberOfLines, numberOfLines,
style: s, style: s,
dataSet: Object.assign({tooltip: title}, dataSet || {}), dataSet: Object.assign({tooltip: title}, dataSet || {}),
allowFontScaling,
...rest, ...rest,
} }
+20 -12
View File
@@ -16,7 +16,7 @@ import {useChatInvite} from './Context'
*/ */
export function Card({size}: {size: 'large' | 'small'}) { export function Card({size}: {size: 'large' | 'small'}) {
const t = useTheme() const t = useTheme()
const {preview} = useChatInvite() const {preview, hasFixedHeight} = useChatInvite()
if (!preview) return null if (!preview) return null
@@ -31,14 +31,15 @@ export function Card({size}: {size: 'large' | 'small'}) {
<Text <Text
emoji emoji
style={[size === 'large' ? a.text_lg : a.text_md, a.font_bold]} style={[size === 'large' ? a.text_lg : a.text_md, a.font_bold]}
numberOfLines={1}> numberOfLines={1}
allowFontScaling={!hasFixedHeight}>
{preview.name} {preview.name}
</Text> </Text>
<View style={[a.flex_row, a.align_center, a.gap_sm]}> <View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Text <Text
style={[a.text_2xs, a.font_medium, t.atoms.text_contrast_high]} style={[a.text_2xs, a.font_medium, t.atoms.text_contrast_high]}
allowFontScaling numberOfLines={1}
numberOfLines={1}> allowFontScaling={!hasFixedHeight}>
<Trans>Group chat</Trans> <Trans>Group chat</Trans>
</Text> </Text>
<Text <Text
@@ -48,8 +49,8 @@ export function Card({size}: {size: 'large' | 'small'}) {
a.font_medium, a.font_medium,
t.atoms.text_contrast_high, t.atoms.text_contrast_high,
]} ]}
allowFontScaling numberOfLines={1}
numberOfLines={1}> allowFontScaling={!hasFixedHeight}>
<Trans comment="The number of members in a group chat, in the format '{members}/{total} members'."> <Trans comment="The number of members in a group chat, in the format '{members}/{total} members'.">
{preview.memberCount}/{preview.memberLimit}{' '} {preview.memberCount}/{preview.memberLimit}{' '}
<Plural <Plural
@@ -70,17 +71,24 @@ export function Card({size}: {size: 'large' | 'small'}) {
<Text <Text
emoji emoji
style={[a.flex_shrink, a.text_sm, a.font_medium]} style={[a.flex_shrink, a.text_sm, a.font_medium]}
allowFontScaling numberOfLines={1}
numberOfLines={1}> allowFontScaling={!hasFixedHeight}>
<Trans comment="The group chat creator, in the format 'By {displayName}'."> <Trans comment="The group chat creator, in the format 'By {displayName}'.">
By <Text style={[a.font_medium]}>{ownerDisplayName}</Text> By{' '}
<Text style={[a.font_medium]} allowFontScaling={!hasFixedHeight}>
{ownerDisplayName}
</Text>
</Trans> </Trans>
</Text> </Text>
<ProfileBadges profile={preview.owner} size="sm" /> <ProfileBadges
profile={preview.owner}
size="sm"
allowFontScaling={!hasFixedHeight}
/>
<Text <Text
style={[a.flex_shrink, t.atoms.text_contrast_medium]} style={[a.flex_shrink, t.atoms.text_contrast_medium]}
allowFontScaling numberOfLines={1}
numberOfLines={1}> allowFontScaling={!hasFixedHeight}>
{ownerHandle} {ownerHandle}
</Text> </Text>
</View> </View>
@@ -32,6 +32,8 @@ export type ChatInviteContextValue = {
* preview to act on. * preview to act on.
*/ */
action: ChatInviteAction | undefined action: ChatInviteAction | undefined
/** Whether the invite is rendered inside a fixed-height container; when true, text inside disables font scaling so the card doesn't overflow. */
hasFixedHeight: boolean
} }
const ChatInviteContext = createContext<ChatInviteContextValue | null>(null) const ChatInviteContext = createContext<ChatInviteContextValue | null>(null)
+2 -2
View File
@@ -17,7 +17,7 @@ export function JoinButton({
onPress?: () => void onPress?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
const {action} = useChatInvite() const {action, hasFixedHeight} = useChatInvite()
if (!action) return null if (!action) return null
@@ -35,7 +35,7 @@ export function JoinButton({
disabled={action.disabled} disabled={action.disabled}
style={[a.w_full, style]}> style={[a.w_full, style]}>
{action.side === 'left' && <ButtonIcon icon={action.icon} />} {action.side === 'left' && <ButtonIcon icon={action.icon} />}
<ButtonText>{action.label}</ButtonText> <ButtonText allowFontScaling={!hasFixedHeight}>{action.label}</ButtonText>
{action.side === 'right' && <ButtonIcon icon={action.icon} />} {action.side === 'right' && <ButtonIcon icon={action.icon} />}
</Button> </Button>
) )
+3 -1
View File
@@ -30,6 +30,7 @@ export function Root({
code, code,
initialPreview, initialPreview,
currentConvoId, currentConvoId,
hasFixedHeight,
children, children,
}: { }: {
code: string code: string
@@ -40,6 +41,7 @@ export function Root({
* open/join (you're already here). * open/join (you're already here).
*/ */
currentConvoId?: string currentConvoId?: string
hasFixedHeight: boolean
children: React.ReactNode children: React.ReactNode
}) { }) {
const {hasSession} = useSession() const {hasSession} = useSession()
@@ -137,7 +139,7 @@ export function Root({
return ( return (
<ChatInviteProvider <ChatInviteProvider
value={{code, loading, error: !!error, preview, action}}> value={{code, loading, error: !!error, preview, action, hasFixedHeight}}>
{children} {children}
</ChatInviteProvider> </ChatInviteProvider>
) )
@@ -74,7 +74,8 @@ let MessageItemInviteEmbed = ({
<ChatInvite.Root <ChatInvite.Root
code={embed.joinLinkPreview.code} code={embed.joinLinkPreview.code}
initialPreview={embed.joinLinkPreview} initialPreview={embed.joinLinkPreview}
currentConvoId={convo.convo.view.id}> currentConvoId={convo.convo.view.id}
hasFixedHeight={false}>
<ChatInvite.Card size="small" /> <ChatInvite.Card size="small" />
<ChatInvite.JoinButton /> <ChatInvite.JoinButton />
</ChatInvite.Root> </ChatInvite.Root>
+16
View File
@@ -47,6 +47,22 @@ export function canBeAddedToGroup(profile: bsky.profile.AnyProfileView) {
} }
} }
/**
* Resolves the effective `allowGroupInvites` value for a chat declaration.
* When unset, group invites follow the general DM preference
* (`allowIncoming`), which itself defaults to `following`. This mirrors the
* `undefined` fallthrough in canBeAddedToGroup, and is the single source of
* truth for both displaying and persisting the setting.
*/
export function resolveAllowGroupInvites(
chat: {allowIncoming?: string; allowGroupInvites?: string} | undefined,
): 'all' | 'none' | 'following' {
return (chat?.allowGroupInvites ?? chat?.allowIncoming ?? 'following') as
| 'all'
| 'none'
| 'following'
}
export function localDateString(date: Date) { export function localDateString(date: Date) {
// can't use toISOString because it should be in local time // can't use toISOString because it should be in local time
const mm = date.getMonth() const mm = date.getMonth()
File diff suppressed because one or more lines are too long
+8
View File
@@ -451,6 +451,12 @@ export function Header({
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const requireEmailVerification = useRequireEmailVerification() const requireEmailVerification = useRequireEmailVerification()
const leftConvos = useLeftConvos() const leftConvos = useLeftConvos()
const {isWithinSplitView} = useIsWithinSplitView()
// In split view, the left column (and this header) stays mounted while the
// right column shows the selected route. Pushing would stack duplicate routes
// on repeated clicks, so navigate instead to dedupe by route + params.
const action = isWithinSplitView ? 'navigate' : 'push'
const {data: unreadInboxData, hasNextPage: hasMoreRequests} = const {data: unreadInboxData, hasNextPage: hasMoreRequests} =
useListConvosQuery({ useListConvosQuery({
@@ -494,9 +500,11 @@ export function Header({
count={inboxAllConvos.length} count={inboxAllConvos.length}
more={hasMoreRequests} more={hasMoreRequests}
variant="solid" variant="solid"
action={action}
/> />
<Link <Link
to="/messages/settings" to="/messages/settings"
action={action}
label={l`Chat settings`} label={l`Chat settings`}
size="small" size="small"
color="secondary" color="secondary"
+3 -42
View File
@@ -26,10 +26,9 @@ import {useLeftConvos} from '#/state/queries/messages/leave-conversation'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations' import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {useUpdateAllRead} from '#/state/queries/messages/update-all-read' import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
import {EmptyState} from '#/view/com/util/EmptyState' import {EmptyState} from '#/view/com/util/EmptyState'
import {FAB} from '#/view/com/util/fab/FAB'
import {List} from '#/view/com/util/List' import {List} from '#/view/com/util/List'
import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {atoms as a, useTheme, web} from '#/alf'
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen' import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -62,8 +61,6 @@ export function MessagesInboxScreen(props: Props) {
} }
export function MessagesInboxScreenInner({}: Props) { export function MessagesInboxScreenInner({}: Props) {
const {gtTablet} = useBreakpoints()
const listConvosQuery = useListConvosQuery({status: 'request'}) const listConvosQuery = useListConvosQuery({status: 'request'})
const {data} = listConvosQuery const {data} = listConvosQuery
@@ -94,21 +91,16 @@ export function MessagesInboxScreenInner({}: Props) {
<Layout.Screen testID="messagesInboxScreen"> <Layout.Screen testID="messagesInboxScreen">
<Layout.Header.Outer> <Layout.Header.Outer>
<Layout.Header.BackButton /> <Layout.Header.BackButton />
<Layout.Header.Content align={gtTablet ? 'left' : 'platform'}> <Layout.Header.Content align="left">
<Layout.Header.TitleText> <Layout.Header.TitleText>
<Trans>Chat requests</Trans> <Trans>Chat requests</Trans>
</Layout.Header.TitleText> </Layout.Header.TitleText>
</Layout.Header.Content> </Layout.Header.Content>
{hasUnreadConvos && gtTablet ? ( {hasUnreadConvos ? <MarkAsReadHeaderButton /> : <Layout.Header.Slot />}
<MarkAsReadHeaderButton />
) : (
<Layout.Header.Slot />
)}
</Layout.Header.Outer> </Layout.Header.Outer>
<RequestList <RequestList
listConvosQuery={listConvosQuery} listConvosQuery={listConvosQuery}
conversations={conversations} conversations={conversations}
hasUnreadConvos={hasUnreadConvos}
/> />
</Layout.Screen> </Layout.Screen>
) )
@@ -117,14 +109,12 @@ export function MessagesInboxScreenInner({}: Props) {
function RequestList({ function RequestList({
listConvosQuery, listConvosQuery,
conversations, conversations,
hasUnreadConvos,
}: { }: {
listConvosQuery: UseInfiniteQueryResult< listConvosQuery: UseInfiniteQueryResult<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>, InfiniteData<ChatBskyConvoListConvos.OutputSchema>,
Error Error
> >
conversations: ChatBskyConvoDefs.ConvoView[] conversations: ChatBskyConvoDefs.ConvoView[]
hasUnreadConvos: boolean
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
@@ -285,7 +275,6 @@ function RequestList({
desktopFixedHeight desktopFixedHeight
sideBorders={false} sideBorders={false}
/> />
{hasUnreadConvos && <MarkAllReadFAB />}
</> </>
) )
} }
@@ -298,34 +287,6 @@ function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) {
return <RequestListItem convo={item} /> return <RequestListItem convo={item} />
} }
function MarkAllReadFAB() {
const {t: l} = useLingui()
const t = useTheme()
const {mutate: markAllRead} = useUpdateAllRead('request', {
onMutate: () => {
Toast.show(l`Marked all as read`, {
type: 'success',
})
},
onError: () => {
Toast.show(l`Failed to mark all requests as read`, {
type: 'error',
})
},
})
return (
<FAB
testID="markAllAsReadFAB"
onPress={() => markAllRead()}
icon={<CheckIcon size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={l`Mark all as read`}
accessibilityHint=""
/>
)
}
function MarkAsReadHeaderButton() { function MarkAsReadHeaderButton() {
const {t: l} = useLingui() const {t: l} = useLingui()
const {mutate: markAllRead} = useUpdateAllRead('request', { const {mutate: markAllRead} = useUpdateAllRead('request', {
+2 -4
View File
@@ -13,6 +13,7 @@ import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider' import {Divider} from '#/components/Divider'
import {resolveAllowGroupInvites} from '#/components/dms/util'
import * as Toggle from '#/components/forms/Toggle' import * as Toggle from '#/components/forms/Toggle'
import {Bell_Stroke2_Corner0_Rounded as BellIcon} from '#/components/icons/Bell' import {Bell_Stroke2_Corner0_Rounded as BellIcon} from '#/components/icons/Bell'
import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car' import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car'
@@ -199,10 +200,7 @@ export function MessagesSettingsScreenInner({}: Props) {
<Toggle.Group <Toggle.Group
label={l`Allow group chat invites from`} label={l`Allow group chat invites from`}
type="radio" type="radio"
values={[ values={[resolveAllowGroupInvites(profile?.associated?.chat)]}
(profile?.associated?.chat
?.allowGroupInvites as AllowIncoming) ?? 'following',
]}
onChange={onSelectGroupInvitesFrom}> onChange={onSelectGroupInvitesFrom}>
<View> <View>
{allowGroupInvitesFromOptions.map(option => ( {allowGroupInvitesFromOptions.map(option => (
@@ -122,7 +122,7 @@ function DirectChatItem({
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
const profile = useProfileShadow(convo.primaryMember) const profile = useProfileShadow(convo.primaryMember)
const {isWithinSplitView} = useIsWithinSplitView() const {isWithinLeftPanel} = useIsWithinSplitView()
const moderation = useMemo( const moderation = useMemo(
() => moderateProfile(profile, moderationOpts), () => moderateProfile(profile, moderationOpts),
@@ -140,7 +140,7 @@ function DirectChatItem({
avatar={ avatar={
<PreviewableUserAvatar <PreviewableUserAvatar
profile={profile} profile={profile}
size={isWithinSplitView ? 48 : 52} size={isWithinLeftPanel ? 48 : 52}
moderation={moderation.ui('avatar')} moderation={moderation.ui('avatar')}
/> />
} }
@@ -161,7 +161,7 @@ function DirectChatItem({
isBlockedAccount={moderation.blocked} isBlockedAccount={moderation.blocked}
showProfileBadges showProfileBadges
postAlerts={ postAlerts={
isWithinSplitView ? null : ( isWithinLeftPanel ? null : (
<PostAlerts <PostAlerts
modui={moderation.ui('contentList')} modui={moderation.ui('contentList')}
size="sm" size="sm"
@@ -189,7 +189,7 @@ function GroupChatItem({
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
const groupOwner = useMaybeProfileShadow(convo.primaryMember) const groupOwner = useMaybeProfileShadow(convo.primaryMember)
const {isWithinSplitView} = useIsWithinSplitView() const {isWithinLeftPanel} = useIsWithinSplitView()
const moderation = useMemo( const moderation = useMemo(
() => () =>
@@ -205,7 +205,7 @@ function GroupChatItem({
avatar={ avatar={
<AvatarBubbles <AvatarBubbles
profiles={convo.members} profiles={convo.members}
size={isWithinSplitView ? 48 : 52} size={isWithinLeftPanel ? 48 : 52}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
/> />
} }
@@ -278,7 +278,7 @@ function BaseChatItem({
const leaveConvoControl = useDialogControl() const leaveConvoControl = useDialogControl()
const {mutate: markAsRead} = useMarkAsReadMutation() const {mutate: markAsRead} = useMarkAsReadMutation()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {isWithinSplitView} = useIsWithinSplitView() const {isWithinLeftPanel} = useIsWithinSplitView()
const playHaptic = useHaptics() const playHaptic = useHaptics()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -458,7 +458,7 @@ function BaseChatItem({
leftFirst: deleteAction, leftFirst: deleteAction,
} }
const avatarSize = isWithinSplitView ? 48 : 52 const avatarSize = isWithinLeftPanel ? 48 : 52
return ( return (
<ChatListItemPortal.Provider> <ChatListItemPortal.Provider>
@@ -469,7 +469,7 @@ function BaseChatItem({
// @ts-expect-error web only // @ts-expect-error web only
onFocus={onFocus} onFocus={onFocus}
onBlur={onMouseLeave} onBlur={onMouseLeave}
style={[a.relative, t.atoms.bg, isWithinSplitView && a.mx_sm]}> style={[a.relative, t.atoms.bg, isWithinLeftPanel && a.mx_sm]}>
<View <View
style={[ style={[
a.z_10, a.z_10,
@@ -481,6 +481,9 @@ function BaseChatItem({
<Link <Link
to={`/messages/${convo.view.id}`} to={`/messages/${convo.view.id}`}
// In split view, this list stays mounted alongside the open convo,
// so push would stack duplicate routes on repeated clicks.
action={isWithinLeftPanel ? 'navigate' : 'push'}
label={title} label={title}
accessibilityHint={accessibilityHint} accessibilityHint={accessibilityHint}
accessibilityActions={ accessibilityActions={
@@ -512,7 +515,7 @@ function BaseChatItem({
a.px_lg, a.px_lg,
a.py_md, a.py_md,
a.gap_md, a.gap_md,
isWithinSplitView && a.rounded_sm, isWithinLeftPanel && a.rounded_sm,
{ {
backgroundColor: hasUnread backgroundColor: hasUnread
? t.palette.primary_25 ? t.palette.primary_25
@@ -11,10 +11,12 @@ export function InboxRequests({
count, count,
more, more,
variant, variant,
action,
}: { }: {
count: number count: number
more: boolean more: boolean
variant?: 'ghost' | 'solid' variant?: 'ghost' | 'solid'
action?: 'push' | 'navigate'
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -41,6 +43,7 @@ export function InboxRequests({
<Link <Link
label={label} label={label}
to="/messages/inbox" to="/messages/inbox"
action={action}
size="small" size="small"
variant={unread ? 'solid' : 'ghost'} variant={unread ? 'solid' : 'ghost'}
color={unread ? 'primary_subtle' : 'secondary'} color={unread ? 'primary_subtle' : 'secondary'}
@@ -66,6 +69,7 @@ export function InboxRequests({
<Link <Link
label={label} label={label}
to="/messages/inbox" to="/messages/inbox"
action={action}
color={unread ? 'primary_subtle' : 'secondary'} color={unread ? 'primary_subtle' : 'secondary'}
size="small"> size="small">
<ButtonIcon icon={InboxIcon} /> <ButtonIcon icon={InboxIcon} />
@@ -273,7 +273,7 @@ function MessageInputInviteEmbed({
const {t: l} = useLingui() const {t: l} = useLingui()
return ( return (
<ChatInvite.Root code={code}> <ChatInvite.Root code={code} hasFixedHeight={false}>
<View <View
style={[ style={[
a.flex_1, a.flex_1,
@@ -7,6 +7,7 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {resolveAllowGroupInvites} from '#/components/dms/util'
import {RQKEY as PROFILE_RKEY} from '../profile' import {RQKEY as PROFILE_RKEY} from '../profile'
export function useUpdateActorDeclaration({ export function useUpdateActorDeclaration({
@@ -34,8 +35,12 @@ export function useUpdateActorDeclaration({
update.allowIncoming ?? update.allowIncoming ??
current?.associated?.chat?.allowIncoming ?? current?.associated?.chat?.allowIncoming ??
'following' 'following'
const allowGroupInvites = const allowGroupInvites = resolveAllowGroupInvites({
update.allowGroupInvites ?? current?.associated?.chat?.allowGroupInvites allowIncoming,
allowGroupInvites:
update.allowGroupInvites ??
current?.associated?.chat?.allowGroupInvites,
})
const result = await agent.com.atproto.repo.putRecord({ const result = await agent.com.atproto.repo.putRecord({
repo: currentAccount.did, repo: currentAccount.did,
collection: 'chat.bsky.actor.declaration', collection: 'chat.bsky.actor.declaration',
@@ -43,7 +48,7 @@ export function useUpdateActorDeclaration({
record: { record: {
$type: 'chat.bsky.actor.declaration', $type: 'chat.bsky.actor.declaration',
allowIncoming, allowIncoming,
...(allowGroupInvites && {allowGroupInvites}), allowGroupInvites,
}, },
}) })
return result return result
@@ -54,14 +59,26 @@ export function useUpdateActorDeclaration({
PROFILE_RKEY(currentAccount?.did), PROFILE_RKEY(currentAccount?.did),
(old?: AppBskyActorDefs.ProfileViewDetailed) => { (old?: AppBskyActorDefs.ProfileViewDetailed) => {
if (!old) return old if (!old) return old
const allowIncoming =
update.allowIncoming ??
old.associated?.chat?.allowIncoming ??
'following'
// resolve the same concrete value the server will receive, so
// optimistic cache and persisted record stay aligned
const allowGroupInvites = resolveAllowGroupInvites({
allowIncoming,
allowGroupInvites:
update.allowGroupInvites ??
old.associated?.chat?.allowGroupInvites,
})
return { return {
...old, ...old,
associated: { associated: {
...old.associated, ...old.associated,
chat: { chat: {
allowIncoming: 'following',
...old.associated?.chat, ...old.associated?.chat,
...update, allowIncoming,
allowGroupInvites,
}, },
}, },
} satisfies AppBskyActorDefs.ProfileViewDetailed } satisfies AppBskyActorDefs.ProfileViewDetailed
@@ -418,67 +418,34 @@ export function ListConvosProviderInner({
})), })),
) )
} else if (ChatBskyConvoDefs.isLogLockConvo(log)) { } else if (ChatBskyConvoDefs.isLogLockConvo(log)) {
queryClient.setQueriesData( mutateConvoView(log.convoId, convo =>
{queryKey: [RQKEY_ROOT]}, ChatBskyConvoDefs.isGroupConvo(convo.kind)
(old?: ConvoListQueryData) => ? {
optimisticUpdate(log.convoId, old, convo => {
if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
return {
...convo, ...convo,
kind: { kind: {...convo.kind, lockStatus: 'locked'},
...convo.kind,
lockStatus: 'locked',
},
rev: log.rev, rev: log.rev,
} }
} : {...convo, rev: log.rev},
return {
...convo,
rev: log.rev,
}
}),
) )
} else if (ChatBskyConvoDefs.isLogUnlockConvo(log)) { } else if (ChatBskyConvoDefs.isLogUnlockConvo(log)) {
queryClient.setQueriesData( mutateConvoView(log.convoId, convo =>
{queryKey: [RQKEY_ROOT]}, ChatBskyConvoDefs.isGroupConvo(convo.kind)
(old?: ConvoListQueryData) => ? {
optimisticUpdate(log.convoId, old, convo => {
if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
return {
...convo, ...convo,
kind: { kind: {...convo.kind, lockStatus: 'unlocked'},
...convo.kind,
lockStatus: 'unlocked',
},
rev: log.rev, rev: log.rev,
} }
} : {...convo, rev: log.rev},
return {
...convo,
rev: log.rev,
}
}),
) )
} else if (ChatBskyConvoDefs.isLogLockConvoPermanently(log)) { } else if (ChatBskyConvoDefs.isLogLockConvoPermanently(log)) {
queryClient.setQueriesData( mutateConvoView(log.convoId, convo =>
{queryKey: [RQKEY_ROOT]}, ChatBskyConvoDefs.isGroupConvo(convo.kind)
(old?: ConvoListQueryData) => ? {
optimisticUpdate(log.convoId, old, convo => {
if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
return {
...convo, ...convo,
kind: { kind: {...convo.kind, lockStatus: 'locked-permanently'},
...convo.kind,
lockStatus: 'locked-permanently',
},
rev: log.rev, rev: log.rev,
} }
} : {...convo, rev: log.rev},
return {
...convo,
rev: log.rev,
}
}),
) )
} else if ( } else if (
ChatBskyConvoDefs.isLogCreateJoinLink(log) || ChatBskyConvoDefs.isLogCreateJoinLink(log) ||
+13
View File
@@ -12,6 +12,7 @@ import {
} from 'react-native' } from 'react-native'
import { import {
type AppBskyActorDefs, type AppBskyActorDefs,
AppBskyEmbedExternal,
AppBskyEmbedVideo, AppBskyEmbedVideo,
type AppBskyFeedDefs, type AppBskyFeedDefs,
} from '@atproto/api' } from '@atproto/api'
@@ -58,6 +59,7 @@ import {
} from '#/components/feeds/PostFeedVideoGridRow' } from '#/components/feeds/PostFeedVideoGridRow'
import {TrendingInterstitial} from '#/components/interstitials/Trending' import {TrendingInterstitial} from '#/components/interstitials/Trending'
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env' import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner' import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner'
@@ -905,6 +907,7 @@ let PostFeed = ({
const seenActorWithStatusRef = useRef<Set<string>>(new Set()) const seenActorWithStatusRef = useRef<Set<string>>(new Set())
const seenPostUrisRef = useRef<Set<string>>(new Set()) const seenPostUrisRef = useRef<Set<string>>(new Set())
const seenStandardSiteUrisRef = useRef<Set<string>>(new Set())
// Helper to calculate position in feed (count only root posts, not interstitials or thread replies) // Helper to calculate position in feed (count only root posts, not interstitials or thread replies)
const getPostPosition = useNonReactiveCallback( const getPostPosition = useNonReactiveCallback(
@@ -974,6 +977,16 @@ let PostFeed = ({
}) })
} }
} }
// Standard site embed view tracking
if (
AppBskyEmbedExternal.isView(post.embed) &&
isStandardSiteEmbed(post.embed.external) &&
!seenStandardSiteUrisRef.current.has(post.embed.external.uri)
) {
seenStandardSiteUrisRef.current.add(post.embed.external.uri)
ax.metric('embed:standardSite:view', {url: post.embed.external.uri})
}
} else if (item.type === 'videoGridRow') { } else if (item.type === 'videoGridRow') {
// Track each video in the grid row // Track each video in the grid row
for (let i = 0; i < item.items.length; i++) { for (let i = 0; i < item.items.length; i++) {
@@ -0,0 +1,25 @@
import {View} from 'react-native'
import {atoms as a} from '#/alf'
import {GalleryFallbackEmbed} from '#/components/Post/Embed/GalleryFallbackEmbed'
import {H1, H3} from '#/components/Typography'
export function GalleryFallback() {
return (
<View style={[a.gap_md]}>
<H1>Gallery fallback (APP-2308)</H1>
<H3>No count</H3>
<GalleryFallbackEmbed />
<H3>1 photo</H3>
<GalleryFallbackEmbed count={1} />
<H3>5 photos</H3>
<GalleryFallbackEmbed count={5} />
<H3>10 photos</H3>
<GalleryFallbackEmbed count={10} />
</View>
)
}
+2
View File
@@ -16,6 +16,7 @@ import {Breakpoints} from './Breakpoints'
import {Buttons} from './Buttons' import {Buttons} from './Buttons'
import {Dialogs} from './Dialogs' import {Dialogs} from './Dialogs'
import {Forms} from './Forms' import {Forms} from './Forms'
import {GalleryFallback} from './GalleryFallback'
import {Icons} from './Icons' import {Icons} from './Icons'
import {Links} from './Links' import {Links} from './Links'
import {Menus} from './Menus' import {Menus} from './Menus'
@@ -120,6 +121,7 @@ export default function Storybook() {
<Breakpoints /> <Breakpoints />
<Dialogs /> <Dialogs />
<Admonitions /> <Admonitions />
<GalleryFallback />
<Settings /> <Settings />
<Button <Button
+12 -11
View File
@@ -1,29 +1,30 @@
import {useEffect, useRef} from 'react' import {useEffect} from 'react'
import {Modal, View} from 'react-native' import {Modal, View} from 'react-native'
import {SystemBars} from 'react-native-edge-to-edge'
import {useDialogStateControlContext} from '#/state/dialogs'
import {useComposerState} from '#/state/shell/composer' import {useComposerState} from '#/state/shell/composer'
import {ComposePost, useComposerCancelRef} from '#/view/com/composer/Composer' import {ComposePost, useComposerCancelRef} from '#/view/com/composer/Composer'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {SheetCompatProvider as TooltipSheetCompatProvider} from '#/components/Tooltip' import {SheetCompatProvider as TooltipSheetCompatProvider} from '#/components/Tooltip'
import {IS_LIQUID_GLASS} from '#/env'
export function Composer({}: {winHeight: number}) { export function Composer() {
const {setFullyExpandedCount} = useDialogStateControlContext()
const t = useTheme() const t = useTheme()
const state = useComposerState() const state = useComposerState()
const ref = useComposerCancelRef() const ref = useComposerCancelRef()
const open = !!state const open = !!state
const prevOpen = useRef(open)
useEffect(() => { useEffect(() => {
if (open && !prevOpen.current) { if (open && !IS_LIQUID_GLASS) {
setFullyExpandedCount(c => c + 1) const entry = SystemBars.pushStackEntry({
} else if (!open && prevOpen.current) { style: {
setFullyExpandedCount(c => c - 1) statusBar: 'light',
},
})
return () => SystemBars.popStackEntry(entry)
} }
prevOpen.current = open }, [open])
}, [open, setFullyExpandedCount])
return ( return (
<Modal <Modal
+21 -29
View File
@@ -1,49 +1,41 @@
import {useEffect} from 'react' import {useEffect} from 'react'
import {Animated, Easing} from 'react-native' import {SystemBars} from 'react-native-edge-to-edge'
import Animated, {
Easing,
SlideInDown,
SlideOutDown,
} from 'react-native-reanimated'
import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue'
import {useComposerState} from '#/state/shell/composer' import {useComposerState} from '#/state/shell/composer'
import {ComposePost} from '#/view/com/composer/Composer'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {ComposePost} from '../com/composer/Composer'
export function Composer({winHeight}: {winHeight: number}) { export function Composer() {
const state = useComposerState() const state = useComposerState()
const t = useTheme() const t = useTheme()
const initInterp = useAnimatedValue(0)
const open = !!state
useEffect(() => { useEffect(() => {
if (state) { if (open) {
Animated.timing(initInterp, { const entry = SystemBars.pushStackEntry({
toValue: 1, style: {
duration: 300, statusBar: t.name !== 'light' ? 'light' : 'dark',
easing: Easing.out(Easing.exp),
useNativeDriver: true,
}).start()
} else {
initInterp.setValue(0)
}
}, [initInterp, state])
const wrapperAnimStyle = {
transform: [
{
translateY: initInterp.interpolate({
inputRange: [0, 1],
outputRange: [winHeight, 0],
}),
}, },
], })
return () => SystemBars.popStackEntry(entry)
} }
}, [open, t.name])
// rendering if (!open) {
// =
if (!state) {
return null return null
} }
return ( return (
<Animated.View <Animated.View
style={[a.absolute, a.inset_0, t.atoms.bg, wrapperAnimStyle]} style={[a.absolute, a.inset_0, t.atoms.bg]}
entering={SlideInDown.duration(300).easing(Easing.out(Easing.exp))}
exiting={SlideOutDown.duration(200).easing(Easing.in(Easing.quad))}
aria-modal aria-modal
accessibilityViewIsModal> accessibilityViewIsModal>
<ComposePost <ComposePost
+2 -5
View File
@@ -5,18 +5,15 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
import {useA11y} from '#/state/a11y' import {useA11y} from '#/state/a11y'
import {useModals} from '#/state/modals' import {useModals} from '#/state/modals'
import {type ComposerOpts, useComposerState} from '#/state/shell/composer' import {type ComposerOpts, useComposerState} from '#/state/shell/composer'
import {ComposePost, useComposerCancelRef} from '#/view/com/composer/Composer'
import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf' import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf'
import {ComposePost, useComposerCancelRef} from '../com/composer/Composer'
const BOTTOM_BAR_HEIGHT = 61 const BOTTOM_BAR_HEIGHT = 61
export function Composer({}: {winHeight: number}) { export function Composer() {
const state = useComposerState() const state = useComposerState()
const isActive = !!state const isActive = !!state
// rendering
// =
if (!isActive) { if (!isActive) {
return null return null
} }
+1 -3
View File
@@ -51,7 +51,6 @@ import {Composer} from './Composer'
import {DrawerContent} from './Drawer' import {DrawerContent} from './Drawer'
function ShellInner() { function ShellInner() {
const winDim = useWindowDimensions()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const {state: policyUpdateState} = usePolicyUpdateContext() const {state: policyUpdateState} = usePolicyUpdateContext()
@@ -108,8 +107,7 @@ function ShellInner() {
<TabsNavigator layout={drawerLayout} /> <TabsNavigator layout={drawerLayout} />
</ErrorBoundary> </ErrorBoundary>
</View> </View>
<Composer />
<Composer winHeight={winDim.height} />
<ModalsContainer /> <ModalsContainer />
<MutedWordsDialog /> <MutedWordsDialog />
<SigninDialog /> <SigninDialog />
+1 -1
View File
@@ -64,7 +64,7 @@ function ShellInner() {
<ErrorBoundary> <ErrorBoundary>
<FlatNavigator layout={drawerLayout} /> <FlatNavigator layout={drawerLayout} />
</ErrorBoundary> </ErrorBoundary>
<Composer winHeight={0} /> <Composer />
<ModalsContainer /> <ModalsContainer />
<MutedWordsDialog /> <MutedWordsDialog />
<SigninDialog /> <SigninDialog />