From 6d455c90987cf879966151d53dd07f2d0749dda5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 21:25:30 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01QoggCVt9XgpKbjrTZjoJD9 --- __e2e__/flows/composer-self-label.yml | 4 +- __e2e__/flows/composer.yml | 26 +- __e2e__/flows/thread-muting.yml | 8 +- oxlint-suppressions.json | 10 +- src/App.tsx | 27 +- src/App.web.tsx | 19 +- src/components/dms/dialogs/NewChatDialog.tsx | 36 +-- .../composePrompt/ComposePromptPill.tsx | 264 ++++++++++++++++++ src/features/composePrompt/context.tsx | 256 +++++++++++++++++ src/features/composePrompt/index.tsx | 50 ++++ .../composePrompt/useComposePromptMedia.ts | 122 ++++++++ src/lib/hooks/useHideBottomBarBorder.tsx | 11 +- src/lib/hooks/useMinimalShellTransform.ts | 17 -- src/screens/CustomFeed/index.tsx | 28 +- .../components/ThreadComposePrompt.tsx | 3 - src/screens/PostThread/index.tsx | 91 +++--- src/screens/Profile/Sections/Feed.tsx | 17 +- src/screens/ProfileList/AboutSection.tsx | 12 +- src/screens/ProfileList/FeedSection.tsx | 10 - src/screens/ProfileList/index.tsx | 23 +- src/view/com/feeds/ComposerPrompt.tsx | 257 ----------------- src/view/com/feeds/FeedPage.tsx | 45 --- src/view/com/lists/ListMembers.tsx | 2 +- src/view/com/posts/PostFeed.tsx | 28 -- src/view/com/util/fab/FAB.tsx | 1 - src/view/com/util/fab/FAB.web.tsx | 14 - src/view/com/util/fab/FABInner.tsx | 90 ------ .../com/util/load-latest/LoadLatestBtn.tsx | 113 -------- src/view/screens/Feeds.tsx | 20 +- src/view/screens/Home.tsx | 74 ++--- src/view/screens/Notifications.tsx | 24 +- src/view/screens/Profile.tsx | 29 +- src/view/shell/bottom-bar/BottomBar.tsx | 2 + src/view/shell/bottom-bar/BottomBarWeb.tsx | 2 + 34 files changed, 860 insertions(+), 875 deletions(-) create mode 100644 src/features/composePrompt/ComposePromptPill.tsx create mode 100644 src/features/composePrompt/context.tsx create mode 100644 src/features/composePrompt/index.tsx create mode 100644 src/features/composePrompt/useComposePromptMedia.ts delete mode 100644 src/view/com/feeds/ComposerPrompt.tsx delete mode 100644 src/view/com/util/fab/FAB.tsx delete mode 100644 src/view/com/util/fab/FAB.web.tsx delete mode 100644 src/view/com/util/fab/FABInner.tsx delete mode 100644 src/view/com/util/load-latest/LoadLatestBtn.tsx diff --git a/__e2e__/flows/composer-self-label.yml b/__e2e__/flows/composer-self-label.yml index 065304a508..d80026e839 100644 --- a/__e2e__/flows/composer-self-label.yml +++ b/__e2e__/flows/composer-self-label.yml @@ -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" diff --git a/__e2e__/flows/composer.yml b/__e2e__/flows/composer.yml index e348ec1b25..f23ff596da 100644 --- a/__e2e__/flows/composer.yml +++ b/__e2e__/flows/composer.yml @@ -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" diff --git a/__e2e__/flows/thread-muting.yml b/__e2e__/flows/thread-muting.yml index f00d097e27..398cdfdee0 100644 --- a/__e2e__/flows/thread-muting.yml +++ b/__e2e__/flows/thread-muting.yml @@ -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 diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index e0435c5a4c..3d7b45e1fa 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -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": { diff --git a/src/App.tsx b/src/App.tsx index 2f0a702039..0589d2e7c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index ef6ac5e4c5..d2ddb443e8 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -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() { - - - - - - - - + + + + + + + + + + diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index 7ee103ef9b..3285a8876c 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -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: [ - - Before you can message another user, you must first verify your email. - , - ], - }) - + /* + * 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 && ( - } - accessibilityRole="button" - accessibilityLabel={l`New chat`} - accessibilityHint="" - /> - )} 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 ( + + + + + + { + onPress() + playHaptic('Light') + }} + onLongPress={ios(() => { + onPress() + playHaptic('Heavy') + })} + onLayout={e => pillHeight.set(e.nativeEvent.layout.height)}> + + + + + {config.label} + + {IS_NATIVE && ( + + )} + + + + + + + ) +} diff --git a/src/features/composePrompt/context.tsx b/src/features/composePrompt/context.tsx new file mode 100644 index 0000000000..2009dc2009 --- /dev/null +++ b/src/features/composePrompt/context.tsx @@ -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 + 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 + /** + * 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, + config: ComposePromptConfig, + ) => number + update: (id: number, config: ComposePromptConfig) => void + unregister: (id: number) => void +} + +const stateContext = createContext(null) +stateContext.displayName = 'ComposePromptStateContext' +const actionsContext = createContext(null) +actionsContext.displayName = 'ComposePromptActionsContext' + +let nextId = 0 + +export function Provider({children}: {children: React.ReactNode}) { + const [entries, setEntries] = useState([]) + const [activeId, setActiveId] = useState(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( + () => ({ + 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( + () => ({visibility, config}), + [visibility, config], + ) + + return ( + + + {children} + + + ) +} + +/** + * 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(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]) +} diff --git a/src/features/composePrompt/index.tsx b/src/features/composePrompt/index.tsx new file mode 100644 index 0000000000..f54079e879 --- /dev/null +++ b/src/features/composePrompt/index.tsx @@ -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 +} diff --git a/src/features/composePrompt/useComposePromptMedia.ts b/src/features/composePrompt/useComposePromptMedia.ts new file mode 100644 index 0000000000..e6a88cbb73 --- /dev/null +++ b/src/features/composePrompt/useComposePromptMedia.ts @@ -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} +} diff --git a/src/lib/hooks/useHideBottomBarBorder.tsx b/src/lib/hooks/useHideBottomBarBorder.tsx index 00559bd0db..6a835f2c9e 100644 --- a/src/lib/hooks/useHideBottomBarBorder.tsx +++ b/src/lib/hooks/useHideBottomBarBorder.tsx @@ -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]) } /** diff --git a/src/lib/hooks/useMinimalShellTransform.ts b/src/lib/hooks/useMinimalShellTransform.ts index 11c08e4dc5..8c9e3babd4 100644 --- a/src/lib/hooks/useMinimalShellTransform.ts +++ b/src/lib/hooks/useMinimalShellTransform.ts @@ -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 -} diff --git a/src/screens/CustomFeed/index.tsx b/src/screens/CustomFeed/index.tsx index 112ea1b5ff..dab7a13cd8 100644 --- a/src/screens/CustomFeed/index.tsx +++ b/src/screens/CustomFeed/index.tsx @@ -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} /> - {(isScrolledDown || hasNew) && ( - - )} - {hasSession && ( - openComposer({logContext: 'Fab'})} - icon={} - accessibilityRole="button" - accessibilityLabel={l`New post`} - accessibilityHint="" - /> - )} + {hasSession && } ) } diff --git a/src/screens/PostThread/components/ThreadComposePrompt.tsx b/src/screens/PostThread/components/ThreadComposePrompt.tsx index 11e76e4e42..87a0088843 100644 --- a/src/screens/PostThread/components/ThreadComposePrompt.tsx +++ b/src/screens/PostThread/components/ThreadComposePrompt.tsx @@ -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 ( { - 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 && ( - + )} ) @@ -681,22 +692,6 @@ function AnalyticsOnlyFeedFeedbackProvider({ return {children} } -function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { - const {footerHeight} = useShellLayout() - - const animatedStyle = useAnimatedStyle(() => { - return { - bottom: footerHeight.get(), - } - }) - - return ( - - - - ) -} - const keyExtractor = (item: ThreadItem) => { return item.key } diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx index c830963922..8ed05050bf 100644 --- a/src/screens/Profile/Sections/Feed.tsx +++ b/src/screens/Profile/Sections/Feed.tsx @@ -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) && ( - - )} ) } diff --git a/src/screens/ProfileList/AboutSection.tsx b/src/screens/ProfileList/AboutSection.tsx index 6ec615c4c0..64354a8116 100644 --- a/src/screens/ProfileList/AboutSection.tsx +++ b/src/screens/ProfileList/AboutSection.tsx @@ -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 && ( - - )} ) } diff --git a/src/screens/ProfileList/FeedSection.tsx b/src/screens/ProfileList/FeedSection.tsx index beb68e1bea..6225732211 100644 --- a/src/screens/ProfileList/FeedSection.tsx +++ b/src/screens/ProfileList/FeedSection.tsx @@ -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) && ( - - )} ) } diff --git a/src/screens/ProfileList/index.tsx b/src/screens/ProfileList/index.tsx index c898663c8f..12a8f01337 100644 --- a/src/screens/ProfileList/index.tsx +++ b/src/screens/ProfileList/index.tsx @@ -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(null) @@ -226,14 +223,7 @@ function ProfileListScreenLoaded({ /> )} - openComposer({logContext: 'Fab'})} - icon={} - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" - /> + {currentAccount && } )} - openComposer({logContext: 'Fab'})} - icon={} - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" - /> + {currentAccount && } { - 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 ( - 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'}), - ]}> - - - - - What's up? - - - {IS_NATIVE && ( - - )} - - - - - ) -} diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 10572f9cba..32efb569ad 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -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>() const queryClient = useQueryClient() - const {openComposer} = useOpenComposer() - const [isScrolledDown, setIsScrolledDown] = useState(false) const headerOffset = useHeaderOffset() const feedFeedback = useFeedFeedback(feedInfo, hasSession) const scrollElRef = useRef(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({ /> - {(isScrolledDown || hasNew) && ( - - )} - - {hasSession && ( - } - accessibilityRole="button" - accessibilityLabel={_(msg({message: `New post`, context: 'action'}))} - accessibilityHint="" - /> - )} ) } diff --git a/src/view/com/lists/ListMembers.tsx b/src/view/com/lists/ListMembers.tsx index a7327700c6..11a42209a7 100644 --- a/src/view/com/lists/ListMembers.tsx +++ b/src/view/com/lists/ListMembers.tsx @@ -61,7 +61,7 @@ export function ListMembers({ list: string style?: StyleProp scrollElRef?: ListRef - onScrolledDownChange: (isScrolledDown: boolean) => void + onScrolledDownChange?: (isScrolledDown: boolean) => void onPressTryAgain?: () => void renderHeader: () => React.ReactElement renderEmptyState: () => React.ReactElement diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 5806548342..13fafc89e8 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -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 - } else if (row.type === 'composerPrompt') { - return } else if (row.type === 'interstitialTrendingVideos') { return } else if (row.type === 'fallbackMarker') { diff --git a/src/view/com/util/fab/FAB.tsx b/src/view/com/util/fab/FAB.tsx deleted file mode 100644 index b222fe45c7..0000000000 --- a/src/view/com/util/fab/FAB.tsx +++ /dev/null @@ -1 +0,0 @@ -export {FABInner as FAB} from './FABInner' diff --git a/src/view/com/util/fab/FAB.web.tsx b/src/view/com/util/fab/FAB.web.tsx deleted file mode 100644 index ac5b08701e..0000000000 --- a/src/view/com/util/fab/FAB.web.tsx +++ /dev/null @@ -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 - } - - return -} diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx deleted file mode 100644 index 3077e8ac14..0000000000 --- a/src/view/com/util/fab/FABInner.tsx +++ /dev/null @@ -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 { - testID?: string - icon: JSX.Element - style?: StyleProp -} - -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 ( - - 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} - - - ) -} - -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', - }, -}) diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx deleted file mode 100644 index d99d342d3c..0000000000 --- a/src/view/com/util/load-latest/LoadLatestBtn.tsx +++ /dev/null @@ -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 ( - - - - - - - ) -} - -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)'), - }, -}) diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 449f11785b..fb5d7ba56f 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -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 @@ -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) { /> - {hasSession && ( - } - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" - /> - )} + {hasSession && } ) } diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index f0f9125f50..9925799026 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -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 @@ -311,50 +312,53 @@ function HomeScreenReady({ } return hasSession ? ( - - {pinnedFeedInfos.length ? ( - pinnedFeedInfos.map((feedInfo, index) => { - const feed = feedInfo.feedDescriptor - if (feed === 'following') { + <> + + + {pinnedFeedInfos.length ? ( + pinnedFeedInfos.map((feedInfo, index) => { + const feed = feedInfo.feedDescriptor + if (feed === 'following') { + return ( + + ) + } + const savedFeedConfig = feedInfo.savedFeed return ( ) - } - const savedFeedConfig = feedInfo.savedFeed - return ( - - ) - }) - ) : ( - - )} - + }) + ) : ( + + )} + + ) : ( 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) { {section.component} ))} - openComposer({logContext: 'Fab'})} - icon={} - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" - /> + ) } @@ -184,7 +172,6 @@ function NotificationsTab({ checkUnread: ({invalidate}: {invalidate: boolean}) => Promise setIsLoadingLatest: (v: boolean) => void }) { - const {_} = useLingui() const [isScrolledDown, setIsScrolledDown] = useState(false) const scrollElRef = useRef(null) const queryClient = useQueryClient() @@ -265,13 +252,6 @@ function NotificationsTab({ ) : undefined } /> - {(isScrolledDown || hasNew) && ( - - )} ) } diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index e2eb20b1b3..1985d314b1 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -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} {hasSession && ( - } - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" + )} diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 336b76b7d3..c592abdb09 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -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) { <> + {hasSession && } + {hasSession && }