Replace the compose FAB with a Liquid Glass compose pill in the bottom bar
## Summary - Remove the compose FAB, the new chat FAB, the "load latest" button and the Discover/Following inline composer prompt. The new chat entry point still needs a replacement. - Add `features/composePrompt`: screens register a pill config (label, accessibility text, an `open` callback) and the bottom bar renders a single pill driven by that context. Registration follows the screen's presence, so the pill fades in and out with pushes, pops and swipe-back, and the label switches to whichever screen is most present mid-transition. The bar's top border hides while a pill is registered. - The pill is a Liquid Glass view with `isInteractive`, toggling its glass style with the system animation as it comes and goes, with a gradient behind it that fades further than the thread version so content shows through the glass. It includes the camera and gallery buttons from the old feed prompt. - When the bottom bar hides, the pill drops into the bar's space and scales down, inset equally from the left, right and bottom edges so its corners sit concentric with the device bevels; while the bar shows it hugs the edges. - Home, Notifications, Feeds, custom feeds and lists open the composer; a profile pre-fills a mention as the FAB did; a thread opens the reply composer, including media picked from the pill. ## Test plan - [ ] Home: pill above the bar; tap opens composer; camera/gallery open with media - [ ] Home: scroll down hides the bar and the pill drops, shrinks and sits inset from the bevels; scroll up restores it - [ ] Push a thread from Home: label crossfades to "Write your reply" mid-transition; tap opens the reply composer - [ ] Thread with replies disabled: pill fades out on push and back in on pop - [ ] Profile of another user: composer opens with their handle - [ ] Search tab / Messages tab: pill fades out; pill fades back when returning - [ ] Conversation screen: pill fades out while the bar slides away - [ ] Android / iOS < 26: plain pill fallback fades in and out - [ ] Mobile web: pill shows above the web bottom bar Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QoggCVt9XgpKbjrTZjoJD9
This commit is contained in:
@@ -1,257 +0,0 @@
|
||||
import {useCallback, useState} from 'react'
|
||||
import {Keyboard, Pressable, View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {
|
||||
useCameraPermission,
|
||||
usePhotoLibraryPermission,
|
||||
useVideoLibraryPermission,
|
||||
} from '#/lib/hooks/usePermissions'
|
||||
import {openCamera, openUnifiedPicker} from '#/lib/media/picker'
|
||||
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
|
||||
import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
||||
import {Camera_Stroke2_Corner0_Rounded as CameraIcon} from '#/components/icons/Camera'
|
||||
import {Image_Stroke2_Corner2_Rounded as ImageIcon} from '#/components/icons/Image'
|
||||
import {SubtleHover} from '#/components/SubtleHover'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
|
||||
export function ComposerPrompt() {
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {openComposer} = useOpenComposer()
|
||||
const profile = useCurrentAccountProfile()
|
||||
const [hover, setHover] = useState(false)
|
||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
|
||||
const sheetWrapper = useSheetWrapper()
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
ax.metric('composerPrompt:press', {})
|
||||
openComposer({logContext: 'Fab'})
|
||||
}, [ax, openComposer])
|
||||
|
||||
const onPressImage = useCallback(async () => {
|
||||
ax.metric('composerPrompt:gallery:press', {})
|
||||
|
||||
// On web, open the composer with the gallery picker auto-opening
|
||||
if (!IS_NATIVE) {
|
||||
openComposer({openGallery: true, logContext: 'Fab'})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const [photoAccess, videoAccess] = await Promise.all([
|
||||
requestPhotoAccessIfNeeded(),
|
||||
requestVideoAccessIfNeeded(),
|
||||
])
|
||||
|
||||
if (!photoAccess) {
|
||||
if (!videoAccess) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (Keyboard.isVisible()) {
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
|
||||
const selectionCountRemaining = MAX_GALLERY_IMAGES
|
||||
const {assets, canceled} = await sheetWrapper(
|
||||
openUnifiedPicker({selectionCountRemaining}),
|
||||
)
|
||||
|
||||
if (canceled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (assets.length > 0) {
|
||||
const imageUris = assets
|
||||
.filter(asset => asset.mimeType?.startsWith('image/'))
|
||||
.slice(0, MAX_GALLERY_IMAGES)
|
||||
.map(asset => ({
|
||||
uri: asset.uri,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
}))
|
||||
|
||||
if (imageUris.length > 0) {
|
||||
openComposer({imageUris, logContext: 'Fab'})
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!String(err).toLowerCase().includes('cancel')) {
|
||||
ax.logger.error('Error opening image picker', {error: err})
|
||||
}
|
||||
}
|
||||
}, [
|
||||
ax,
|
||||
openComposer,
|
||||
requestPhotoAccessIfNeeded,
|
||||
requestVideoAccessIfNeeded,
|
||||
sheetWrapper,
|
||||
])
|
||||
|
||||
const onPressCamera = useCallback(async () => {
|
||||
ax.metric('composerPrompt:camera:press', {})
|
||||
|
||||
try {
|
||||
if (!(await requestCameraAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
|
||||
if (IS_NATIVE) {
|
||||
if (Keyboard.isVisible()) {
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
const image = await openCamera({
|
||||
mediaTypes: 'images',
|
||||
})
|
||||
if (!image) {
|
||||
return
|
||||
}
|
||||
|
||||
const imageUris = [
|
||||
{
|
||||
uri: image.path,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
},
|
||||
]
|
||||
|
||||
/*
|
||||
* Statement form rather than a ternary: React Compiler cannot lower a
|
||||
* conditional expression inside a `try`, and `imageUris` is built here.
|
||||
*/
|
||||
let nativeImageUris
|
||||
if (IS_NATIVE) {
|
||||
nativeImageUris = imageUris
|
||||
}
|
||||
openComposer({
|
||||
imageUris: nativeImageUris,
|
||||
logContext: 'Fab',
|
||||
})
|
||||
} catch (err: any) {
|
||||
if (!String(err).toLowerCase().includes('cancel')) {
|
||||
ax.logger.error('Error opening camera', {error: err})
|
||||
}
|
||||
}
|
||||
}, [ax, openComposer, requestCameraAccessIfNeeded])
|
||||
|
||||
if (!profile) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Compose new post`)}
|
||||
accessibilityHint={_(msg`Opens the post composer`)}
|
||||
onPointerEnter={() => setHover(true)}
|
||||
onPointerLeave={() => setHover(false)}
|
||||
style={({pressed}) => [
|
||||
a.relative,
|
||||
a.flex_row,
|
||||
a.align_start,
|
||||
{
|
||||
paddingLeft: 18,
|
||||
paddingRight: 15,
|
||||
},
|
||||
a.py_md,
|
||||
native({
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
}),
|
||||
web({
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
pressed && web({outline: 'none'}),
|
||||
]}>
|
||||
<SubtleHover hover={hover} />
|
||||
<UserAvatar
|
||||
avatar={profile.avatar}
|
||||
size={42}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.ml_md,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
{
|
||||
height: 40,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_md,
|
||||
{includeFontPadding: false},
|
||||
]}>
|
||||
<Trans>What's up?</Trans>
|
||||
</Text>
|
||||
<View style={[a.flex_row, a.gap_md]}>
|
||||
{IS_NATIVE && (
|
||||
<Button
|
||||
onPress={e => {
|
||||
e.stopPropagation()
|
||||
onPressCamera()
|
||||
}}
|
||||
label={_(msg`Open camera`)}
|
||||
accessibilityHint={_(msg`Opens device camera`)}
|
||||
variant="ghost"
|
||||
shape="round">
|
||||
{({hovered, pressed, focused}) => (
|
||||
<CameraIcon
|
||||
size="lg"
|
||||
style={{
|
||||
color:
|
||||
hovered || pressed || focused
|
||||
? t.palette.primary_500
|
||||
: t.palette.contrast_300,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onPress={e => {
|
||||
e.stopPropagation()
|
||||
onPressImage()
|
||||
}}
|
||||
label={_(msg`Add image`)}
|
||||
accessibilityHint={_(msg`Opens image picker`)}
|
||||
variant="ghost"
|
||||
shape="round">
|
||||
{({hovered, pressed, focused}) => (
|
||||
<ImageIcon
|
||||
size="lg"
|
||||
style={{
|
||||
color:
|
||||
hovered || pressed || focused
|
||||
? t.palette.primary_500
|
||||
: t.palette.contrast_300,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
@@ -7,13 +7,10 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {type NavigationProp, useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {DISCOVER_FEED_URI, VIDEO_FEED_URIS} from '#/lib/constants'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers'
|
||||
import {type AllNavigatorParams} from '#/lib/routes/types'
|
||||
import {listenSoftReset} from '#/state/events'
|
||||
@@ -28,13 +25,9 @@ import {
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {useSession} from '#/state/session'
|
||||
import {PostFeed} from '#/view/com/posts/PostFeed'
|
||||
import {FAB} from '#/view/com/util/fab/FAB'
|
||||
import {type ListMethods} from '#/view/com/util/List'
|
||||
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
|
||||
import {MainScrollProvider} from '#/view/com/util/MainScrollProvider'
|
||||
import {useTheme} from '#/alf'
|
||||
import {useHeaderOffset} from '#/components/hooks/useHeaderOffset'
|
||||
import {EditBig_Stroke2_Corner2_Rounded as EditBigIcon} from '#/components/icons/EditBig'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
import {app} from '#/lexicons'
|
||||
@@ -64,11 +57,8 @@ export function FeedPage({
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const {hasSession} = useSession()
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp<AllNavigatorParams>>()
|
||||
const queryClient = useQueryClient()
|
||||
const {openComposer} = useOpenComposer()
|
||||
const [isScrolledDown, setIsScrolledDown] = useState(false)
|
||||
const headerOffset = useHeaderOffset()
|
||||
const feedFeedback = useFeedFeedback(feedInfo, hasSession)
|
||||
const scrollElRef = useRef<ListMethods>(null)
|
||||
@@ -81,7 +71,6 @@ export function FeedPage({
|
||||
const _isVideoFeed = isBskyVideoFeed || feedIsVideoMode
|
||||
return IS_NATIVE && _isVideoFeed
|
||||
}, [feedInfo])
|
||||
const t = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
if (isPageFocused) {
|
||||
@@ -120,21 +109,6 @@ export function FeedPage({
|
||||
return listenSoftReset(onSoftReset)
|
||||
}, [onSoftReset, isPageFocused])
|
||||
|
||||
const onPressCompose = useCallback(() => {
|
||||
openComposer({logContext: 'Fab'})
|
||||
}, [openComposer])
|
||||
|
||||
const onPressLoadLatest = useCallback(() => {
|
||||
scrollToTop()
|
||||
truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
|
||||
setHasNew(false)
|
||||
ax.metric('feed:refresh', {
|
||||
feedType: feed.split('|')[0],
|
||||
feedUrl: feed,
|
||||
reason: 'load-latest',
|
||||
})
|
||||
}, [ax, scrollToTop, feed, queryClient])
|
||||
|
||||
const shouldPrefetch = IS_NATIVE && isPageAdjacent
|
||||
const isDiscoverFeed = feedInfo.uri === DISCOVER_FEED_URI
|
||||
return (
|
||||
@@ -152,7 +126,6 @@ export function FeedPage({
|
||||
pollInterval={POLL_FREQ}
|
||||
disablePoll={hasNew || !isPageFocused}
|
||||
scrollElRef={scrollElRef}
|
||||
onScrolledDownChange={setIsScrolledDown}
|
||||
onHasNew={setHasNew}
|
||||
renderEmptyState={renderEmptyState}
|
||||
renderEndOfFeed={renderEndOfFeed}
|
||||
@@ -162,24 +135,6 @@ export function FeedPage({
|
||||
/>
|
||||
</FeedFeedbackProvider>
|
||||
</MainScrollProvider>
|
||||
{(isScrolledDown || hasNew) && (
|
||||
<LoadLatestBtn
|
||||
onPress={onPressLoadLatest}
|
||||
label={_(msg`Load new posts`)}
|
||||
showIndicator={hasNew}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasSession && (
|
||||
<FAB
|
||||
testID="composeFAB"
|
||||
onPress={onPressCompose}
|
||||
icon={<EditBigIcon size="lg" fill={t.palette.white} />}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg({message: `New post`, context: 'action'}))}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function ListMembers({
|
||||
list: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
scrollElRef?: ListRef
|
||||
onScrolledDownChange: (isScrolledDown: boolean) => void
|
||||
onScrolledDownChange?: (isScrolledDown: boolean) => void
|
||||
onPressTryAgain?: () => void
|
||||
renderHeader: () => React.ReactElement
|
||||
renderEmptyState: () => React.ReactElement
|
||||
|
||||
@@ -75,7 +75,6 @@ import {
|
||||
} from '#/features/liveNow'
|
||||
import {app} from '#/lexicons'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import {ComposerPrompt} from '../feeds/ComposerPrompt'
|
||||
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
|
||||
import {FeedShutdownMsg} from './FeedShutdownMsg'
|
||||
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
|
||||
@@ -162,10 +161,6 @@ type FeedRow =
|
||||
type: 'ageAssuranceBanner'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'composerPrompt'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'liveEventFeedsAndTrendingBanner'
|
||||
key: string
|
||||
@@ -558,17 +553,6 @@ let PostFeed = ({
|
||||
type: 'liveEventFeedsAndTrendingBanner',
|
||||
key: 'liveEventFeedsAndTrendingBanner-' + sliceIndex,
|
||||
})
|
||||
// Show composer prompt for Discover and Following feeds
|
||||
if (
|
||||
hasSession &&
|
||||
(feedUriOrActorDid === DISCOVER_FEED_URI ||
|
||||
feed === 'following')
|
||||
) {
|
||||
arr.push({
|
||||
type: 'composerPrompt',
|
||||
key: 'composerPrompt-' + sliceIndex,
|
||||
})
|
||||
}
|
||||
} else if (sliceIndex === trendingIndices.topics) {
|
||||
arr.push({
|
||||
type: 'interstitialFeedTrendingTopics',
|
||||
@@ -588,16 +572,6 @@ let PostFeed = ({
|
||||
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
|
||||
})
|
||||
}
|
||||
} else if (feedKind === 'following') {
|
||||
if (sliceIndex === 0) {
|
||||
// Show composer prompt for Following feed
|
||||
if (hasSession) {
|
||||
arr.push({
|
||||
type: 'composerPrompt',
|
||||
key: 'composerPrompt-' + sliceIndex,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (feedKind === 'profile') {
|
||||
if (sliceIndex === 5) {
|
||||
arr.push({
|
||||
@@ -854,8 +828,6 @@ let PostFeed = ({
|
||||
)
|
||||
} else if (row.type === 'liveEventFeedsAndTrendingBanner') {
|
||||
return <DiscoverFeedLiveEventFeedsAndTrendingBanner />
|
||||
} else if (row.type === 'composerPrompt') {
|
||||
return <ComposerPrompt />
|
||||
} else if (row.type === 'interstitialTrendingVideos') {
|
||||
return <TrendingVideosInterstitial />
|
||||
} else if (row.type === 'fallbackMarker') {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export {FABInner as FAB} from './FABInner'
|
||||
@@ -1,14 +0,0 @@
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {useBreakpoints} from '#/alf'
|
||||
import {FABInner, type FABProps} from './FABInner'
|
||||
|
||||
export const FAB = (props: FABProps) => {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
if (!gtMobile) {
|
||||
return <FABInner {...props} />
|
||||
}
|
||||
|
||||
return <View />
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import {type ComponentProps, type JSX} from 'react'
|
||||
import {
|
||||
type GestureResponderEvent,
|
||||
type Pressable,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
|
||||
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {atoms as a, ios, useBreakpoints, useTheme} from '#/alf'
|
||||
import {IS_WEB} from '#/env'
|
||||
|
||||
export interface FABProps extends ComponentProps<typeof Pressable> {
|
||||
testID?: string
|
||||
icon: JSX.Element
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
export function FABInner({testID, icon, onPress, style, ...props}: FABProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const t = useTheme()
|
||||
const playHaptic = useHaptics()
|
||||
const fabMinimalShellTransform = useMinimalShellFabTransform()
|
||||
|
||||
const size = gtMobile ? styles.sizeLarge : styles.sizeRegular
|
||||
|
||||
const tabletSpacing = gtMobile
|
||||
? {right: 50, bottom: 50}
|
||||
: {right: 24, bottom: clamp(insets.bottom, 15, 60) + 15}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.outer,
|
||||
size,
|
||||
tabletSpacing,
|
||||
!gtMobile && fabMinimalShellTransform,
|
||||
]}>
|
||||
<PressableScale
|
||||
testID={testID}
|
||||
onPressIn={ios(() => playHaptic('Light'))}
|
||||
onPress={evt => {
|
||||
onPress?.(evt)
|
||||
playHaptic('Light')
|
||||
}}
|
||||
onLongPress={ios((evt: GestureResponderEvent) => {
|
||||
onPress?.(evt)
|
||||
playHaptic('Heavy')
|
||||
})}
|
||||
targetScale={0.9}
|
||||
style={[
|
||||
a.rounded_full,
|
||||
size,
|
||||
{backgroundColor: t.palette.primary_500},
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
style,
|
||||
]}
|
||||
{...props}>
|
||||
{icon}
|
||||
</PressableScale>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
sizeRegular: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 30,
|
||||
},
|
||||
sizeLarge: {
|
||||
width: 70,
|
||||
height: 70,
|
||||
borderRadius: 35,
|
||||
},
|
||||
outer: {
|
||||
// @ts-expect-error web-only
|
||||
position: IS_WEB ? 'fixed' : 'absolute',
|
||||
zIndex: 1,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
})
|
||||
@@ -1,113 +0,0 @@
|
||||
import {StyleSheet} from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {useMediaQuery} from 'react-responsive'
|
||||
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||
import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useLayoutBreakpoints, useTheme, web} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {ArrowTop_Stroke2_Corner0_Rounded as ArrowIcon} from '#/components/icons/Arrow'
|
||||
import {CENTER_COLUMN_OFFSET} from '#/components/Layout'
|
||||
import {SubtleHover} from '#/components/SubtleHover'
|
||||
|
||||
export function LoadLatestBtn({
|
||||
onPress,
|
||||
label,
|
||||
showIndicator,
|
||||
}: {
|
||||
onPress: () => void
|
||||
label: string
|
||||
showIndicator: boolean
|
||||
}) {
|
||||
const {hasSession} = useSession()
|
||||
const {isDesktop, isTablet, isMobile, isTabletOrMobile} = useWebMediaQueries()
|
||||
const {centerColumnOffset} = useLayoutBreakpoints()
|
||||
const fabMinimalShellTransform = useMinimalShellFabTransform()
|
||||
const insets = useSafeAreaInsets()
|
||||
const t = useTheme()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
|
||||
// move button inline if it starts overlapping the left nav
|
||||
const isTallViewport = useMediaQuery({minHeight: 700})
|
||||
|
||||
// Adjust height of the fab if we have a session only on mobile web. If we don't have a session, we want to adjust
|
||||
// it on both tablet and mobile since we are showing the bottom bar (see createNativeStackNavigatorWithAuth)
|
||||
const showBottomBar = hasSession ? isMobile : isTabletOrMobile
|
||||
|
||||
const bottomPosition = isTablet
|
||||
? {bottom: 50}
|
||||
: {bottom: clamp(insets.bottom, 15, 60) + 15}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
testID="loadLatestBtn"
|
||||
style={[
|
||||
a.fixed,
|
||||
a.z_20,
|
||||
{left: 18},
|
||||
isDesktop &&
|
||||
(isTallViewport
|
||||
? styles.loadLatestOutOfLine
|
||||
: styles.loadLatestInline),
|
||||
isTablet &&
|
||||
(centerColumnOffset
|
||||
? styles.loadLatestInlineOffset
|
||||
: styles.loadLatestInline),
|
||||
bottomPosition,
|
||||
showBottomBar && fabMinimalShellTransform,
|
||||
]}>
|
||||
<PressableScale
|
||||
style={[
|
||||
{
|
||||
width: 42,
|
||||
height: 42,
|
||||
},
|
||||
a.rounded_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
showIndicator ? {backgroundColor: t.palette.primary_50} : t.atoms.bg,
|
||||
]}
|
||||
onPress={onPress}
|
||||
hitSlop={HITSLOP_20}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint=""
|
||||
targetScale={0.9}
|
||||
onPointerEnter={onHoverIn}
|
||||
onPointerLeave={onHoverOut}>
|
||||
<SubtleHover hover={hovered} style={[a.rounded_full]} />
|
||||
<ArrowIcon
|
||||
size="md"
|
||||
style={[
|
||||
a.z_10,
|
||||
showIndicator
|
||||
? {color: t.palette.primary_500}
|
||||
: t.atoms.text_contrast_medium,
|
||||
]}
|
||||
/>
|
||||
</PressableScale>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loadLatestInline: {
|
||||
left: web('calc(50vw - 282px)'),
|
||||
},
|
||||
loadLatestInlineOffset: {
|
||||
left: web(`calc(50vw - 282px + ${CENTER_COLUMN_OFFSET}px)`),
|
||||
},
|
||||
loadLatestOutOfLine: {
|
||||
left: web('calc(50vw - 382px)'),
|
||||
},
|
||||
})
|
||||
@@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
} from '#/state/queries/feed'
|
||||
import {useSession} from '#/state/session'
|
||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||
import {FAB} from '#/view/com/util/fab/FAB'
|
||||
import {List, type ListMethods} from '#/view/com/util/List'
|
||||
import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
@@ -35,7 +33,6 @@ import * as FeedCard from '#/components/FeedCard'
|
||||
import {SearchInput} from '#/components/forms/SearchInput'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||
import {EditBig_Stroke2_Corner2_Rounded as EditBigIcon} from '#/components/icons/EditBig'
|
||||
import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
|
||||
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass'
|
||||
import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle'
|
||||
@@ -44,6 +41,7 @@ import * as Layout from '#/components/Layout'
|
||||
import {Link} from '#/components/Link'
|
||||
import * as ListCard from '#/components/ListCard'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {NewPostComposePrompt} from '#/features/composePrompt'
|
||||
import {type app} from '#/lexicons'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'>
|
||||
@@ -104,8 +102,6 @@ type FlatlistSlice =
|
||||
|
||||
export function FeedsScreen(_props: Props) {
|
||||
const pal = usePalette('default')
|
||||
const t = useTheme()
|
||||
const {openComposer} = useOpenComposer()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const [query, setQuery] = useState('')
|
||||
const [isPTR, setIsPTR] = useState(false)
|
||||
@@ -143,9 +139,6 @@ export function FeedsScreen(_props: Props) {
|
||||
() => debounce(q => search(q), 500), // debounce for 500ms
|
||||
[search],
|
||||
)
|
||||
const onPressCompose = useCallback(() => {
|
||||
openComposer({logContext: 'Fab'})
|
||||
}, [openComposer])
|
||||
const onChangeQuery = useCallback(
|
||||
(text: string) => {
|
||||
setQuery(text)
|
||||
@@ -536,16 +529,7 @@ export function FeedsScreen(_props: Props) {
|
||||
/>
|
||||
</Layout.Center>
|
||||
|
||||
{hasSession && (
|
||||
<FAB
|
||||
testID="composeFAB"
|
||||
onPress={onPressCompose}
|
||||
icon={<EditBigIcon size="lg" fill={t.palette.white} />}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New post`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
)}
|
||||
{hasSession && <NewPostComposePrompt />}
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
+39
-35
@@ -49,6 +49,7 @@ import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
|
||||
import {NewPostComposePrompt} from '#/features/composePrompt'
|
||||
import {useDemoMode} from '#/storage/hooks/demo-mode'
|
||||
|
||||
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home' | 'Start'>
|
||||
@@ -311,50 +312,53 @@ function HomeScreenReady({
|
||||
}
|
||||
|
||||
return hasSession ? (
|
||||
<Pager
|
||||
key={allFeeds.join(',')}
|
||||
ref={pagerRef}
|
||||
testID="homeScreen"
|
||||
initialPage={selectedIndex}
|
||||
onPageSelected={onPageSelected}
|
||||
onPageScrollStateChanged={onPageScrollStateChanged}
|
||||
renderTabBar={renderTabBar}>
|
||||
{pinnedFeedInfos.length ? (
|
||||
pinnedFeedInfos.map((feedInfo, index) => {
|
||||
const feed = feedInfo.feedDescriptor
|
||||
if (feed === 'following') {
|
||||
<>
|
||||
<NewPostComposePrompt />
|
||||
<Pager
|
||||
key={allFeeds.join(',')}
|
||||
ref={pagerRef}
|
||||
testID="homeScreen"
|
||||
initialPage={selectedIndex}
|
||||
onPageSelected={onPageSelected}
|
||||
onPageScrollStateChanged={onPageScrollStateChanged}
|
||||
renderTabBar={renderTabBar}>
|
||||
{pinnedFeedInfos.length ? (
|
||||
pinnedFeedInfos.map((feedInfo, index) => {
|
||||
const feed = feedInfo.feedDescriptor
|
||||
if (feed === 'following') {
|
||||
return (
|
||||
<FeedPage
|
||||
key={feed}
|
||||
testID="followingFeedPage"
|
||||
isPageFocused={maybeSelectedFeed === feed}
|
||||
isPageAdjacent={Math.abs(selectedIndex - index) === 1}
|
||||
feed={feed}
|
||||
feedParams={homeFeedParams}
|
||||
renderEmptyState={renderFollowingEmptyState}
|
||||
renderEndOfFeed={FollowingEndOfFeed}
|
||||
feedInfo={feedInfo}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const savedFeedConfig = feedInfo.savedFeed
|
||||
return (
|
||||
<FeedPage
|
||||
key={feed}
|
||||
testID="followingFeedPage"
|
||||
testID="customFeedPage"
|
||||
isPageFocused={maybeSelectedFeed === feed}
|
||||
isPageAdjacent={Math.abs(selectedIndex - index) === 1}
|
||||
feed={feed}
|
||||
feedParams={homeFeedParams}
|
||||
renderEmptyState={renderFollowingEmptyState}
|
||||
renderEndOfFeed={FollowingEndOfFeed}
|
||||
renderEmptyState={renderCustomFeedEmptyState}
|
||||
savedFeedConfig={savedFeedConfig}
|
||||
feedInfo={feedInfo}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const savedFeedConfig = feedInfo.savedFeed
|
||||
return (
|
||||
<FeedPage
|
||||
key={feed}
|
||||
testID="customFeedPage"
|
||||
isPageFocused={maybeSelectedFeed === feed}
|
||||
isPageAdjacent={Math.abs(selectedIndex - index) === 1}
|
||||
feed={feed}
|
||||
renderEmptyState={renderCustomFeedEmptyState}
|
||||
savedFeedConfig={savedFeedConfig}
|
||||
feedInfo={feedInfo}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<NoFeedsPinned preferences={preferences} />
|
||||
)}
|
||||
</Pager>
|
||||
})
|
||||
) : (
|
||||
<NoFeedsPinned preferences={preferences} />
|
||||
)}
|
||||
</Pager>
|
||||
</>
|
||||
) : (
|
||||
<Pager
|
||||
testID="homeScreen"
|
||||
|
||||
@@ -7,7 +7,6 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {
|
||||
type NativeStackScreenProps,
|
||||
type NotificationsTabNavigatorParams,
|
||||
@@ -24,18 +23,16 @@ import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {NotificationFeed} from '#/view/com/notifications/NotificationFeed'
|
||||
import {Pager} from '#/view/com/pager/Pager'
|
||||
import {TabBar} from '#/view/com/pager/TabBar'
|
||||
import {FAB} from '#/view/com/util/fab/FAB'
|
||||
import {type ListMethods} from '#/view/com/util/List'
|
||||
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {ButtonIcon} from '#/components/Button'
|
||||
import {EditBig_Stroke2_Corner2_Rounded as EditBigIcon} from '#/components/icons/EditBig'
|
||||
import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/components/icons/SettingsGear2'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {InlineLinkText, Link} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
import {NewPostComposePrompt} from '#/features/composePrompt'
|
||||
|
||||
// We don't currently persist this across reloads since
|
||||
// you gotta visit All to clear the badge anyway.
|
||||
@@ -48,8 +45,6 @@ type Props = NativeStackScreenProps<
|
||||
>
|
||||
export function NotificationsScreen({}: Props) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {openComposer} = useOpenComposer()
|
||||
const unreadNotifs = useUnreadNotifications()
|
||||
const hasNew = !!unreadNotifs
|
||||
const {checkUnread: checkUnreadAll} = useUnreadNotificationsApi()
|
||||
@@ -157,14 +152,7 @@ export function NotificationsScreen({}: Props) {
|
||||
<View key={i}>{section.component}</View>
|
||||
))}
|
||||
</Pager>
|
||||
<FAB
|
||||
testID="composeFAB"
|
||||
onPress={() => openComposer({logContext: 'Fab'})}
|
||||
icon={<EditBigIcon size="lg" fill={t.palette.white} />}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New post`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
<NewPostComposePrompt />
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
@@ -184,7 +172,6 @@ function NotificationsTab({
|
||||
checkUnread: ({invalidate}: {invalidate: boolean}) => Promise<void>
|
||||
setIsLoadingLatest: (v: boolean) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const [isScrolledDown, setIsScrolledDown] = useState(false)
|
||||
const scrollElRef = useRef<ListMethods>(null)
|
||||
const queryClient = useQueryClient()
|
||||
@@ -265,13 +252,6 @@ function NotificationsTab({
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{(isScrolledDown || hasNew) && (
|
||||
<LoadLatestBtn
|
||||
onPress={onPressLoadLatest}
|
||||
label={_(msg`Load new notifications`)}
|
||||
showIndicator={hasNew}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,14 +35,12 @@ import {ProfileLists} from '#/view/com/lists/ProfileLists'
|
||||
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
|
||||
import {type PostFeedRef} from '#/view/com/posts/PostFeed'
|
||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||
import {FAB} from '#/view/com/util/fab/FAB'
|
||||
import {type ListRef} from '#/view/com/util/List'
|
||||
import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header'
|
||||
import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
|
||||
import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Circle_And_Square_Stroke1_Corner0_Rounded_Filled as CircleAndSquareIcon} from '#/components/icons/CircleAndSquare'
|
||||
import {EditBig_Stroke2_Corner2_Rounded as EditBigIcon} from '#/components/icons/EditBig'
|
||||
import {Heart2_Stroke1_Corner0_Rounded as HeartIcon} from '#/components/icons/Heart2'
|
||||
import {Image_Stroke1_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
|
||||
import {Message_Stroke1_Corner0_Rounded_Filled as MessageIcon} from '#/components/icons/Message'
|
||||
@@ -50,6 +48,7 @@ import {VideoClip_Stroke1_Corner0_Rounded as VideoIcon} from '#/components/icons
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {ScreenHider} from '#/components/moderation/ScreenHider'
|
||||
import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks'
|
||||
import {NewPostComposePrompt} from '#/features/composePrompt'
|
||||
import {type app} from '#/lexicons'
|
||||
import {navigate} from '#/Navigation'
|
||||
|
||||
@@ -172,7 +171,6 @@ function ProfileScreenLoaded({
|
||||
hideBackButton: boolean
|
||||
isPlaceholderProfile: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const {openComposer} = useOpenComposer()
|
||||
@@ -326,14 +324,11 @@ function ProfileScreenLoaded({
|
||||
// events
|
||||
// =
|
||||
|
||||
const onPressCompose = () => {
|
||||
const mention =
|
||||
profile.handle === currentAccount?.handle ||
|
||||
isInvalidHandle(profile.handle)
|
||||
? undefined
|
||||
: profile.handle
|
||||
openComposer({mention, logContext: 'ProfileFeed'})
|
||||
}
|
||||
// the compose pill pre-fills a mention when viewing someone else's profile
|
||||
const composeMention =
|
||||
profile.handle === currentAccount?.handle || isInvalidHandle(profile.handle)
|
||||
? undefined
|
||||
: profile.handle
|
||||
|
||||
const onPageSelected = (i: number) => {
|
||||
setCurrentPage(i)
|
||||
@@ -598,13 +593,9 @@ function ProfileScreenLoaded({
|
||||
: null}
|
||||
</PagerWithHeader>
|
||||
{hasSession && (
|
||||
<FAB
|
||||
testID="composeFAB"
|
||||
onPress={onPressCompose}
|
||||
icon={<EditBigIcon size="lg" fill={t.palette.white} />}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New post`)}
|
||||
accessibilityHint=""
|
||||
<NewPostComposePrompt
|
||||
mention={composeMention}
|
||||
logContext="ProfileFeed"
|
||||
/>
|
||||
)}
|
||||
</ScreenHider>
|
||||
|
||||
@@ -56,6 +56,7 @@ import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {ComposePromptPill} from '#/features/composePrompt'
|
||||
import {useActorStatus} from '#/features/liveNow'
|
||||
import {useDemoMode} from '#/storage/hooks/demo-mode'
|
||||
import {styles} from './BottomBarStyles'
|
||||
@@ -161,6 +162,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
<>
|
||||
<SwitchAccountDialog control={accountSwitchControl} />
|
||||
<MessagesTabMenu control={messagesMenuControl} />
|
||||
{hasSession && <ComposePromptPill />}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.bottomBar,
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {ComposePromptPill} from '#/features/composePrompt'
|
||||
import {styles} from './BottomBarStyles'
|
||||
|
||||
type NavItemValue = 'home' | 'search' | 'chat' | 'notifications' | 'profile'
|
||||
@@ -85,6 +86,7 @@ export function BottomBarWeb() {
|
||||
return (
|
||||
<>
|
||||
<SwitchAccountDialog control={accountSwitchControl} />
|
||||
{hasSession && <ComposePromptPill />}
|
||||
|
||||
<Animated.View
|
||||
role="navigation"
|
||||
|
||||
Reference in New Issue
Block a user