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