Activity notification settings (#8485)
Co-authored-by: Eric Bailey <git@esb.lol> Co-authored-by: Samuel Newman <mozzius@protonmail.com> Co-authored-by: hailey <me@haileyok.com>
This commit is contained in:
@@ -11,6 +11,8 @@ import {useLingui} from '@lingui/react'
|
||||
import {useActorStatus} from '#/lib/actor-status'
|
||||
import {getModerationCauseKey} from '#/lib/moderation'
|
||||
import {type LogEvents} from '#/lib/statsig/statsig'
|
||||
import {forceLTR} from '#/lib/strings/bidi'
|
||||
import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
@@ -18,7 +20,7 @@ import {useProfileFollowMutationQueue} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, platform, useTheme} from '#/alf'
|
||||
import {
|
||||
Button,
|
||||
ButtonIcon,
|
||||
@@ -183,14 +185,77 @@ export function AvatarPlaceholder() {
|
||||
export function NameAndHandle({
|
||||
profile,
|
||||
moderationOpts,
|
||||
inline = false,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
inline?: boolean
|
||||
}) {
|
||||
if (inline) {
|
||||
return (
|
||||
<InlineNameAndHandle profile={profile} moderationOpts={moderationOpts} />
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<Name profile={profile} moderationOpts={moderationOpts} />
|
||||
<Handle profile={profile} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function InlineNameAndHandle({
|
||||
profile,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const verification = useSimpleVerificationState({profile})
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const name = sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
const handle = sanitizeHandle(profile.handle, '@')
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<Name profile={profile} moderationOpts={moderationOpts} />
|
||||
<Handle profile={profile} />
|
||||
<View style={[a.flex_row, a.align_end, a.flex_shrink]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
a.font_bold,
|
||||
a.leading_tight,
|
||||
a.flex_shrink_0,
|
||||
{maxWidth: '70%'},
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{forceLTR(name)}
|
||||
</Text>
|
||||
{verification.showBadge && (
|
||||
<View
|
||||
style={[
|
||||
a.pl_2xs,
|
||||
a.self_center,
|
||||
{marginTop: platform({default: 0, android: -1})},
|
||||
]}>
|
||||
<VerificationCheck
|
||||
width={platform({android: 13, default: 12})}
|
||||
verifier={verification.role === 'verifier'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_medium,
|
||||
{flexShrink: 10},
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{NON_BREAKING_SPACE + handle}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -30,31 +31,33 @@ const BUBBLE_SHADOW_OFFSET = ARROW_SIZE / 3 // vibes-based, provide more shadow
|
||||
|
||||
type TooltipContextType = {
|
||||
position: 'top' | 'bottom'
|
||||
ready: boolean
|
||||
visible: boolean
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
}
|
||||
|
||||
type TargetMeasurements = {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type TargetContextType = {
|
||||
targetMeasurements:
|
||||
| {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
| undefined
|
||||
targetRef: React.RefObject<View>
|
||||
targetMeasurements: TargetMeasurements | undefined
|
||||
setTargetMeasurements: (measurements: TargetMeasurements) => void
|
||||
shouldMeasure: boolean
|
||||
}
|
||||
|
||||
const TooltipContext = createContext<TooltipContextType>({
|
||||
position: 'bottom',
|
||||
ready: false,
|
||||
visible: false,
|
||||
onVisibleChange: () => {},
|
||||
})
|
||||
|
||||
const TargetContext = createContext<TargetContextType>({
|
||||
targetMeasurements: undefined,
|
||||
targetRef: {current: null},
|
||||
setTargetMeasurements: () => {},
|
||||
shouldMeasure: false,
|
||||
})
|
||||
|
||||
export function Outer({
|
||||
@@ -68,21 +71,12 @@ export function Outer({
|
||||
visible: boolean
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
}) {
|
||||
/**
|
||||
* Whether we have measured the target and are ready to show the tooltip.
|
||||
*/
|
||||
const [ready, setReady] = useState(false)
|
||||
/**
|
||||
* Lagging state to track the externally-controlled visibility of the
|
||||
* tooltip.
|
||||
* tooltip, which needs to wait for the target to be measured before
|
||||
* actually being shown.
|
||||
*/
|
||||
const [prevRequestVisible, setPrevRequestVisible] = useState<
|
||||
boolean | undefined
|
||||
>()
|
||||
/**
|
||||
* Needs to reference the element this Tooltip is attached to.
|
||||
*/
|
||||
const targetRef = useRef<View>(null)
|
||||
const [visible, setVisible] = useState<boolean>(false)
|
||||
const [targetMeasurements, setTargetMeasurements] = useState<
|
||||
| {
|
||||
x: number
|
||||
@@ -93,33 +87,24 @@ export function Outer({
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
if (requestVisible && !prevRequestVisible) {
|
||||
setPrevRequestVisible(true)
|
||||
|
||||
if (targetRef.current) {
|
||||
/*
|
||||
* Once opened, measure the dimensions and position of the target
|
||||
*/
|
||||
targetRef.current.measure((_x, _y, width, height, pageX, pageY) => {
|
||||
if (pageX !== undefined && pageY !== undefined && width && height) {
|
||||
setTargetMeasurements({x: pageX, y: pageY, width, height})
|
||||
setReady(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (!requestVisible && prevRequestVisible) {
|
||||
setPrevRequestVisible(false)
|
||||
if (requestVisible && !visible && targetMeasurements) {
|
||||
setVisible(true)
|
||||
} else if (!requestVisible && visible) {
|
||||
setVisible(false)
|
||||
setTargetMeasurements(undefined)
|
||||
setReady(false)
|
||||
}
|
||||
|
||||
const ctx = useMemo(
|
||||
() => ({position, ready, onVisibleChange}),
|
||||
[position, ready, onVisibleChange],
|
||||
() => ({position, visible, onVisibleChange}),
|
||||
[position, visible, onVisibleChange],
|
||||
)
|
||||
const targetCtx = useMemo(
|
||||
() => ({targetMeasurements, targetRef}),
|
||||
[targetMeasurements, targetRef],
|
||||
() => ({
|
||||
targetMeasurements,
|
||||
setTargetMeasurements,
|
||||
shouldMeasure: requestVisible,
|
||||
}),
|
||||
[requestVisible, targetMeasurements, setTargetMeasurements],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -132,7 +117,20 @@ export function Outer({
|
||||
}
|
||||
|
||||
export function Target({children}: {children: React.ReactNode}) {
|
||||
const {targetRef} = useContext(TargetContext)
|
||||
const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
|
||||
const targetRef = useRef<View>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldMeasure) return
|
||||
/*
|
||||
* Once opened, measure the dimensions and position of the target
|
||||
*/
|
||||
targetRef.current?.measure((_x, _y, width, height, pageX, pageY) => {
|
||||
if (pageX !== undefined && pageY !== undefined && width && height) {
|
||||
setTargetMeasurements({x: pageX, y: pageY, width, height})
|
||||
}
|
||||
})
|
||||
}, [shouldMeasure, setTargetMeasurements])
|
||||
|
||||
return (
|
||||
<View collapsable={false} ref={targetRef}>
|
||||
@@ -148,13 +146,13 @@ export function Content({
|
||||
children: React.ReactNode
|
||||
label: string
|
||||
}) {
|
||||
const {position, ready, onVisibleChange} = useContext(TooltipContext)
|
||||
const {position, visible, onVisibleChange} = useContext(TooltipContext)
|
||||
const {targetMeasurements} = useContext(TargetContext)
|
||||
const requestClose = useCallback(() => {
|
||||
onVisibleChange(false)
|
||||
}, [onVisibleChange])
|
||||
|
||||
if (!ready || !targetMeasurements) return null
|
||||
if (!visible || !targetMeasurements) return null
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
|
||||
@@ -13,10 +13,12 @@ import {Text} from '#/components/Typography'
|
||||
|
||||
type TooltipContextType = {
|
||||
position: 'top' | 'bottom'
|
||||
onVisibleChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const TooltipContext = createContext<TooltipContextType>({
|
||||
position: 'bottom',
|
||||
onVisibleChange: () => {},
|
||||
})
|
||||
|
||||
export function Outer({
|
||||
@@ -30,7 +32,10 @@ export function Outer({
|
||||
visible: boolean
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
}) {
|
||||
const ctx = useMemo(() => ({position}), [position])
|
||||
const ctx = useMemo(
|
||||
() => ({position, onVisibleChange}),
|
||||
[position, onVisibleChange],
|
||||
)
|
||||
return (
|
||||
<Popover.Root open={visible} onOpenChange={onVisibleChange}>
|
||||
<TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider>
|
||||
@@ -54,7 +59,7 @@ export function Content({
|
||||
label: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {position} = useContext(TooltipContext)
|
||||
const {position, onVisibleChange} = useContext(TooltipContext)
|
||||
return (
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
@@ -63,6 +68,7 @@ export function Content({
|
||||
side={position}
|
||||
sideOffset={4}
|
||||
collisionPadding={MIN_EDGE_SPACE}
|
||||
onInteractOutside={() => onVisibleChange(false)}
|
||||
style={flatten([
|
||||
a.rounded_sm,
|
||||
select(t.name, {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import {useCallback} from 'react'
|
||||
import {type ModerationOpts} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {BellPlus_Stroke2_Corner0_Rounded as BellPlusIcon} from '#/components/icons/BellPlus'
|
||||
import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
|
||||
import * as Tooltip from '#/components/Tooltip'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {SubscribeProfileDialog} from './SubscribeProfileDialog'
|
||||
|
||||
export function SubscribeProfileButton({
|
||||
profile,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const requireEmailVerification = useRequireEmailVerification()
|
||||
const subscribeDialogControl = useDialogControl()
|
||||
const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] =
|
||||
useActivitySubscriptionsNudged()
|
||||
|
||||
const onDismissTooltip = () => {
|
||||
setActivitySubscriptionsNudged(true)
|
||||
}
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
subscribeDialogControl.open()
|
||||
}, [subscribeDialogControl])
|
||||
|
||||
const name = createSanitizedDisplayName(profile, true)
|
||||
|
||||
const wrappedOnPress = requireEmailVerification(onPress, {
|
||||
instructions: [
|
||||
<Trans key="message">
|
||||
Before you can get notifications for {name}'s posts, you must first
|
||||
verify your email.
|
||||
</Trans>,
|
||||
],
|
||||
})
|
||||
|
||||
const isSubscribed =
|
||||
profile.viewer?.activitySubscription?.post ||
|
||||
profile.viewer?.activitySubscription?.reply
|
||||
|
||||
const Icon = isSubscribed ? BellRingingIcon : BellPlusIcon
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip.Outer
|
||||
visible={!activitySubscriptionsNudged}
|
||||
onVisibleChange={onDismissTooltip}
|
||||
position="bottom">
|
||||
<Tooltip.Target>
|
||||
<Button
|
||||
accessibilityRole="button"
|
||||
testID="dmBtn"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
shape="round"
|
||||
label={_(msg`Get notified when ${name} posts`)}
|
||||
onPress={wrappedOnPress}>
|
||||
<ButtonIcon icon={Icon} size="md" />
|
||||
</Button>
|
||||
</Tooltip.Target>
|
||||
<Tooltip.TextBubble>
|
||||
<Text>
|
||||
<Trans>Get notified about new posts</Trans>
|
||||
</Text>
|
||||
</Tooltip.TextBubble>
|
||||
</Tooltip.Outer>
|
||||
|
||||
<SubscribeProfileDialog
|
||||
control={subscribeDialogControl}
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import {useMemo, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
type AppBskyNotificationDefs,
|
||||
type AppBskyNotificationListActivitySubscriptions,
|
||||
type ModerationOpts,
|
||||
type Un$Typed,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {
|
||||
type InfiniteData,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {updateProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {RQKEY_getActivitySubscriptions} from '#/state/queries/activity-subscriptions'
|
||||
import {useAgent} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {platform, useTheme, web} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {
|
||||
Button,
|
||||
ButtonIcon,
|
||||
type ButtonProps,
|
||||
ButtonText,
|
||||
} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function SubscribeProfileDialog({
|
||||
control,
|
||||
profile,
|
||||
moderationOpts,
|
||||
includeProfile,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
includeProfile?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle />
|
||||
<DialogInner
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
includeProfile={includeProfile}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogInner({
|
||||
profile,
|
||||
moderationOpts,
|
||||
includeProfile,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
includeProfile?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const agent = useAgent()
|
||||
const control = Dialog.useDialogContext()
|
||||
const queryClient = useQueryClient()
|
||||
const initialState = parseActivitySubscription(
|
||||
profile.viewer?.activitySubscription,
|
||||
)
|
||||
const [state, setState] = useState(initialState)
|
||||
|
||||
const values = useMemo(() => {
|
||||
const {post, reply} = state
|
||||
const res = []
|
||||
if (post) res.push('post')
|
||||
if (reply) res.push('reply')
|
||||
return res
|
||||
}, [state])
|
||||
|
||||
const onChange = (newValues: string[]) => {
|
||||
setState(oldValues => {
|
||||
// ensure you can't have reply without post
|
||||
if (!oldValues.reply && newValues.includes('reply')) {
|
||||
return {
|
||||
post: true,
|
||||
reply: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (oldValues.post && !newValues.includes('post')) {
|
||||
return {
|
||||
post: false,
|
||||
reply: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
post: newValues.includes('post'),
|
||||
reply: newValues.includes('reply'),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const {
|
||||
mutate: saveChanges,
|
||||
isPending: isSaving,
|
||||
error,
|
||||
} = useMutation({
|
||||
mutationFn: async (
|
||||
activitySubscription: Un$Typed<AppBskyNotificationDefs.ActivitySubscription>,
|
||||
) => {
|
||||
await agent.app.bsky.notification.putActivitySubscription({
|
||||
subject: profile.did,
|
||||
activitySubscription,
|
||||
})
|
||||
},
|
||||
onSuccess: (_data, activitySubscription) => {
|
||||
control.close(() => {
|
||||
updateProfileShadow(queryClient, profile.did, {
|
||||
activitySubscription,
|
||||
})
|
||||
|
||||
if (!activitySubscription.post && !activitySubscription.reply) {
|
||||
logger.metric('activitySubscription:disable', {})
|
||||
Toast.show(
|
||||
_(
|
||||
msg`You will no longer receive notifications for ${sanitizeHandle(profile.handle, '@')}`,
|
||||
),
|
||||
'check',
|
||||
)
|
||||
|
||||
// filter out the subscription
|
||||
queryClient.setQueryData(
|
||||
RQKEY_getActivitySubscriptions,
|
||||
(
|
||||
old?: InfiniteData<AppBskyNotificationListActivitySubscriptions.OutputSchema>,
|
||||
) => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map(page => ({
|
||||
...page,
|
||||
subscriptions: page.subscriptions.filter(
|
||||
item => item.did !== profile.did,
|
||||
),
|
||||
})),
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
logger.metric('activitySubscription:enable', {
|
||||
setting: activitySubscription.reply ? 'posts_and_replies' : 'posts',
|
||||
})
|
||||
if (!initialState.post && !initialState.reply) {
|
||||
Toast.show(
|
||||
_(
|
||||
msg`You'll start receiving notifications for ${sanitizeHandle(profile.handle, '@')}!`,
|
||||
),
|
||||
'check',
|
||||
)
|
||||
} else {
|
||||
Toast.show(_(msg`Changes saved`), 'check')
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
onError: err => {
|
||||
logger.error('Could not save activity subscription', {message: err})
|
||||
},
|
||||
})
|
||||
|
||||
const buttonProps: Omit<ButtonProps, 'children'> = useMemo(() => {
|
||||
const isDirty =
|
||||
state.post !== initialState.post || state.reply !== initialState.reply
|
||||
const hasAny = state.post || state.reply
|
||||
|
||||
if (isDirty) {
|
||||
return {
|
||||
label: _(msg`Save changes`),
|
||||
color: hasAny ? 'primary' : 'negative',
|
||||
onPress: () => saveChanges(state),
|
||||
disabled: isSaving,
|
||||
}
|
||||
} else {
|
||||
// on web, a disabled save button feels more natural than a massive close button
|
||||
if (isWeb) {
|
||||
return {
|
||||
label: _(msg`Save changes`),
|
||||
color: 'secondary',
|
||||
disabled: true,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
label: _(msg`Cancel`),
|
||||
color: 'secondary',
|
||||
onPress: () => control.close(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [state, initialState, control, _, isSaving, saveChanges])
|
||||
|
||||
const name = createSanitizedDisplayName(profile, false)
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
style={web({maxWidth: 400})}
|
||||
label={_(msg`Get notified of new posts from ${name}`)}>
|
||||
<View style={[a.gap_lg]}>
|
||||
<View style={[a.gap_xs]}>
|
||||
<Text style={[a.font_heavy, a.text_2xl]}>
|
||||
<Trans>Keep me posted</Trans>
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
|
||||
<Trans>Get notified of this account’s activity</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{includeProfile && (
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
disabledPreview
|
||||
/>
|
||||
<ProfileCard.NameAndHandle
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
</ProfileCard.Header>
|
||||
)}
|
||||
|
||||
<Toggle.Group
|
||||
label={_(msg`Subscribe to account activity`)}
|
||||
values={values}
|
||||
onChange={onChange}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Toggle.Item
|
||||
label={_(msg`Posts`)}
|
||||
name="post"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.py_xs,
|
||||
platform({
|
||||
native: [a.justify_between],
|
||||
web: [a.flex_row_reverse, a.gap_sm],
|
||||
}),
|
||||
]}>
|
||||
<Toggle.LabelText
|
||||
style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}>
|
||||
<Trans>Posts</Trans>
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Switch />
|
||||
</Toggle.Item>
|
||||
<Toggle.Item
|
||||
label={_(msg`Replies`)}
|
||||
name="reply"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.py_xs,
|
||||
platform({
|
||||
native: [a.justify_between],
|
||||
web: [a.flex_row_reverse, a.gap_sm],
|
||||
}),
|
||||
]}>
|
||||
<Toggle.LabelText
|
||||
style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}>
|
||||
<Trans>Replies</Trans>
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Switch />
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
|
||||
{error && (
|
||||
<Admonition type="error">
|
||||
<Trans>Could not save changes: {cleanError(error)}</Trans>
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
<Button {...buttonProps} size="large" variant="solid">
|
||||
<ButtonText>{buttonProps.label}</ButtonText>
|
||||
{isSaving && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
function parseActivitySubscription(
|
||||
sub?: AppBskyNotificationDefs.ActivitySubscription,
|
||||
): Un$Typed<AppBskyNotificationDefs.ActivitySubscription> {
|
||||
if (!sub) return {post: false, reply: false}
|
||||
const {post, reply} = sub
|
||||
return {post, reply}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
|
||||
import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ActivitySubscriptionsNUX() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const nuxDialogs = useNuxDialogContext()
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
Dialog.useAutoOpen(control)
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
nuxDialogs.dismissActiveNux()
|
||||
}, [nuxDialogs])
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control} onClose={onClose}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Introducing activity notifications`)}
|
||||
style={[web({maxWidth: 400})]}
|
||||
contentContainerStyle={[
|
||||
{
|
||||
paddingTop: 0,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
gap: isWeb ? 16 : 24,
|
||||
paddingTop: isWeb ? 24 : 48,
|
||||
borderTopLeftRadius: a.rounded_md.borderRadius,
|
||||
borderTopRightRadius: a.rounded_md.borderRadius,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.pl_sm,
|
||||
a.pr_md,
|
||||
a.py_sm,
|
||||
a.rounded_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_xs,
|
||||
{
|
||||
backgroundColor: t.palette.primary_100,
|
||||
},
|
||||
]}>
|
||||
<SparkleIcon fill={t.palette.primary_800} size="sm" />
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
{
|
||||
color: t.palette.primary_800,
|
||||
},
|
||||
]}>
|
||||
<Trans>New Feature</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[a.relative, a.w_full]}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.shadow_md,
|
||||
{
|
||||
shadowOpacity: 0.4,
|
||||
top: 5,
|
||||
bottom: 0,
|
||||
left: '17%',
|
||||
right: '17%',
|
||||
width: '66%',
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.overflow_hidden,
|
||||
{
|
||||
aspectRatio: 398 / 228,
|
||||
},
|
||||
]}>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={require('../../../../assets/images/activity_notifications_announcement.webp')}
|
||||
style={[
|
||||
a.w_full,
|
||||
{
|
||||
aspectRatio: 398 / 268,
|
||||
},
|
||||
]}
|
||||
alt={_(
|
||||
msg`A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature.`,
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.px_xl,
|
||||
isWeb ? [a.pt_xl, a.gap_xl, a.pb_sm] : [a.pt_3xl, a.gap_3xl],
|
||||
]}>
|
||||
<View style={[a.gap_md, a.align_center]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_3xl,
|
||||
a.leading_tight,
|
||||
a.font_heavy,
|
||||
a.text_center,
|
||||
{
|
||||
fontSize: isWeb ? 28 : 32,
|
||||
maxWidth: 300,
|
||||
},
|
||||
]}>
|
||||
<Trans>Get notified when someone posts</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
a.text_center,
|
||||
{
|
||||
maxWidth: 340,
|
||||
},
|
||||
]}>
|
||||
<Trans>
|
||||
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.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{!isWeb && (
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onPress={() => {
|
||||
control.close()
|
||||
}}
|
||||
style={[a.w_full, {maxWidth: 280}]}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -11,12 +11,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 +33,12 @@ const queuedNuxs: {
|
||||
}) => boolean
|
||||
}[] = [
|
||||
{
|
||||
id: Nux.InitialVerificationAnnouncement,
|
||||
id: Nux.ActivitySubscriptions,
|
||||
enabled: ({currentProfile}) => {
|
||||
return isDaysOld(2, currentProfile.createdAt)
|
||||
return isExistingUserAsOf(
|
||||
'2025-07-03T00:00:00.000Z',
|
||||
currentProfile.createdAt,
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -111,7 +114,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 +122,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 +175,7 @@ function Inner({
|
||||
return (
|
||||
<Context.Provider value={ctx}>
|
||||
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
|
||||
{activeNux === Nux.InitialVerificationAnnouncement && (
|
||||
<InitialVerificationAnnouncement />
|
||||
)}
|
||||
{activeNux === Nux.ActivitySubscriptions && <ActivitySubscriptionsNUX />}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user