{
+ const nearScreen = useContext(NearScreenContext)
+
+ return nearScreen ? children : null
+}
+
function VideoError({error, retry}: {error: unknown; retry: () => void}) {
const {_} = useLingui()
diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx
index ace85dc984..9c5444b27d 100644
--- a/src/components/Post/Embed/index.tsx
+++ b/src/components/Post/Embed/index.tsx
@@ -268,64 +268,60 @@ export function QuoteEmbed({
const [hover, setHover] = React.useState(false)
return (
{
- setHover(true)
- }}
- onPointerLeave={() => {
- setHover(false)
- }}>
+ style={[a.mt_sm]}
+ onPointerEnter={() => setHover(true)}
+ onPointerLeave={() => setHover(false)}>
-
-
-
-
-
- {moderation ? (
-
- ) : null}
- {richText ? (
-
- ) : null}
- {quote.embed && (
-
- )}
-
+ {({active}) => (
+ <>
+ {!active && }
+
+
+
+
+ {moderation ? (
+
+ ) : null}
+ {richText ? (
+
+ ) : null}
+ {quote.embed && (
+
+ )}
+
+ >
+ )}
)
diff --git a/src/components/Post/PostRepliedTo.tsx b/src/components/Post/PostRepliedTo.tsx
new file mode 100644
index 0000000000..3085826c23
--- /dev/null
+++ b/src/components/Post/PostRepliedTo.tsx
@@ -0,0 +1,63 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {useSession} from '#/state/session'
+import {UserInfoText} from '#/view/com/util/UserInfoText'
+import {atoms as a, useTheme} from '#/alf'
+import {ArrowCornerDownRight_Stroke2_Corner2_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight'
+import {ProfileHoverCard} from '#/components/ProfileHoverCard'
+import {Text} from '#/components/Typography'
+import type * as bsky from '#/types/bsky'
+
+export function PostRepliedTo({
+ parentAuthor,
+ isParentBlocked,
+ isParentNotFound,
+}: {
+ parentAuthor: string | bsky.profile.AnyProfileView | undefined
+ isParentBlocked?: boolean
+ isParentNotFound?: boolean
+}) {
+ const t = useTheme()
+ const {currentAccount} = useSession()
+
+ const textStyle = [a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]
+
+ let label
+ if (isParentBlocked) {
+ label =
Replied to a blocked post
+ } else if (isParentNotFound) {
+ label =
Replied to a post
+ } else if (parentAuthor) {
+ const did =
+ typeof parentAuthor === 'string' ? parentAuthor : parentAuthor.did
+ const isMe = currentAccount?.did === did
+ if (isMe) {
+ label =
Replied to you
+ } else {
+ label = (
+
+ Replied to{' '}
+
+
+
+
+ )
+ }
+ }
+
+ if (!label) {
+ // Should not happen.
+ return null
+ }
+
+ return (
+
+
+ {label}
+
+ )
+}
diff --git a/src/components/PostControls/DiscoverDebug.tsx b/src/components/PostControls/DiscoverDebug.tsx
index 796981f0c2..02f55b7991 100644
--- a/src/components/PostControls/DiscoverDebug.tsx
+++ b/src/components/PostControls/DiscoverDebug.tsx
@@ -2,13 +2,13 @@ import {Pressable} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {t} from '@lingui/macro'
-import {IS_INTERNAL} from '#/lib/app-info'
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
import {useGate} from '#/lib/statsig/statsig'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
+import {IS_INTERNAL} from '#/env'
export function DiscoverDebug({
feedContext,
@@ -32,8 +32,7 @@ export function DiscoverDebug({
hitSlop={10}
style={[
a.absolute,
- a.bottom_0,
- {zIndex: 1000},
+ {zIndex: 1000, maxWidth: 65, bottom: -4},
gtMobile ? a.right_0 : a.left_0,
]}
onPress={e => {
@@ -42,6 +41,7 @@ export function DiscoverDebug({
Toast.show(t`Copied to clipboard`, 'clipboard-check')
}}>
({})
+PostControlContext.displayName = 'PostControlContext'
// Base button style, which the the other ones extend
export function PostControlButton({
diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx
index 01ddd0bcfc..3fd919cd3e 100644
--- a/src/components/PostControls/PostMenu/PostMenuItems.tsx
+++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx
@@ -17,9 +17,9 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
-import {IS_INTERNAL} from '#/lib/app-info'
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
+import {useTranslate} from '#/lib/hooks/useTranslate'
import {getCurrentRoute} from '#/lib/routes/helpers'
import {makeProfileLink} from '#/lib/routes/links'
import {
@@ -29,7 +29,6 @@ import {
import {logEvent, useGate} from '#/lib/statsig/statsig'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {toShareUrl} from '#/lib/strings/url-helpers'
-import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -83,6 +82,7 @@ import {
useReportDialogControl,
} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
+import {IS_INTERNAL} from '#/env'
import * as bsky from '#/types/bsky'
let PostMenuItems = ({
@@ -118,6 +118,7 @@ let PostMenuItems = ({
const {hidePost} = useHiddenPostsApi()
const feedFeedback = useFeedFeedbackContext()
const openLink = useOpenLink()
+ const translate = useTranslate()
const navigation = useNavigation()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
const blockPromptControl = useDialogControl()
@@ -172,11 +173,6 @@ let PostMenuItems = ({
return makeProfileLink(postAuthor, 'post', urip.rkey)
}, [postUri, postAuthor])
- const translatorUrl = getTranslatorLink(
- record.text,
- langPrefs.primaryLanguage,
- )
-
const onDeletePost = () => {
deletePostMutate({uri: postUri}).then(
() => {
@@ -234,8 +230,8 @@ let PostMenuItems = ({
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}
- const onPressTranslate = async () => {
- await openLink(translatorUrl, true)
+ const onPressTranslate = () => {
+ translate(record.text, langPrefs.primaryLanguage)
if (
bsky.dangerousIsType(
@@ -600,8 +596,8 @@ let PostMenuItems = ({
isDetachPending
? Loader
: quoteEmbed.isDetached
- ? Eye
- : EyeSlash
+ ? Eye
+ : EyeSlash
}
position="right"
/>
diff --git a/src/components/PostControls/RepostButton.tsx b/src/components/PostControls/RepostButton.tsx
index 31438c6bd3..e09950b49c 100644
--- a/src/components/PostControls/RepostButton.tsx
+++ b/src/components/PostControls/RepostButton.tsx
@@ -145,7 +145,7 @@ let RepostButtonDialogInner = ({
diff --git a/src/components/dialogs/EmailDialog/screens/Verify.tsx b/src/components/dialogs/EmailDialog/screens/Verify.tsx
index dabd0d2f24..07aef6145f 100644
--- a/src/components/dialogs/EmailDialog/screens/Verify.tsx
+++ b/src/components/dialogs/EmailDialog/screens/Verify.tsx
@@ -211,7 +211,9 @@ export function Verify({config, showScreen}: ScreenProps) {
Verify your email
)
) : (
- Verify email code
+
+ Verify email code
+
)}
@@ -345,7 +347,13 @@ export function Verify({config, showScreen}: ScreenProps
) {
{state.error && {state.error}}
) {
state.mutationStatus === 'pending'
}>
- Verify code
+
+ Verify code
+
{state.mutationStatus === 'pending' && }
diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx
index 8ced355e47..e18fdf2dba 100644
--- a/src/components/dialogs/GifSelect.tsx
+++ b/src/components/dialogs/GifSelect.tsx
@@ -5,7 +5,7 @@ import React, {
useRef,
useState,
} from 'react'
-import {TextInput, View} from 'react-native'
+import {type TextInput, View} from 'react-native'
import {useWindowDimensions} from 'react-native'
import {Image} from 'expo-image'
import {msg, Trans} from '@lingui/macro'
@@ -15,13 +15,14 @@ import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {isWeb} from '#/platform/detection'
import {
- Gif,
+ type Gif,
+ tenorUrlToBskyGifUrl,
useFeaturedGifsQuery,
useGifSearchQuery,
} from '#/state/queries/tenor'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
-import {ListMethods} from '#/view/com/util/List'
+import {type ListMethods} from '#/view/com/util/List'
import {atoms as a, ios, native, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -316,7 +317,7 @@ export function GifPreview({
t.atoms.bg_contrast_25,
]}
source={{
- uri: gif.media_formats.tinygif.url,
+ uri: tenorUrlToBskyGifUrl(gif.media_formats.tinygif.url),
}}
contentFit="cover"
accessibilityLabel={gif.title}
diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx
index 63b640d9fe..dbb966fd3f 100644
--- a/src/components/dialogs/PostInteractionSettingsDialog.tsx
+++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx
@@ -1,6 +1,10 @@
import React from 'react'
-import {StyleProp, View, ViewStyle} from 'react-native'
-import {AppBskyFeedDefs, AppBskyFeedPostgate, AtUri} from '@atproto/api'
+import {type StyleProp, View, type ViewStyle} from 'react-native'
+import {
+ type AppBskyFeedDefs,
+ type AppBskyFeedPostgate,
+ AtUri,
+} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
@@ -22,7 +26,7 @@ import {
import {
createThreadgateViewQueryKey,
getThreadgateView,
- ThreadgateAllowUISetting,
+ type ThreadgateAllowUISetting,
threadgateViewToAllowUISetting,
useSetThreadgateAllowMutation,
useThreadgateViewQuery,
@@ -558,7 +562,8 @@ export function usePrefetchPostInteractionSettings({
await Promise.all([
queryClient.prefetchQuery({
queryKey: createPostgateQueryKey(postUri),
- queryFn: () => getPostgateRecord({agent, postUri}),
+ queryFn: () =>
+ getPostgateRecord({agent, postUri}).then(res => res ?? null),
staleTime: STALE.SECONDS.THIRTY,
}),
queryClient.prefetchQuery({
diff --git a/src/components/dialogs/SearchablePeopleList.tsx b/src/components/dialogs/SearchablePeopleList.tsx
index 81655be0f5..159f623b93 100644
--- a/src/components/dialogs/SearchablePeopleList.tsx
+++ b/src/components/dialogs/SearchablePeopleList.tsx
@@ -390,8 +390,8 @@ function DefaultProfileCard({
!enabled
? {opacity: 0.5}
: pressed || focused || hovered
- ? t.atoms.bg_contrast_25
- : t.atoms.bg,
+ ? t.atoms.bg_contrast_25
+ : t.atoms.bg,
]}>
{
+ nuxDialogs.dismissActiveNux()
+ }, [nuxDialogs])
+
+ return (
+
+
+
+
+
+
+
+
+ New Feature
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Get notified when someone posts
+
+
+
+ You can now choose to be notified when specific people post. If
+ there’s someone you want timely updates from, go to their
+ profile and find the new bell icon near the follow button.
+
+
+
+
+ {!isWeb && (
+ {
+ control.close()
+ }}
+ style={[a.w_full, {maxWidth: 280}]}>
+
+ Close
+
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx
index 11377e1ded..985d58eecc 100644
--- a/src/components/dialogs/nuxs/index.tsx
+++ b/src/components/dialogs/nuxs/index.tsx
@@ -3,6 +3,7 @@ import {type AppBskyActorDefs} from '@atproto/api'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
+import {STALE} from '#/state/queries'
import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs'
import {
usePreferencesQuery,
@@ -11,12 +12,12 @@ import {
import {useProfileQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell'
-import {InitialVerificationAnnouncement} from '#/components/dialogs/nuxs/InitialVerificationAnnouncement'
+import {ActivitySubscriptionsNUX} from '#/components/dialogs/nuxs/ActivitySubscriptions'
/*
* NUXs
*/
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
-import {isDaysOld} from '#/components/dialogs/nuxs/utils'
+import {isExistingUserAsOf} from '#/components/dialogs/nuxs/utils'
type Context = {
activeNux: Nux | undefined
@@ -33,9 +34,12 @@ const queuedNuxs: {
}) => boolean
}[] = [
{
- id: Nux.InitialVerificationAnnouncement,
+ id: Nux.ActivitySubscriptions,
enabled: ({currentProfile}) => {
- return isDaysOld(2, currentProfile.createdAt)
+ return isExistingUserAsOf(
+ '2025-07-07T00:00:00.000Z',
+ currentProfile.createdAt,
+ )
},
},
]
@@ -44,6 +48,7 @@ const Context = React.createContext({
activeNux: undefined,
dismissActiveNux: () => {},
})
+Context.displayName = 'NuxDialogContext'
export function useNuxDialogContext() {
return React.useContext(Context)
@@ -52,7 +57,10 @@ export function useNuxDialogContext() {
export function NuxDialogs() {
const {currentAccount} = useSession()
const {data: preferences} = usePreferencesQuery()
- const {data: profile} = useProfileQuery({did: currentAccount?.did})
+ const {data: profile} = useProfileQuery({
+ did: currentAccount?.did,
+ staleTime: STALE.INFINITY, // createdAt isn't gonna change
+ })
const onboardingActive = useOnboardingState().isActive
const isLoading =
@@ -111,7 +119,7 @@ function Inner({
}
React.useEffect(() => {
- if (snoozed) return
+ if (snoozed) return // comment this out to test
if (!nuxs) return
for (const {id, enabled} of queuedNuxs) {
@@ -119,7 +127,7 @@ function Inner({
// check if completed first
if (nux && nux.completed) {
- continue
+ continue // comment this out to test
}
// then check gate (track exposure)
@@ -172,9 +180,7 @@ function Inner({
return (
{/*For example, activeNux === Nux.NeueTypography && */}
- {activeNux === Nux.InitialVerificationAnnouncement && (
-
- )}
+ {activeNux === Nux.ActivitySubscriptions && }
)
}
diff --git a/src/components/dialogs/nuxs/utils.ts b/src/components/dialogs/nuxs/utils.ts
index 0cc5104840..ba8f0169d6 100644
--- a/src/components/dialogs/nuxs/utils.ts
+++ b/src/components/dialogs/nuxs/utils.ts
@@ -16,3 +16,18 @@ export function isDaysOld(days: number, createdAt?: string) {
if (isOldEnough) return true
return false
}
+
+export function isExistingUserAsOf(date: string, createdAt?: string) {
+ /*
+ * Should never happen because we gate NUXs to only accounts with a valid
+ * profile and a `createdAt` (see `nuxs/index.tsx`). But if it ever did, the
+ * account is either old enough to be pre-onboarding, or some failure happened
+ * during account creation. Fail closed. - esb
+ */
+ if (!createdAt) return false
+
+ const threshold = Date.parse(date)
+ const then = new Date(createdAt).getTime()
+
+ return then < threshold
+}
diff --git a/src/components/dms/EmojiReactionPicker.tsx b/src/components/dms/EmojiReactionPicker.tsx
index cdb1680e82..b83fd44547 100644
--- a/src/components/dms/EmojiReactionPicker.tsx
+++ b/src/components/dms/EmojiReactionPicker.tsx
@@ -98,8 +98,8 @@ export function EmojiReactionPicker({
: t.palette.primary_500,
}
: alreadyReacted
- ? {backgroundColor: t.palette.primary_200}
- : bgColor,
+ ? {backgroundColor: t.palette.primary_200}
+ : bgColor,
{height: 40, width: 40},
a.justify_center,
a.align_center,
diff --git a/src/components/dms/EmojiReactionPicker.web.tsx b/src/components/dms/EmojiReactionPicker.web.tsx
index 4dabfc7df5..f2a7cc1193 100644
--- a/src/components/dms/EmojiReactionPicker.web.tsx
+++ b/src/components/dms/EmojiReactionPicker.web.tsx
@@ -78,10 +78,12 @@ function MenuInner({
-
+ evt.stopPropagation()}>
+
+
) : (
diff --git a/src/components/dms/MessageContext.tsx b/src/components/dms/MessageContext.tsx
index 84056fb306..645079eb26 100644
--- a/src/components/dms/MessageContext.tsx
+++ b/src/components/dms/MessageContext.tsx
@@ -1,6 +1,7 @@
import React from 'react'
const MessageContext = React.createContext(false)
+MessageContext.displayName = 'MessageContext'
export function MessageContextProvider({
children,
diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx
index a45ec7a079..670e677db2 100644
--- a/src/components/dms/MessageContextMenu.tsx
+++ b/src/components/dms/MessageContextMenu.tsx
@@ -5,9 +5,8 @@ import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useOpenLink} from '#/lib/hooks/useOpenLink'
+import {useTranslate} from '#/lib/hooks/useTranslate'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
-import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useConvoActive} from '#/state/messages/convo'
@@ -39,7 +38,7 @@ export let MessageContextMenu = ({
const deleteControl = usePromptControl()
const reportControl = usePromptControl()
const langPrefs = useLanguagePrefs()
- const openLink = useOpenLink()
+ const translate = useTranslate()
const isFromSelf = message.sender?.did === currentAccount?.did
@@ -57,11 +56,7 @@ export let MessageContextMenu = ({
}, [_, message.text, message.facets])
const onPressTranslateMessage = useCallback(() => {
- const translatorUrl = getTranslatorLink(
- message.text,
- langPrefs.primaryLanguage,
- )
- openLink(translatorUrl, true)
+ translate(message.text, langPrefs.primaryLanguage)
logger.metric(
'translate',
@@ -72,7 +67,7 @@ export let MessageContextMenu = ({
},
{statsig: false},
)
- }, [langPrefs.primaryLanguage, message.text, openLink])
+ }, [langPrefs.primaryLanguage, message.text, translate])
const onDelete = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
@@ -128,8 +123,7 @@ export let MessageContextMenu = ({
label={_(msg`Message options`)}
contentLabel={_(
msg`Message from @${
- sender?.handle ?? // should always be defined
- 'unknown'
+ sender?.handle ?? 'unknown' // should always be defined
}: ${message.text}`,
)}>
{children}
diff --git a/src/components/dms/MessageProfileButton.tsx b/src/components/dms/MessageProfileButton.tsx
index 07b3a0c042..fbbb5c9bfa 100644
--- a/src/components/dms/MessageProfileButton.tsx
+++ b/src/components/dms/MessageProfileButton.tsx
@@ -73,7 +73,8 @@ export function MessageProfileButton({
a.align_center,
t.atoms.bg_contrast_25,
a.rounded_full,
- {width: 34, height: 34},
+ // Matches size of button below to avoid layout shift
+ {width: 33, height: 33},
]}>
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index ea7f8e94ef..9993317d6a 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import {createContext, useContext, useMemo, useRef} from 'react'
import {
type AccessibilityProps,
StyleSheet,
@@ -16,7 +16,9 @@ import {
applyFonts,
atoms as a,
ios,
+ platform,
type TextStyleProp,
+ tokens,
useAlf,
useTheme,
web,
@@ -25,7 +27,7 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Text} from '#/components/Typography'
-const Context = React.createContext<{
+const Context = createContext<{
inputRef: React.RefObject | null
isInvalid: boolean
hovered: boolean
@@ -44,11 +46,12 @@ const Context = React.createContext<{
onFocus: () => {},
onBlur: () => {},
})
+Context.displayName = 'TextFieldContext'
export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}>
export function Root({children, isInvalid = false}: RootProps) {
- const inputRef = React.useRef(null)
+ const inputRef = useRef(null)
const {
state: hovered,
onIn: onHoverIn,
@@ -56,7 +59,7 @@ export function Root({children, isInvalid = false}: RootProps) {
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
- const context = React.useMemo(
+ const context = useMemo(
() => ({
inputRef,
hovered,
@@ -96,7 +99,7 @@ export function Root({children, isInvalid = false}: RootProps) {
export function useSharedInputStyles() {
const t = useTheme()
- return React.useMemo(() => {
+ return useMemo(() => {
const hover: ViewStyle[] = [
{
borderColor: t.palette.contrast_100,
@@ -158,7 +161,7 @@ export function createInput(Component: typeof TextInput) {
}: InputProps) {
const t = useTheme()
const {fonts} = useAlf()
- const ctx = React.useContext(Context)
+ const ctx = useContext(Context)
const withinRoot = Boolean(ctx.inputRef)
const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
@@ -196,7 +199,8 @@ export function createInput(Component: typeof TextInput) {
minWidth: 0,
},
ios({paddingTop: 12, paddingBottom: 13}),
- android(a.py_md),
+ // Needs to be sm on Paper, md on Fabric for some godforsaken reason -sfn
+ android(a.py_sm),
// fix for autofill styles covering border
web({
paddingTop: 10,
@@ -282,8 +286,8 @@ export function LabelText({
export function Icon({icon: Comp}: {icon: React.ComponentType}) {
const t = useTheme()
- const ctx = React.useContext(Context)
- const {hover, focus, errorHover, errorFocus} = React.useMemo(() => {
+ const ctx = useContext(Context)
+ const {hover, focus, errorHover, errorFocus} = useMemo(() => {
const hover: TextStyle[] = [
{
color: t.palette.contrast_800,
@@ -341,7 +345,7 @@ export function SuffixText({
}
>) {
const t = useTheme()
- const ctx = React.useContext(Context)
+ const ctx = useContext(Context)
return (
)
}
+
+export function GhostText({
+ children,
+ value,
+}: {
+ children: string
+ value: string
+}) {
+ const t = useTheme()
+ // eslint-disable-next-line bsky-internal/avoid-unwrapped-text
+ return (
+
+
+ {children}
+
+ {value}
+
+
+
+ )
+}
diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx
index 4e3695bbf2..9c3564aa54 100644
--- a/src/components/forms/Toggle.tsx
+++ b/src/components/forms/Toggle.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import {Pressable, View, ViewStyle} from 'react-native'
+import {Pressable, View, type ViewStyle} from 'react-native'
import Animated, {LinearTransition} from 'react-native-reanimated'
import {HITSLOP_10} from '#/lib/constants'
@@ -8,9 +8,9 @@ import {
atoms as a,
flatten,
native,
- TextStyleProp,
+ type TextStyleProp,
useTheme,
- ViewStyleProp,
+ type ViewStyleProp,
} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
@@ -35,6 +35,7 @@ const ItemContext = React.createContext({
pressed: false,
focused: false,
})
+ItemContext.displayName = 'ToggleItemContext'
const GroupContext = React.createContext<{
values: string[]
@@ -49,6 +50,7 @@ const GroupContext = React.createContext<{
maxSelectionsReached: false,
setFieldValue: () => {},
})
+GroupContext.displayName = 'ToggleGroupContext'
export type GroupProps = React.PropsWithChildren<{
type?: 'radio' | 'checkbox'
diff --git a/src/components/hooks/useOnGesture/index.ts b/src/components/hooks/useOnGesture/index.ts
new file mode 100644
index 0000000000..6f05606612
--- /dev/null
+++ b/src/components/hooks/useOnGesture/index.ts
@@ -0,0 +1,24 @@
+import {useEffect} from 'react'
+
+import {
+ type GlobalGestureEvents,
+ useGlobalGestureEvents,
+} from '#/state/global-gesture-events'
+
+/**
+ * Listen for global gesture events. Callback should be wrapped with
+ * `useCallback` or otherwise memoized to avoid unnecessary re-renders.
+ */
+export function useOnGesture(
+ onGestureCallback: (e: GlobalGestureEvents['begin']) => void,
+) {
+ const ctx = useGlobalGestureEvents()
+ useEffect(() => {
+ ctx.register()
+ ctx.events.on('begin', onGestureCallback)
+ return () => {
+ ctx.unregister()
+ ctx.events.off('begin', onGestureCallback)
+ }
+ }, [ctx, onGestureCallback])
+}
diff --git a/src/components/hooks/useOnGesture/index.web.ts b/src/components/hooks/useOnGesture/index.web.ts
new file mode 100644
index 0000000000..6129fde106
--- /dev/null
+++ b/src/components/hooks/useOnGesture/index.web.ts
@@ -0,0 +1 @@
+export function useOnGesture() {}
diff --git a/src/components/icons/ArrowCornerDownRight.tsx b/src/components/icons/ArrowCornerDownRight.tsx
new file mode 100644
index 0000000000..86dde70154
--- /dev/null
+++ b/src/components/icons/ArrowCornerDownRight.tsx
@@ -0,0 +1,7 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ArrowCornerDownRight_Stroke2_Corner2_Rounded = createSinglePathSVG(
+ {
+ path: 'M15.793 10.293a1 1 0 0 1 1.338-.068l.076.068 3.293 3.293a2 2 0 0 1 .138 2.677l-.138.151-3.293 3.293a1 1 0 1 1-1.414-1.414L18.086 16H8a5 5 0 0 1-5-5V5a1 1 0 0 1 2 0v6a3 3 0 0 0 3 3h10.086l-2.293-2.293-.068-.076a1 1 0 0 1 .068-1.338Z',
+ },
+)
diff --git a/src/components/icons/BellPlus.tsx b/src/components/icons/BellPlus.tsx
new file mode 100644
index 0000000000..cd29de1979
--- /dev/null
+++ b/src/components/icons/BellPlus.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const BellPlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M12 2a1 1 0 0 1 0 2 5.85 5.85 0 0 0-5.802 5.08L5.143 17h13.715l-.382-2.868-.01-.102a1 1 0 0 1 1.973-.262l.02.1.532 4a1 1 0 0 1-.99 1.132h-3.357c-.905 1.747-2.606 3-4.644 3s-3.74-1.253-4.643-3H4a1 1 0 0 1-.991-1.132l1.207-9.053A7.85 7.85 0 0 1 12 2ZM9.78 19c.61.637 1.397 1 2.22 1s1.611-.363 2.22-1H9.78ZM17 2.5a1 1 0 0 1 1 1V6h2.5a1 1 0 0 1 0 2H18v2.5a1 1 0 0 1-2 0V8h-2.5a1 1 0 1 1 0-2H16V3.5a1 1 0 0 1 1-1Z',
+})
diff --git a/src/components/icons/BellRinging.tsx b/src/components/icons/BellRinging.tsx
index b174fcedc2..11981a7a38 100644
--- a/src/components/icons/BellRinging.tsx
+++ b/src/components/icons/BellRinging.tsx
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
export const BellRinging_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 2a7.854 7.854 0 0 1 7.785 6.815l1.055 7.92.018.224a2 2 0 0 1-2 2.041h-2.215c-.904 1.747-2.605 3-4.643 3s-3.739-1.253-4.643-3H5.142a2 2 0 0 1-1.982-2.265l1.056-7.92.057-.363A7.854 7.854 0 0 1 12 2ZM9.78 19c.609.637 1.398 1 2.22 1s1.611-.363 2.22-1H9.78ZM12 4a5.854 5.854 0 0 0-5.76 4.81l-.041.27L5.142 17h13.716l-1.056-7.92A5.854 5.854 0 0 0 12 4ZM2.718 7.464a1 1 0 1 1-1.953-.427l1.953.427Zm20.518-.427a1 1 0 0 1-1.954.427l1.954-.427ZM3.193 2.105a1 1 0 0 1 1.531 1.287 9.47 9.47 0 0 0-2.006 4.072L.765 7.037a11.46 11.46 0 0 1 2.428-4.932Zm16.205-.123a1 1 0 0 1 1.34.047l.069.076.217.265a11.46 11.46 0 0 1 2.212 4.667l-.978.213-.976.214a9.46 9.46 0 0 0-1.826-3.853l-.18-.22-.062-.081a1 1 0 0 1 .184-1.328Z',
})
+
+export const BellRinging_Filled_Corner0_Rounded = createSinglePathSVG({
+ path: 'M12 2a7.854 7.854 0 0 1 7.784 6.815l1.207 9.053a1 1 0 0 1-.99 1.132h-3.354c-.904 1.748-2.608 3-4.647 3-2.038 0-3.742-1.252-4.646-3H4a1.002 1.002 0 0 1-.991-1.132l1.207-9.053A7.85 7.85 0 0 1 12 2ZM9.78 19c.608.637 1.398 1 2.221 1s1.613-.363 2.222-1H9.779ZM3.193 2.104a1 1 0 0 1 1.53 1.288A9.47 9.47 0 0 0 2.72 7.464a1 1 0 0 1-1.954-.427 11.46 11.46 0 0 1 2.428-4.933Zm16.205-.122a1 1 0 0 1 1.409.122 11.47 11.47 0 0 1 2.429 4.933 1 1 0 0 1-1.954.427 9.47 9.47 0 0 0-2.006-4.072 1 1 0 0 1 .122-1.41Z',
+})
diff --git a/src/components/icons/Circle.tsx b/src/components/icons/Circle.tsx
new file mode 100644
index 0000000000..93d837119e
--- /dev/null
+++ b/src/components/icons/Circle.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Circle_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Z',
+})
diff --git a/src/components/icons/VerifiedCheck.tsx b/src/components/icons/VerifiedCheck.tsx
index 9d0aa9158d..a98fdfc5d9 100644
--- a/src/components/icons/VerifiedCheck.tsx
+++ b/src/components/icons/VerifiedCheck.tsx
@@ -3,28 +3,27 @@ import Svg, {Circle, Path} from 'react-native-svg'
import {type Props, useCommonSVGProps} from '#/components/icons/common'
-export const VerifiedCheck = React.forwardRef