From 281acc0edc301638aa0ba8c025641616afb137c2 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 27 Jun 2025 22:38:08 +0300 Subject: [PATCH] Activity notifications (#8557) * add button + dialog * rename feature * rm joke * add button * add dialog to settings * tweak web styles * tweak switch logic * integrate api * add new icons * implement gating of button * add tooltip * fix import * add activity notifs to shadow * add metrics * rm timeout * Fix visibility state of native tooltip (#8581) * Fix visibility state of native tooltip * rm onLayout --------- Co-authored-by: Eric Bailey * reset nudge in settings * Make sure web tooltip closes on interaction with target * Add route to server * Remove unused label * Revert "Add route to server" This reverts commit 66bc325aed478814b0265c5b68bba7dd6a2ae213. --------- Co-authored-by: Eric Bailey --- .../bellPlus_stroke2_corner0_rounded.svg | 1 + .../bellRinging_filled_corner0_rounded.svg | 1 + src/components/Tooltip/index.tsx | 94 +++--- src/components/Tooltip/index.web.tsx | 10 +- .../SubscribeProfileButton.tsx | 89 ++++++ .../SubscribeProfileDialog.tsx | 283 ++++++++++++++++++ src/components/icons/BellPlus.tsx | 5 + src/components/icons/BellRinging.tsx | 4 + src/logger/metrics.ts | 4 + .../Profile/Header/ProfileHeaderStandard.tsx | 27 +- .../ActivityNotificationSettings.tsx | 13 +- src/screens/Settings/Settings.tsx | 15 + src/state/cache/profile-shadow.ts | 7 +- .../hooks/activity-subscriptions-nudged.ts | 8 + src/storage/schema.ts | 1 + 15 files changed, 506 insertions(+), 56 deletions(-) create mode 100644 assets/icons/bellPlus_stroke2_corner0_rounded.svg create mode 100644 assets/icons/bellRinging_filled_corner0_rounded.svg create mode 100644 src/components/activity-notifications/SubscribeProfileButton.tsx create mode 100644 src/components/activity-notifications/SubscribeProfileDialog.tsx create mode 100644 src/components/icons/BellPlus.tsx create mode 100644 src/storage/hooks/activity-subscriptions-nudged.ts diff --git a/assets/icons/bellPlus_stroke2_corner0_rounded.svg b/assets/icons/bellPlus_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..7e3349427a --- /dev/null +++ b/assets/icons/bellPlus_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/bellRinging_filled_corner0_rounded.svg b/assets/icons/bellRinging_filled_corner0_rounded.svg new file mode 100644 index 0000000000..67be5d99d3 --- /dev/null +++ b/assets/icons/bellRinging_filled_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx index 446cf18fc6..fbdb969db6 100644 --- a/src/components/Tooltip/index.tsx +++ b/src/components/Tooltip/index.tsx @@ -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 + targetMeasurements: TargetMeasurements | undefined + setTargetMeasurements: (measurements: TargetMeasurements) => void + shouldMeasure: boolean } const TooltipContext = createContext({ position: 'bottom', - ready: false, + visible: false, onVisibleChange: () => {}, }) const TargetContext = createContext({ 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(null) + const [visible, setVisible] = useState(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(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 ( @@ -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 ( diff --git a/src/components/Tooltip/index.web.tsx b/src/components/Tooltip/index.web.tsx index 739a714cd4..fc5808d7ad 100644 --- a/src/components/Tooltip/index.web.tsx +++ b/src/components/Tooltip/index.web.tsx @@ -13,10 +13,12 @@ import {Text} from '#/components/Typography' type TooltipContextType = { position: 'top' | 'bottom' + onVisibleChange: (open: boolean) => void } const TooltipContext = createContext({ 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 ( {children} @@ -54,7 +59,7 @@ export function Content({ label: string }) { const t = useTheme() - const {position} = useContext(TooltipContext) + const {position, onVisibleChange} = useContext(TooltipContext) return ( onVisibleChange(false)} style={flatten([ a.rounded_sm, select(t.name, { diff --git a/src/components/activity-notifications/SubscribeProfileButton.tsx b/src/components/activity-notifications/SubscribeProfileButton.tsx new file mode 100644 index 0000000000..71253dca9b --- /dev/null +++ b/src/components/activity-notifications/SubscribeProfileButton.tsx @@ -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: [ + + Before you can get notifications for {name}'s posts, you must first + verify your email. + , + ], + }) + + const isSubscribed = + profile.viewer?.activitySubscription?.post || + profile.viewer?.activitySubscription?.reply + + const Icon = isSubscribed ? BellRingingIcon : BellPlusIcon + + return ( + <> + + + + + + + Get notified about new posts + + + + + + + ) +} diff --git a/src/components/activity-notifications/SubscribeProfileDialog.tsx b/src/components/activity-notifications/SubscribeProfileDialog.tsx new file mode 100644 index 0000000000..1818735cda --- /dev/null +++ b/src/components/activity-notifications/SubscribeProfileDialog.tsx @@ -0,0 +1,283 @@ +import {useMemo, useState} from 'react' +import {View} from 'react-native' +import { + type AppBskyNotificationDefs, + type ModerationOpts, + type Un$Typed, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {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 {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 ( + + + + + ) +} + +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, + ) => { + 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', + ) + } 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 = 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 ( + + + + + Keep me posted + + + Get notified of this account’s activity + + + + {includeProfile && ( + + + + + )} + + + + + + Posts + + + + + + Replies + + + + + + + {error && ( + + Could not save changes: {cleanError(error)} + + )} + + + + + + + ) +} + +function parseActivitySubscription( + sub?: AppBskyNotificationDefs.ActivitySubscription, +): Un$Typed { + if (!sub) return {post: false, reply: false} + const {post, reply} = sub + return {post, reply} +} 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/logger/metrics.ts b/src/logger/metrics.ts index 31af1be2b0..bbde6c9bc0 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -443,4 +443,8 @@ export type MetricEvents = { [key: string]: any } 'thread:click:headerMenuOpen': {} + 'activitySubscription:enable': { + setting: 'posts' | 'posts_and_replies' + } + 'activitySubscription:disable': {} } diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 1639abaf0c..5dbf32c57f 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -15,7 +15,6 @@ import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {isIOS} from '#/platform/detection' import {useProfileShadow} from '#/state/cache/profile-shadow' -import {type Shadow} from '#/state/cache/types' import { useProfileBlockMutationQueue, useProfileFollowMutationQueue, @@ -24,6 +23,7 @@ import {useRequireAuth, useSession} from '#/state/session' import {ProfileMenu} from '#/view/com/profile/ProfileMenu' import * as Toast from '#/view/com/util/Toast' import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf' +import {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {MessageProfileButton} from '#/components/dms/MessageProfileButton' @@ -58,8 +58,8 @@ let ProfileHeaderStandard = ({ }: Props): React.ReactNode => { const t = useTheme() const {gtMobile} = useBreakpoints() - const profile: Shadow = - useProfileShadow(profileUnshadowed) + const profile = + useProfileShadow(profileUnshadowed) const {currentAccount, hasSession} = useSession() const {_} = useLingui() const moderation = useMemo( @@ -134,13 +134,26 @@ let ProfileHeaderStandard = ({ } }, [_, queueUnblock]) - const isMe = React.useMemo( + const isMe = useMemo( () => currentAccount?.did === profile.did, [currentAccount, profile], ) const {isActive: live} = useActorStatus(profile) + const subscriptionsAllowed = useMemo(() => { + switch (profile.associated?.activitySubscription?.allowSubscriptions) { + case 'followers': + case undefined: + return !!profile.viewer?.following + case 'mutuals': + return !!profile.viewer?.following && !!profile.viewer.followedBy + case 'none': + default: + return false + } + }, [profile]) + return ( + {hasSession && subscriptionsAllowed && ( + + )} {hasSession && }