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 && }