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:
Claude
2026-09-07 21:25:30 +00:00
parent 6671bd9f42
commit 6d455c9098
34 changed files with 860 additions and 875 deletions
+2 -2
View File
@@ -14,9 +14,9 @@ appId: xyz.blueskyweb.app
# Post an image with the porn label
- assertVisible:
id: "composeFAB"
id: "composePromptPill"
- tapOn:
id: "composeFAB"
id: "composePromptPill"
- inputText: "Post with an image"
- tapOn:
id: "openMediaBtn"
+13 -13
View File
@@ -13,16 +13,16 @@ appId: xyz.blueskyweb.app
id: "viewHeaderHomeFeedPrefsBtn"
- assertVisible:
id: "composeFAB"
id: "composePromptPill"
- tapOn:
id: "composeFAB"
id: "composePromptPill"
- inputText: "Post text only"
- tapOn:
id: "composerPublishBtn"
- assertVisible:
id: "composeFAB"
id: "composePromptPill"
- tapOn:
id: "composeFAB"
id: "composePromptPill"
- inputText: "Post with an image"
- tapOn:
id: "openMediaBtn"
@@ -32,14 +32,14 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "composerPublishBtn"
- assertVisible:
id: "composeFAB"
id: "composePromptPill"
- tapOn:
id: "composeFAB"
id: "composePromptPill"
- inputText: "Post with a https://example.com link card"
- tapOn:
id: "composerPublishBtn"
- assertVisible:
id: "composeFAB"
id: "composePromptPill"
- tapOn:
id: "e2eRefreshHome"
- tapOn:
@@ -54,7 +54,7 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "composerPublishBtn"
- assertVisible:
id: "composeFAB"
id: "composePromptPill"
- tapOn:
id: "replyBtn"
# Wait for the composer to fully open before typing.
@@ -71,7 +71,7 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "composerPublishBtn"
- assertVisible:
id: "composeFAB"
id: "composePromptPill"
- tapOn:
id: "replyBtn"
# Wait for the composer to fully open before typing.
@@ -83,7 +83,7 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "composerPublishBtn"
- assertVisible:
id: "composeFAB"
id: "composePromptPill"
- tapOn:
id: "repostBtn"
- tapOn:
@@ -92,7 +92,7 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "composerPublishBtn"
- assertVisible:
id: "composeFAB"
id: "composePromptPill"
- tapOn:
id: "repostBtn"
- tapOn:
@@ -106,7 +106,7 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "composerPublishBtn"
- assertVisible:
id: "composeFAB"
id: "composePromptPill"
- tapOn:
id: "repostBtn"
- tapOn:
@@ -115,4 +115,4 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "composerPublishBtn"
- assertVisible:
id: "composeFAB"
id: "composePromptPill"
+4 -4
View File
@@ -14,9 +14,9 @@ appId: xyz.blueskyweb.app
visible:
id: "viewHeaderHomeFeedPrefsBtn"
- assertVisible:
id: "composeFAB"
id: "composePromptPill"
- tapOn:
id: "composeFAB"
id: "composePromptPill"
- inputText: "Test thread"
- tapOn:
id: "composerPublishBtn"
@@ -24,7 +24,7 @@ appId: xyz.blueskyweb.app
# out. Without a settle guard the next action can race the closing composer.
- extendedWaitUntil:
visible:
id: "composeFAB"
id: "composePromptPill"
timeout: 10000
# Login, reply to the thread, and log out
@@ -48,7 +48,7 @@ appId: xyz.blueskyweb.app
# Wait for the composer to close before signing out.
- extendedWaitUntil:
visible:
id: "composeFAB"
id: "composePromptPill"
timeout: 10000
# Login, confirm notification exists, mute thread, and log out
+1 -9
View File
@@ -1289,17 +1289,9 @@
"count": 2
}
},
"src/view/com/feeds/ComposerPrompt.tsx": {
"typescript/no-explicit-any": {
"count": 2
},
"typescript/no-floating-promises": {
"count": 2
}
},
"src/view/com/feeds/FeedPage.tsx": {
"typescript/no-floating-promises": {
"count": 2
"count": 1
}
},
"src/view/com/feeds/ProfileFeedgens.tsx": {
+15 -12
View File
@@ -77,6 +77,7 @@ import {
setupDeviceId,
} from '#/analytics'
import {IS_ANDROID, IS_IOS} from '#/env'
import {Provider as ComposePromptProvider} from '#/features/composePrompt'
import {
prefetchLiveEvents,
Provider as LiveEventsProvider,
@@ -172,18 +173,20 @@ function InnerApp() {
<ServiceAccountManager>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={a.h_full}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TranslateOnDeviceProvider>
<TestCtrls />
<Shell />
<ToastOutlet />
</TranslateOnDeviceProvider>
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
<ComposePromptProvider>
<GestureHandlerRootView
style={a.h_full}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TranslateOnDeviceProvider>
<TestCtrls />
<Shell />
<ToastOutlet />
</TranslateOnDeviceProvider>
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</ComposePromptProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceAccountManager>
+11 -8
View File
@@ -69,6 +69,7 @@ import {
features,
setupDeviceId,
} from '#/analytics'
import {Provider as ComposePromptProvider} from '#/features/composePrompt'
import {
prefetchLiveEvents,
Provider as LiveEventsProvider,
@@ -153,14 +154,16 @@ function InnerApp() {
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<TranslateOnDeviceProvider>
<HotkeysProvider>
<Shell />
<ToastOutlet />
</HotkeysProvider>
</TranslateOnDeviceProvider>
</IntentDialogProvider>
<ComposePromptProvider>
<IntentDialogProvider>
<TranslateOnDeviceProvider>
<HotkeysProvider>
<Shell />
<ToastOutlet />
</HotkeysProvider>
</TranslateOnDeviceProvider>
</IntentDialogProvider>
</ComposePromptProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceConfigProvider>
+5 -31
View File
@@ -1,19 +1,14 @@
import {useCallback} from 'react'
import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react/macro'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {isNetworkError} from '#/lib/strings/errors'
import {matchXrpcError} from '#/lib/xrpc-error'
import {logger} from '#/logger'
import {useCreateGroupChat} from '#/state/queries/messages/create-group-chat'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
import {FAB} from '#/view/com/util/fab/FAB'
import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
import {InitiateChatFlow} from '#/components/dms/InitiateChatFlow'
import {MessagePlus_Stroke2_Corner0_Rounded as NewChatIcon} from '#/components/icons/Message'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {chat} from '#/lexicons'
@@ -29,12 +24,8 @@ export function NewChat({
startInGroupChat?: boolean
onClose?: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const ax = useAnalytics()
const requireEmailVerification = useRequireEmailVerification()
const {data: chatStatus} = useChatActorStatusQuery()
const chatDisabled = !!chatStatus?.chatDisabled
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
@@ -140,29 +131,12 @@ export function NewChat({
[control, onNewChat],
)
const onPress = useCallback(() => {
control.open()
}, [control])
const wrappedOnPress = requireEmailVerification(onPress, {
instructions: [
<Trans key="new-chat">
Before you can message another user, you must first verify your email.
</Trans>,
],
})
/*
* The new chat FAB that used to open this dialog was removed along with
* the other FABs; a replacement entry point still needs to be designed.
*/
return (
<>
{!chatDisabled && (
<FAB
testID="newChatFAB"
onPress={wrappedOnPress}
icon={<NewChatIcon size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={l`New chat`}
accessibilityHint=""
/>
)}
<Dialog.Outer
control={control}
testID="newChatDialog"
@@ -0,0 +1,264 @@
import {useState} from 'react'
import {useWindowDimensions} from 'react-native'
import Animated, {
interpolate,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {scheduleOnRN} from 'react-native-worklets'
import {LinearGradient} from 'expo-linear-gradient'
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.
*/
const SIDE_MARGIN_UP = tokens.space.md
/**
* Gap between the pill and the top of the bottom bar.
*/
const GAP_ABOVE_BAR = tokens.space.sm
/**
* Extra room above the pill for the gradient to fade in over.
*/
const GRADIENT_LEAD = tokens.space._4xl
/**
* How much the pill shrinks once it has dropped into the bar's space.
*/
const DOWN_SCALE = 0.92
/**
* 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 {width: windowWidth} = useWindowDimensions()
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)
const pillHeight = useSharedValue(AVATAR_SIZE + tokens.space.sm * 2)
/*
* 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)
}
},
)
/*
* In the down state the pill is inset from the left, right and bottom
* edges by the same amount, so that its corners are concentric with the
* device bevels. The inner pill is scaled about its centre, so the outer
* padding and offset are solved for the visual margins after scaling.
*/
const downMargin = Math.max(insets.bottom, tokens.space.lg)
const wrapperStyle = useAnimatedStyle(() => {
const mode = footerMode.get()
const scale = interpolate(mode, [0, 1], [1, DOWN_SCALE])
const sideMargin = interpolate(mode, [0, 1], [SIDE_MARGIN_UP, downMargin])
const paddingHorizontal =
(sideMargin - ((1 - scale) * windowWidth) / 2) / scale
const upBottom = footerHeight.get() + GAP_ABOVE_BAR
const bottomMargin = interpolate(mode, [0, 1], [upBottom, downMargin])
const translateY =
upBottom -
bottomMargin +
((1 - scale) * pillHeight.get()) / 2 +
// a small drop while fading, like a sheet dismissing
(1 - visibility.get()) * tokens.space.md
return {
bottom: footerHeight.get(),
paddingHorizontal,
transform: [{translateY}],
}
})
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,
transform: [
{scale: interpolate(footerMode.get(), [0, 1], [1, DOWN_SCALE])},
],
}
})
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,
{paddingTop: GRADIENT_LEAD, paddingBottom: GAP_ABOVE_BAR},
wrapperStyle,
]}>
<Animated.View
pointerEvents="none"
style={[a.absolute, a.inset_0, fadeStyle]}>
<LinearGradient
key={t.name} // android does not update when you change the colors. sigh.
start={[0.5, 0]}
end={[0.5, 1]}
colors={[
utils.alpha(t.atoms.bg.backgroundColor, 0),
utils.alpha(t.atoms.bg.backgroundColor, 0.75),
]}
locations={[0, 0.65]}
style={[a.absolute, a.inset_0]}
/>
</Animated.View>
<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')
})}
onLayout={e => pillHeight.set(e.nativeEvent.layout.height)}>
<GlassView
isInteractive
glassEffectStyle={{
style: glassVisible ? 'regular' : 'none',
animate: true,
}}
tintColor={t.palette.contrast_50}
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>
)
}
+256
View File
@@ -0,0 +1,256 @@
import {
createContext,
useContext,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
} from 'react'
import {
clamp,
type DerivedValue,
type SharedValue,
useAnimatedReaction,
useDerivedValue,
useSharedValue,
withSpring,
} from 'react-native-reanimated'
import {scheduleOnRN} from 'react-native-worklets'
import {SHELL_SPRING_CONFIG} from '#/lib/custom-animations/springs'
import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
import {useScreenPresence} from '#/lib/hooks/useScreenPresence'
import {type ComposerOpts} from '#/state/shell/composer'
export type ComposePromptOpenOptions = Pick<
ComposerOpts,
'imageUris' | 'openGallery'
>
export type ComposePromptConfig = {
/**
* Placeholder text shown in the pill, e.g. "What's up?"
*/
label: string
/**
* Accessibility label and hint for the pill's main press target.
*/
accessibilityLabel: string
accessibilityHint: string
/**
* Opens the composer. Media picked from the pill's camera and gallery
* buttons is passed through so the screen can merge it with its own
* options (reply target, mention, log context).
*/
open: (options?: ComposePromptOpenOptions) => void
}
type Entry = {
id: number
/**
* How present the registering screen is, 0..1. Owned by the registering
* hook so that it can be sprung to 0 on unregister.
*/
presence: SharedValue<number>
config: ComposePromptConfig
}
type StateContext = {
/**
* How visible the pill should be, 0 (hidden) to 1 (shown): the summed
* presence of every screen that shows it, clamped.
*/
visibility: DerivedValue<number>
/**
* Config of the screen that is currently the most present, or undefined
* when no screen shows the pill.
*/
config: ComposePromptConfig | undefined
}
type ActionsContext = {
register: (
presence: SharedValue<number>,
config: ComposePromptConfig,
) => number
update: (id: number, config: ComposePromptConfig) => void
unregister: (id: number) => void
}
const stateContext = createContext<StateContext | null>(null)
stateContext.displayName = 'ComposePromptStateContext'
const actionsContext = createContext<ActionsContext | null>(null)
actionsContext.displayName = 'ComposePromptActionsContext'
let nextId = 0
export function Provider({children}: {children: React.ReactNode}) {
const [entries, setEntries] = useState<Entry[]>([])
const [activeId, setActiveId] = useState<number | null>(null)
const visibility = useDerivedValue(() => {
let sum = 0
for (const entry of entries) {
sum += entry.presence.get()
}
return clamp(sum, 0, 1)
}, [entries])
/*
* The pill shows the label of whichever screen is the most present, so
* that during a push or swipe-back the text switches at the midpoint of
* the transition rather than when the outgoing screen unmounts.
*/
const mostPresentId = useDerivedValue(() => {
let best: number | null = null
let bestPresence = 0
for (const entry of entries) {
const presence = entry.presence.get()
if (presence > bestPresence) {
best = entry.id
bestPresence = presence
}
}
return best
}, [entries])
useAnimatedReaction(
() => mostPresentId.get(),
(current, previous) => {
// keep the last label while everything fades out
if (current !== previous && current !== null) {
scheduleOnRN(setActiveId, current)
}
},
)
const actions = useMemo<ActionsContext>(
() => ({
register(presence, config) {
const id = nextId++
setEntries(prev => [...prev, {id, presence, config}])
return id
},
update(id, config) {
setEntries(prev =>
prev.map(entry => (entry.id === id ? {...entry, config} : entry)),
)
},
unregister(id) {
setEntries(prev => prev.filter(entry => entry.id !== id))
},
}),
[],
)
const config = (
entries.find(entry => entry.id === activeId) ?? entries.at(-1)
)?.config
const state = useMemo<StateContext>(
() => ({visibility, config}),
[visibility, config],
)
return (
<stateContext.Provider value={state}>
<actionsContext.Provider value={actions}>
{children}
</actionsContext.Provider>
</stateContext.Provider>
)
}
/**
* What the pill should render right now. Used by the bottom bars.
*/
export function useComposePromptState() {
const context = useContext(stateContext)
if (!context) {
throw new Error(
'useComposePromptState must be used within a ComposePromptProvider',
)
}
return context
}
function useComposePromptActions() {
const context = useContext(actionsContext)
if (!context) {
throw new Error(
'useComposePromptActions must be used within a ComposePromptProvider',
)
}
return context
}
/**
* Shows the compose pill in the bottom bar while the surrounding
* `Layout.Screen` is present, following its transition in and out frame by
* frame. Pass `null` to show nothing (e.g. when replies are disabled).
*/
export function useComposePromptForScreen(config: ComposePromptConfig | null) {
const {register, update, unregister} = useComposePromptActions()
const {presence} = useScreenPresence()
const contribution = useSharedValue(0)
const idRef = useRef<number | null>(null)
const enabled = config !== null
// the bar's top border would cut across the pill's gradient
useHideBottomBarBorderForScreen({enabled})
useAnimatedReaction(
() => presence.get(),
(current, previous) => {
if (enabled && current !== previous) {
contribution.set(current)
}
},
[enabled],
)
const getConfig = useEffectEvent(() => config)
useEffect(() => {
const initial = getConfig()
if (!initial) return
contribution.set(presence.get())
const id = register(contribution, initial)
idRef.current = id
return () => {
idRef.current = null
/*
* Fade out before removing so that a screen removed without a
* transition (or a config switched off) does not snap the pill away.
*/
contribution.set(
withSpring(0, SHELL_SPRING_CONFIG, finished => {
if (finished) {
scheduleOnRN(unregister, id)
}
}),
)
}
}, [enabled, register, unregister, contribution, presence])
const label = config?.label
const accessibilityLabel = config?.accessibilityLabel
const accessibilityHint = config?.accessibilityHint
const open = config?.open
useEffect(() => {
if (
idRef.current !== null &&
label &&
accessibilityLabel &&
accessibilityHint &&
open
) {
update(idRef.current, {
label,
accessibilityLabel,
accessibilityHint,
open,
})
}
}, [label, accessibilityLabel, accessibilityHint, open, update])
}
+50
View File
@@ -0,0 +1,50 @@
import {useLingui} from '@lingui/react/macro'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {type ComposerLogContext} from '#/state/shell/composer'
import {type ComposePromptConfig, useComposePromptForScreen} from './context'
export {ComposePromptPill} from './ComposePromptPill'
export {
type ComposePromptConfig,
type ComposePromptOpenOptions,
Provider,
useComposePromptForScreen,
useComposePromptState,
} from './context'
/**
* Shows the compose pill in the bottom bar while this screen is present.
* Render anywhere inside a `Layout.Screen`.
*/
export function ComposePrompt(config: ComposePromptConfig) {
useComposePromptForScreen(config)
return null
}
/**
* The common case: a pill that opens the composer for a new post. Replaces
* the old compose FAB, so it shares its analytics context by default.
*/
export function NewPostComposePrompt({
mention,
logContext = 'Fab',
}: {
/**
* Handle to pre-fill the post with, e.g. on someone else's profile.
*/
mention?: string
logContext?: ComposerLogContext
}) {
const {t: l} = useLingui()
const {openComposer} = useOpenComposer()
useComposePromptForScreen({
label: l`What's up?`,
accessibilityLabel: l`Compose new post`,
accessibilityHint: l`Opens the post composer`,
open: options => openComposer({...options, mention, logContext}),
})
return null
}
@@ -0,0 +1,122 @@
import {Keyboard} from 'react-native'
import {
useCameraPermission,
usePhotoLibraryPermission,
useVideoLibraryPermission,
} from '#/lib/hooks/usePermissions'
import {openCamera, openUnifiedPicker} from '#/lib/media/picker'
import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import {type ComposePromptOpenOptions} from './context'
/**
* Press handlers for the pill's camera and gallery buttons. Picks media
* first, then opens the composer with it via `open`.
*/
export function useComposePromptMedia(
open: ((options?: ComposePromptOpenOptions) => void) | undefined,
) {
const ax = useAnalytics()
const {requestCameraAccessIfNeeded} = useCameraPermission()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
const sheetWrapper = useSheetWrapper()
const onPressGallery = async () => {
if (!open) return
ax.metric('composerPrompt:gallery:press', {})
// On web, open the composer with the gallery picker auto-opening
if (!IS_NATIVE) {
open({openGallery: true})
return
}
try {
const [photoAccess, videoAccess] = await Promise.all([
requestPhotoAccessIfNeeded(),
requestVideoAccessIfNeeded(),
])
if (!photoAccess && !videoAccess) {
return
}
if (Keyboard.isVisible()) {
Keyboard.dismiss()
}
const {assets, canceled} = await sheetWrapper(
openUnifiedPicker({selectionCountRemaining: MAX_GALLERY_IMAGES}),
)
if (canceled) {
return
}
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) {
open({imageUris})
}
} catch (err: unknown) {
if (!String(err).toLowerCase().includes('cancel')) {
ax.logger.error('Error opening image picker', {error: err})
}
}
}
const onPressCamera = async () => {
if (!open) return
ax.metric('composerPrompt:camera:press', {})
try {
if (!(await requestCameraAccessIfNeeded())) {
return
}
if (IS_NATIVE && Keyboard.isVisible()) {
Keyboard.dismiss()
}
const image = await openCamera({
mediaTypes: 'images',
})
if (!image) {
return
}
/*
* Statement form rather than a ternary: React Compiler cannot lower a
* conditional expression inside a `try`.
*/
let imageUris
if (IS_NATIVE) {
imageUris = [
{
uri: image.path,
width: image.width,
height: image.height,
},
]
}
open({imageUris})
} catch (err: unknown) {
if (!String(err).toLowerCase().includes('cancel')) {
ax.logger.error('Error opening camera', {error: err})
}
}
}
return {onPressGallery, onPressCamera}
}
+8 -3
View File
@@ -36,7 +36,7 @@ function useHideBottomBarBorderSetter() {
* Hides the bottom bar's top border while the surrounding screen is present,
* fading it with the screen transition.
*/
export function useHideBottomBarBorderForScreen() {
export function useHideBottomBarBorderForScreen({enabled} = {enabled: true}) {
const register = useHideBottomBarBorderSetter()
const {presence} = useScreenPresence()
const contribution = useSharedValue(0)
@@ -44,13 +44,18 @@ export function useHideBottomBarBorderForScreen() {
useAnimatedReaction(
() => presence.get(),
(current, previous) => {
if (current !== previous) {
if (enabled && current !== previous) {
contribution.set(current)
}
},
[enabled],
)
useEffect(() => register(contribution), [register, contribution])
useEffect(() => {
if (!enabled) return
contribution.set(presence.get())
return register(contribution)
}, [enabled, register, contribution, presence])
}
/**
-17
View File
@@ -3,8 +3,6 @@ import {interpolate, useAnimatedStyle} from 'react-native-reanimated'
import {useMinimalShellMode} from '#/state/shell/minimal-mode'
import {useShellLayout} from '#/state/shell/shell-layout'
// Keep these separated so that we only pay for useAnimatedStyle that gets used.
export function useMinimalShellFooterTransform() {
const {footerMode} = useMinimalShellMode()
const {footerHeight} = useShellLayout()
@@ -28,18 +26,3 @@ export function useMinimalShellFooterTransform() {
return footerTransform
}
export function useMinimalShellFabTransform() {
const {footerMode} = useMinimalShellMode()
const fabTransform = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(footerMode.get(), [0, 1], [-44, 0]),
},
],
}
})
return fabTransform
}
+2 -26
View File
@@ -6,7 +6,6 @@ import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useQueryClient} from '@tanstack/react-query'
import {TRENDING_DID, TRENDING_HANDLE, VIDEO_FEED_URIS} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
import {type CommonNavigatorParams} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
@@ -29,15 +28,12 @@ import {useSession} from '#/state/session'
import {PostFeed} from '#/view/com/posts/PostFeed'
import {EmptyState} from '#/view/com/util/EmptyState'
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 {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {useTheme} from '#/alf'
import {EditBig_Stroke2_Corner2_Rounded as EditBigIcon} from '#/components/icons/EditBig'
import {HashtagWide_Stroke1_Corner0_Rounded as HashtagWideIcon} from '#/components/icons/Hashtag'
import * as Layout from '#/components/Layout'
import {IS_NATIVE} from '#/env'
import {NewPostComposePrompt} from '#/features/composePrompt'
import {app} from '#/lexicons'
import {
CustomFeedHeader,
@@ -132,16 +128,13 @@ export function CustomFeedScreenInner({
}) {
const {t: l} = useLingui()
const {hasSession} = useSession()
const {openComposer} = useOpenComposer()
const isScreenFocused = useIsFocused()
const t = useTheme()
useSetTitle(feedInfo?.displayName)
const feed = `feedgen|${feedInfo.uri}` as FeedDescriptor
const [hasNew, setHasNew] = useState(false)
const [isScrolledDown, setIsScrolledDown] = useState(false)
const queryClient = useQueryClient()
const feedFeedback = useFeedFeedback(feedInfo, hasSession)
const scrollElRef = useAnimatedRef() as ListRef
@@ -197,28 +190,11 @@ export function CustomFeedScreenInner({
disablePoll={hasNew}
onHasNew={setHasNew}
scrollElRef={scrollElRef}
onScrolledDownChange={setIsScrolledDown}
renderEmptyState={renderPostsEmpty}
isVideoFeed={isVideoFeed}
/>
</FeedFeedbackProvider>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onScrollToTop}
label={l`Load new posts`}
showIndicator={hasNew}
/>
)}
{hasSession && (
<FAB
testID="composeFAB"
onPress={() => openComposer({logContext: 'Fab'})}
icon={<EditBigIcon size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={l`New post`}
accessibilityHint=""
/>
)}
{hasSession && <NewPostComposePrompt />}
</>
)
}
@@ -6,7 +6,6 @@ import {Trans} from '@lingui/react/macro'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {useHaptics} from '#/lib/haptics'
import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
@@ -34,8 +33,6 @@ export function ThreadComposePrompt({
onOut: onHoverOut,
} = useInteractionState()
useHideBottomBarBorderForScreen()
return (
<View
style={[
+43 -48
View File
@@ -7,8 +7,7 @@ import {
useState,
} from 'react'
import {useWindowDimensions, View} from 'react-native'
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@@ -27,7 +26,6 @@ import {
} from '#/state/queries/usePostThread'
import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
import {useShellLayout} from '#/state/shell/shell-layout'
import {useUnstablePostSource} from '#/state/unstable-post-source'
import {List, type ListMethods} from '#/view/com/util/List'
import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown'
@@ -52,11 +50,15 @@ import {
ThreadItemTreePost,
ThreadItemTreePostSkeleton,
} from '#/screens/PostThread/components/ThreadItemTreePost'
import {atoms as a, native, platform, useBreakpoints, web} from '#/alf'
import {native, platform, useBreakpoints, web} from '#/alf'
import * as Layout from '#/components/Layout'
import {ListFooter} from '#/components/Lists'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import {
ComposePrompt,
type ComposePromptOpenOptions,
} from '#/features/composePrompt'
const PARENT_CHUNK_SIZE = IS_NATIVE ? 5 : 20
const CHILDREN_CHUNK_SIZE = 50
@@ -66,6 +68,7 @@ const analyticsOnlySendInteraction: FeedFeedbackStateContext['sendInteraction']
export function PostThread({uri}: {uri: string}) {
const ax = useAnalytics()
const {t: l} = useLingui()
const {gtMobile} = useBreakpoints()
const {hasSession} = useSession()
const initialNumToRender = useInitialNumToRender()
@@ -125,34 +128,37 @@ export function PostThread({uri}: {uri: string}) {
}
},
)
const onReplyToAnchor = useNonReactiveCallback(() => {
if (anchor?.type !== 'threadPost') {
return
}
const post = anchor.value.post
openComposer({
replyTo: {
uri: anchor.uri,
cid: post.cid,
text: post.record.text,
author: post.author,
embed: post.embed,
moderation: anchor.moderation,
langs: post.record.langs,
},
onPostSuccess: optimisticOnPostReply,
logContext: 'PostReply',
})
if (anchorPostSource) {
feedFeedback.sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionReply',
feedContext: anchorPostSource.post.feedContext,
reqId: anchorPostSource.post.reqId,
const onReplyToAnchor = useNonReactiveCallback(
(options?: ComposePromptOpenOptions) => {
if (anchor?.type !== 'threadPost') {
return
}
const post = anchor.value.post
openComposer({
...options,
replyTo: {
uri: anchor.uri,
cid: post.cid,
text: post.record.text,
author: post.author,
embed: post.embed,
moderation: anchor.moderation,
langs: post.record.langs,
},
onPostSuccess: optimisticOnPostReply,
logContext: 'PostReply',
})
}
})
if (anchorPostSource) {
feedFeedback.sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionReply',
feedContext: anchorPostSource.post.feedContext,
reqId: anchorPostSource.post.reqId,
})
}
},
)
const isRoot = !!anchor && anchor.value.post.record.reply === undefined
const canReply = !anchor?.value.post?.viewer?.replyDisabled
@@ -645,7 +651,12 @@ export function PostThread({uri}: {uri: string}) {
)}
{!gtMobile && canReply && hasSession && (
<MobileComposePrompt onPressReply={onReplyToAnchor} />
<ComposePrompt
label={l`Write your reply`}
accessibilityLabel={l`Compose reply`}
accessibilityHint={l`Opens composer`}
open={onReplyToAnchor}
/>
)}
</PostThreadContextProvider>
)
@@ -681,22 +692,6 @@ function AnalyticsOnlyFeedFeedbackProvider({
return <FeedFeedbackProvider value={value}>{children}</FeedFeedbackProvider>
}
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
const {footerHeight} = useShellLayout()
const animatedStyle = useAnimatedStyle(() => {
return {
bottom: footerHeight.get(),
}
})
return (
<Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
<ThreadComposePrompt onPressCompose={onPressReply} />
</Animated.View>
)
}
const keyExtractor = (item: ThreadItem) => {
return item.key
}
+2 -15
View File
@@ -1,4 +1,4 @@
import {useCallback, useEffect, useImperativeHandle, useState} from 'react'
import {useCallback, useEffect, useImperativeHandle} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
@@ -18,7 +18,6 @@ import {
} from '#/view/com/util/EmptyState'
import {type ListRef} from '#/view/com/util/List'
import {findListNativeTag} from '#/view/com/util/listNativeTag'
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
import {atoms as a, ios, useTheme} from '#/alf'
import {EditBig_Stroke1_Corner0_Rounded as EditIcon} from '#/components/icons/EditBig'
import {Text} from '#/components/Typography'
@@ -54,8 +53,6 @@ export function ProfileFeedSection({
}: FeedSectionProps) {
const {_} = useLingui()
const queryClient = useQueryClient()
const [hasNew, setHasNew] = useState(false)
const [isScrolledDown, setIsScrolledDown] = useState(false)
const shouldUseAdjustedNumToRender = feed.endsWith('posts_and_author_threads')
const isVideoFeed = IS_NATIVE && feed.endsWith('posts_with_video')
const adjustedInitialNumToRender = useInitialNumToRender({
@@ -67,8 +64,7 @@ export function ProfileFeedSection({
offset: -headerHeight,
})
truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
setHasNew(false)
}, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
}, [scrollElRef, headerHeight, queryClient, feed])
useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
@@ -102,8 +98,6 @@ export function ProfileFeedSection({
enabled={isFocused}
feed={feed}
scrollElRef={scrollElRef}
onHasNew={setHasNew}
onScrolledDownChange={setIsScrolledDown}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
progressViewOffset={ios(0)}
@@ -115,13 +109,6 @@ export function ProfileFeedSection({
isVideoFeed={isVideoFeed}
ref={postFeedRef}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onScrollToTop}
label={_(msg`Load new posts`)}
showIndicator={hasNew}
/>
)}
</View>
)
}
+1 -11
View File
@@ -1,4 +1,4 @@
import {useCallback, useImperativeHandle, useState} from 'react'
import {useCallback, useImperativeHandle} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
@@ -8,7 +8,6 @@ import {useSession} from '#/state/session'
import {ListMembers} from '#/view/com/lists/ListMembers'
import {EmptyState} from '#/view/com/util/EmptyState'
import {type ListRef} from '#/view/com/util/List'
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {BulletList_Stroke1_Corner0_Rounded as ListIcon} from '#/components/icons/BulletList'
@@ -38,7 +37,6 @@ export function AboutSection({
const {_} = useLingui()
const {currentAccount} = useSession()
const {gtMobile} = useBreakpoints()
const [isScrolledDown, setIsScrolledDown] = useState(false)
const isOwner = list.creator.did === currentAccount?.did
const onScrollToTop = useCallback(() => {
@@ -124,15 +122,7 @@ export function AboutSection({
renderHeader={renderHeader}
renderEmptyState={renderEmptyState}
headerOffset={headerHeight}
onScrolledDownChange={setIsScrolledDown}
/>
{isScrolledDown && (
<LoadLatestBtn
onPress={onScrollToTop}
label={_(msg`Scroll to top`)}
showIndicator={false}
/>
)}
</View>
)
}
-10
View File
@@ -14,7 +14,6 @@ import {
import {PostFeed} from '#/view/com/posts/PostFeed'
import {EmptyState} from '#/view/com/util/EmptyState'
import {type ListRef} from '#/view/com/util/List'
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {HashtagWide_Stroke1_Corner0_Rounded as HashtagWideIcon} from '#/components/icons/Hashtag'
@@ -46,7 +45,6 @@ export function FeedSection({
}: FeedSectionProps) {
const queryClient = useQueryClient()
const [hasNew, setHasNew] = useState(false)
const [isScrolledDown, setIsScrolledDown] = useState(false)
const isScreenFocused = useIsFocused()
const {_} = useLingui()
@@ -103,17 +101,9 @@ export function FeedSection({
disablePoll={hasNew}
scrollElRef={scrollElRef}
onHasNew={setHasNew}
onScrolledDownChange={setIsScrolledDown}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onScrollToTop}
label={_(msg`Load new posts`)}
showIndicator={hasNew}
/>
)}
</View>
)
}
+3 -20
View File
@@ -9,7 +9,6 @@ import {Trans} from '@lingui/react/macro'
import {useIsFocused} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
import {
type CommonNavigatorParams,
@@ -27,17 +26,16 @@ import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {truncateAndInvalidate} from '#/state/queries/util'
import {useSession} from '#/state/session'
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
import {FAB} from '#/view/com/util/fab/FAB'
import {type ListRef} from '#/view/com/util/List'
import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen'
import {atoms as a, native, platform, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {ListAddRemoveUsersDialog} from '#/components/dialogs/lists/ListAddRemoveUsersDialog'
import {EditBig_Stroke2_Corner2_Rounded as EditBigIcon} from '#/components/icons/EditBig'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as Hider from '#/components/moderation/Hider'
import {IS_WEB} from '#/env'
import {NewPostComposePrompt} from '#/features/composePrompt'
import {app} from '#/lexicons'
import {AboutSection} from './AboutSection'
import {ErrorScreen} from './components/ErrorScreen'
@@ -150,7 +148,6 @@ function ProfileListScreenLoaded({
const t = useTheme()
const {_} = useLingui()
const queryClient = useQueryClient()
const {openComposer} = useOpenComposer()
const {currentAccount} = useSession()
const {rkey} = route.params
const feedSectionRef = useRef<SectionRef>(null)
@@ -226,14 +223,7 @@ function ProfileListScreenLoaded({
/>
)}
</PagerWithHeader>
<FAB
testID="composeFAB"
onPress={() => openComposer({logContext: 'Fab'})}
icon={<EditBigIcon size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityHint=""
/>
{currentAccount && <NewPostComposePrompt />}
</View>
<ListAddRemoveUsersDialog
control={addUserDialogControl}
@@ -269,14 +259,7 @@ function ProfileListScreenLoaded({
headerHeight={IS_WEB ? 0 : headerHeight}
/>
)}
<FAB
testID="composeFAB"
onPress={() => openComposer({logContext: 'Fab'})}
icon={<EditBigIcon size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityHint=""
/>
{currentAccount && <NewPostComposePrompt />}
</View>
<ListAddRemoveUsersDialog
control={addUserDialogControl}
-257
View File
@@ -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>
)
}
-45
View File
@@ -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>
)
}
+1 -1
View File
@@ -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
-28
View File
@@ -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
View File
@@ -1 +0,0 @@
export {FABInner as FAB} from './FABInner'
-14
View File
@@ -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 />
}
-90
View File
@@ -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)'),
},
})
+2 -18
View File
@@ -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
View File
@@ -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"
+2 -22
View File
@@ -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}
/>
)}
</>
)
}
+10 -19
View File
@@ -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>
+2
View File
@@ -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"