Files
bsky-social-app/src/features/composePrompt/ComposePromptPill.tsx
T
Claude 6b69667567 Settle the compose pill: border follows the pill, less width change, light tint
- Blend the bar's top border away from the pill's own visibility rather than
  a per-screen border contribution. The latter was faded on a spring when a
  screen was popped, so the border flashed back mid-pop between two screens
  that both show the pill.
- Give the current label a small head start when picking the most present
  screen, so the two cannot trade places repeatedly as they cross.
- Grow the up-state side inset to 16pt so the pill changes width less on its
  way down to the safe-area inset.
- Add a half-strength tint to the clear glass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoggCVt9XgpKbjrTZjoJD9
2026-09-07 22:09:59 +00:00

229 lines
7.4 KiB
TypeScript

import {useState} from 'react'
import Animated, {
interpolate,
useAnimatedReaction,
useAnimatedStyle,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {scheduleOnRN} from 'react-native-worklets'
import {useLingui} from '@lingui/react/macro'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {useHaptics} from '#/lib/haptics'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useMinimalShellMode} from '#/state/shell/minimal-mode'
import {useShellLayout} from '#/state/shell/shell-layout'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, ios, tokens, useTheme, utils} from '#/alf'
import {Button} from '#/components/Button'
import {GlassView, IS_GLASS_AVAILABLE} from '#/components/GlassView'
import {Camera_Stroke2_Corner0_Rounded as CameraIcon} from '#/components/icons/Camera'
import {Image_Stroke2_Corner2_Rounded as ImageIcon} from '#/components/icons/Image'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE, IS_WEB} from '#/env'
import {useComposePromptState} from './context'
import {useComposePromptMedia} from './useComposePromptMedia'
const AVATAR_SIZE = 32
/**
* Horizontal inset of the pill while the bottom bar is showing. Once the bar
* hides, the inset grows to the bottom safe area for equal spacing, so the
* closer this is to that, the less the pill changes width on the way down.
*/
const SIDE_MARGIN_UP = tokens.space.lg
/**
* Gap between the pill and the top of the bottom bar.
*/
const GAP_ABOVE_BAR = tokens.space._2xs
/**
* The compose pill that lives in the bottom bar. Screens opt in with
* `useComposePromptForScreen`; the pill fades with their presence, and when
* the bar hides it drops into the bar's space rather than leaving with it.
*/
export function ComposePromptPill() {
const {visibility, config} = useComposePromptState()
const {footerMode} = useMinimalShellMode()
const {footerHeight} = useShellLayout()
const insets = useSafeAreaInsets()
const t = useTheme()
const {t: l} = useLingui()
const ax = useAnalytics()
const playHaptic = useHaptics()
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const {onPressGallery, onPressCamera} = useComposePromptMedia(config?.open)
/*
* Liquid Glass materializes and dissolves through the system's own
* animation when the style flips, so the glass follows a boolean while the
* contents follow the presence value frame by frame.
*/
const [glassVisible, setGlassVisible] = useState(false)
useAnimatedReaction(
() => visibility.get() > 0.5,
(current, previous) => {
if (current !== previous) {
scheduleOnRN(setGlassVisible, current)
}
},
)
/*
* The pill sits just above the bar and follows it down as it hides. In the
* down state it is inset from the left, right and bottom edges by the same
* amount, so that its corners are concentric with the device bevels.
*/
const downMargin = Math.max(insets.bottom, tokens.space.lg)
const wrapperStyle = useAnimatedStyle(() => {
const mode = footerMode.get()
const upBottom = footerHeight.get() + GAP_ABOVE_BAR
return {
bottom: footerHeight.get(),
paddingHorizontal: interpolate(
mode,
[0, 1],
[SIDE_MARGIN_UP, downMargin],
),
transform: [
{translateY: interpolate(mode, [0, 1], [0, upBottom - downMargin])},
],
}
})
const pillStyle = useAnimatedStyle(() => {
const shown = visibility.get()
return {
pointerEvents: shown > 0.5 ? 'auto' : 'none',
// a fully transparent ancestor stops Liquid Glass rendering at all
opacity: IS_GLASS_AVAILABLE ? 1 : shown,
}
})
const fadeStyle = useAnimatedStyle(() => ({
opacity: visibility.get(),
}))
if (!config) {
return null
}
const onPress = () => {
ax.metric('composerPrompt:press', {})
config.open()
}
return (
<Animated.View
pointerEvents="box-none"
style={[
IS_WEB ? a.fixed : a.absolute,
a.left_0,
a.right_0,
a.z_10,
{paddingBottom: GAP_ABOVE_BAR},
wrapperStyle,
]}>
<Animated.View style={pillStyle}>
<PressableScale
testID="composePromptPill"
accessibilityRole="button"
accessibilityLabel={config.accessibilityLabel}
accessibilityHint={config.accessibilityHint}
targetScale={0.97}
onPress={() => {
onPress()
playHaptic('Light')
}}
onLongPress={ios(() => {
onPress()
playHaptic('Heavy')
})}>
<GlassView
isInteractive
glassEffectStyle={{
style: glassVisible ? 'clear' : 'none',
animate: true,
}}
tintColor={utils.alpha(t.palette.contrast_50, 0.5)}
style={[a.rounded_full]}
fallbackStyle={[
a.border,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
]}>
<Animated.View
style={[
a.flex_row,
a.align_center,
a.gap_sm,
a.py_sm,
{paddingLeft: tokens.space.sm, paddingRight: tokens.space.xs},
fadeStyle,
]}>
<UserAvatar
size={AVATAR_SIZE}
avatar={profile?.avatar}
type={profile?.associated?.labeler ? 'labeler' : 'user'}
/>
<Text
numberOfLines={1}
style={[a.flex_1, a.text_md, t.atoms.text_contrast_medium]}>
{config.label}
</Text>
{IS_NATIVE && (
<Button
onPress={e => {
e.stopPropagation()
void onPressCamera()
}}
label={l`Open camera`}
accessibilityHint={l`Opens device camera`}
variant="ghost"
shape="round"
size="small">
{({hovered, pressed, focused}) => (
<CameraIcon
size="lg"
style={{
color:
hovered || pressed || focused
? t.palette.primary_500
: t.palette.contrast_400,
}}
/>
)}
</Button>
)}
<Button
onPress={e => {
e.stopPropagation()
void onPressGallery()
}}
label={l`Add image`}
accessibilityHint={l`Opens image picker`}
variant="ghost"
shape="round"
size="small">
{({hovered, pressed, focused}) => (
<ImageIcon
size="lg"
style={{
color:
hovered || pressed || focused
? t.palette.primary_500
: t.palette.contrast_400,
}}
/>
)}
</Button>
</Animated.View>
</GlassView>
</PressableScale>
</Animated.View>
</Animated.View>
)
}