diff options
author | Samuel Newman <mozzius@protonmail.com> | 2025-07-02 00:36:04 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-07-01 14:36:04 -0700 |
commit | bc072570d27e1f397406daea355570f5aec95647 (patch) | |
tree | 0d698c0bababd9b5e221df763a1ab15744ebdb71 /src | |
parent | 8f9a8ddce022e328b07b793c3f1500e1c423ef73 (diff) | |
download | voidsky-bc072570d27e1f397406daea355570f5aec95647.tar.zst |
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>
Diffstat (limited to 'src')
46 files changed, 1715 insertions, 197 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx index f1a9c569d..26a2b2a2a 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -84,6 +84,7 @@ import {SearchScreen} from '#/screens/Search' import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings' import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings' import {AccountSettingsScreen} from '#/screens/Settings/AccountSettings' +import {ActivityPrivacySettingsScreen} from '#/screens/Settings/ActivityPrivacySettings' import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings' import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords' @@ -109,8 +110,10 @@ import { } from '#/components/dialogs/EmailDialog' import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' +import {NotificationsActivityListScreen} from './screens/Notifications/ActivityList' import {LegacyNotificationSettingsScreen} from './screens/Settings/LegacyNotificationSettings' import {NotificationSettingsScreen} from './screens/Settings/NotificationSettings' +import {ActivityNotificationSettingsScreen} from './screens/Settings/NotificationSettings/ActivityNotificationSettings' import {LikeNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikeNotificationSettings' import {LikesOnRepostsNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings' import {MentionNotificationSettingsScreen} from './screens/Settings/NotificationSettings/MentionNotificationSettings' @@ -391,6 +394,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { }} /> <Stack.Screen + name="ActivityPrivacySettings" + getComponent={() => ActivityPrivacySettingsScreen} + options={{ + title: title(msg`Privacy and Security`), + requireAuth: true, + }} + /> + <Stack.Screen name="NotificationSettings" getComponent={() => NotificationSettingsScreen} options={{title: title(msg`Notification settings`), requireAuth: true}} @@ -460,6 +471,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { }} /> <Stack.Screen + name="ActivityNotificationSettings" + getComponent={() => ActivityNotificationSettingsScreen} + options={{ + title: title(msg`Activity notifications`), + requireAuth: true, + }} + /> + <Stack.Screen name="MiscellaneousNotificationSettings" getComponent={() => MiscellaneousNotificationSettingsScreen} options={{ @@ -525,6 +544,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { options={{title: title(msg`Chat request inbox`), requireAuth: true}} /> <Stack.Screen + name="NotificationsActivityList" + getComponent={() => NotificationsActivityListScreen} + options={{title: title(msg`Notifications`), requireAuth: true}} + /> + <Stack.Screen name="LegacyNotificationSettings" getComponent={() => LegacyNotificationSettingsScreen} options={{title: title(msg`Notification settings`), requireAuth: true}} diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 30b26bead..4aec74880 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -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> ) } diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx index 446cf18fc..fbdb969db 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<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({ @@ -69,20 +72,11 @@ export function Outer({ 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> diff --git a/src/components/Tooltip/index.web.tsx b/src/components/Tooltip/index.web.tsx index 739a714cd..fc5808d7a 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<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, { diff --git a/src/components/activity-notifications/SubscribeProfileButton.tsx b/src/components/activity-notifications/SubscribeProfileButton.tsx new file mode 100644 index 000000000..71253dca9 --- /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: [ + <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} + /> + </> + ) +} diff --git a/src/components/activity-notifications/SubscribeProfileDialog.tsx b/src/components/activity-notifications/SubscribeProfileDialog.tsx new file mode 100644 index 000000000..d1ab2842d --- /dev/null +++ b/src/components/activity-notifications/SubscribeProfileDialog.tsx @@ -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} +} diff --git a/src/components/dialogs/nuxs/ActivitySubscriptions.tsx b/src/components/dialogs/nuxs/ActivitySubscriptions.tsx new file mode 100644 index 000000000..b9f3979ed --- /dev/null +++ b/src/components/dialogs/nuxs/ActivitySubscriptions.tsx @@ -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> + ) +} diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx index 11377e1de..8096a0141 100644 --- a/src/components/dialogs/nuxs/index.tsx +++ b/src/components/dialogs/nuxs/index.tsx @@ -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> ) } diff --git a/src/components/dialogs/nuxs/utils.ts b/src/components/dialogs/nuxs/utils.ts index 0cc510484..ba8f0169d 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/icons/BellPlus.tsx b/src/components/icons/BellPlus.tsx new file mode 100644 index 000000000..cd29de197 --- /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 b174fcedc..11981a7a3 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/lib/api/feed/list.ts b/src/lib/api/feed/list.ts index 9744e3d4c..9697b0aaf 100644 --- a/src/lib/api/feed/list.ts +++ b/src/lib/api/feed/list.ts @@ -1,20 +1,20 @@ import { - AppBskyFeedDefs, - AppBskyFeedGetListFeed as GetListFeed, - BskyAgent, + type Agent, + type AppBskyFeedDefs, + type AppBskyFeedGetListFeed as GetListFeed, } from '@atproto/api' -import {FeedAPI, FeedAPIResponse} from './types' +import {type FeedAPI, type FeedAPIResponse} from './types' export class ListFeedAPI implements FeedAPI { - agent: BskyAgent + agent: Agent params: GetListFeed.QueryParams constructor({ agent, feedParams, }: { - agent: BskyAgent + agent: Agent feedParams: GetListFeed.QueryParams }) { this.agent = agent diff --git a/src/lib/api/feed/posts.ts b/src/lib/api/feed/posts.ts new file mode 100644 index 000000000..33eff5099 --- /dev/null +++ b/src/lib/api/feed/posts.ts @@ -0,0 +1,52 @@ +import { + type Agent, + type AppBskyFeedDefs, + type AppBskyFeedGetPosts, +} from '@atproto/api' + +import {logger} from '#/logger' +import {type FeedAPI, type FeedAPIResponse} from './types' + +export class PostListFeedAPI implements FeedAPI { + agent: Agent + params: AppBskyFeedGetPosts.QueryParams + peek: AppBskyFeedDefs.FeedViewPost | null = null + + constructor({ + agent, + feedParams, + }: { + agent: Agent + feedParams: AppBskyFeedGetPosts.QueryParams + }) { + this.agent = agent + if (feedParams.uris.length > 25) { + logger.warn( + `Too many URIs provided - expected 25, got ${feedParams.uris.length}`, + ) + } + this.params = { + uris: feedParams.uris.slice(0, 25), + } + } + + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { + if (this.peek) return this.peek + throw new Error('Has not fetched yet') + } + + async fetch({}: {}): Promise<FeedAPIResponse> { + const res = await this.agent.app.bsky.feed.getPosts({ + ...this.params, + }) + if (res.success) { + this.peek = {post: res.data.posts[0]} + return { + feed: res.data.posts.map(post => ({post})), + } + } + return { + feed: [], + } + } +} diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts index 311f38a79..6c3e7deb8 100644 --- a/src/lib/hooks/useNotificationHandler.ts +++ b/src/lib/hooks/useNotificationHandler.ts @@ -1,6 +1,6 @@ import {useEffect} from 'react' import * as Notifications from 'expo-notifications' -import {type AppBskyNotificationListNotifications} from '@atproto/api' +import {AtUri} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {CommonActions, useNavigation} from '@react-navigation/native' @@ -32,6 +32,7 @@ export type NotificationReason = | 'repost-via-repost' | 'verified' | 'unverified' + | 'subscribed-post' /** * Manually overridden type, but retains the possibility of @@ -112,61 +113,68 @@ export function useNotificationsHandler() { }) Notifications.setNotificationChannelAsync( - 'like' satisfies AppBskyNotificationListNotifications.Notification['reason'], + 'like' satisfies NotificationReason, { name: _(msg`Likes`), importance: Notifications.AndroidImportance.HIGH, }, ) Notifications.setNotificationChannelAsync( - 'repost' satisfies AppBskyNotificationListNotifications.Notification['reason'], + 'repost' satisfies NotificationReason, { name: _(msg`Reposts`), importance: Notifications.AndroidImportance.HIGH, }, ) Notifications.setNotificationChannelAsync( - 'reply' satisfies AppBskyNotificationListNotifications.Notification['reason'], + 'reply' satisfies NotificationReason, { name: _(msg`Replies`), importance: Notifications.AndroidImportance.HIGH, }, ) Notifications.setNotificationChannelAsync( - 'mention' satisfies AppBskyNotificationListNotifications.Notification['reason'], + 'mention' satisfies NotificationReason, { name: _(msg`Mentions`), importance: Notifications.AndroidImportance.HIGH, }, ) Notifications.setNotificationChannelAsync( - 'quote' satisfies AppBskyNotificationListNotifications.Notification['reason'], + 'quote' satisfies NotificationReason, { name: _(msg`Quotes`), importance: Notifications.AndroidImportance.HIGH, }, ) Notifications.setNotificationChannelAsync( - 'follow' satisfies AppBskyNotificationListNotifications.Notification['reason'], + 'follow' satisfies NotificationReason, { name: _(msg`New followers`), importance: Notifications.AndroidImportance.HIGH, }, ) Notifications.setNotificationChannelAsync( - 'like-via-repost' satisfies AppBskyNotificationListNotifications.Notification['reason'], + 'like-via-repost' satisfies NotificationReason, { name: _(msg`Likes of your reposts`), importance: Notifications.AndroidImportance.HIGH, }, ) Notifications.setNotificationChannelAsync( - 'repost-via-repost' satisfies AppBskyNotificationListNotifications.Notification['reason'], + 'repost-via-repost' satisfies NotificationReason, { name: _(msg`Reposts of your reposts`), importance: Notifications.AndroidImportance.HIGH, }, ) + Notifications.setNotificationChannelAsync( + 'subscribed-post' satisfies NotificationReason, + { + name: _(msg`Activity from others`), + importance: Notifications.AndroidImportance.HIGH, + }, + ) }, [_]) useEffect(() => { @@ -220,6 +228,23 @@ export function useNotificationsHandler() { } } else { switch (payload.reason) { + case 'subscribed-post': + const urip = new AtUri(payload.uri) + if (urip.collection === 'app.bsky.feed.post') { + setTimeout(() => { + // @ts-expect-error types are weird here + navigation.navigate('HomeTab', { + screen: 'PostThread', + params: { + name: urip.host, + rkey: urip.rkey, + }, + }) + }, 500) + } else { + resetToTab('NotificationsTab') + } + break case 'like': case 'repost': case 'follow': @@ -231,6 +256,7 @@ export function useNotificationsHandler() { case 'repost-via-repost': case 'verified': case 'unverified': + default: resetToTab('NotificationsTab') break // TODO implement these after we have an idea of how to handle each individual case diff --git a/src/lib/moderation/create-sanitized-display-name.ts b/src/lib/moderation/create-sanitized-display-name.ts index 4f9584f91..4c62a5c03 100644 --- a/src/lib/moderation/create-sanitized-display-name.ts +++ b/src/lib/moderation/create-sanitized-display-name.ts @@ -1,12 +1,9 @@ -import {AppBskyActorDefs} from '@atproto/api' - import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' +import type * as bsky from '#/types/bsky' export function createSanitizedDisplayName( - profile: - | AppBskyActorDefs.ProfileViewBasic - | AppBskyActorDefs.ProfileViewDetailed, + profile: bsky.profile.AnyProfileView, noAt = false, ) { if (profile.displayName != null && profile.displayName !== '') { diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index c92be34c2..b1db5caa6 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -51,6 +51,7 @@ export type CommonNavigatorParams = { AppearanceSettings: undefined AccountSettings: undefined PrivacyAndSecuritySettings: undefined + ActivityPrivacySettings: undefined ContentAndMediaSettings: undefined NotificationSettings: undefined ReplyNotificationSettings: undefined @@ -72,6 +73,7 @@ export type CommonNavigatorParams = { MessagesConversation: {conversation: string; embed?: string; accept?: true} MessagesSettings: undefined MessagesInbox: undefined + NotificationsActivityList: {posts: string} LegacyNotificationSettings: undefined Feeds: undefined Start: {name: string; rkey: string} diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index fca3f609a..3b1106480 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -7,7 +7,6 @@ export type Gate = | 'old_postonboarding' | 'onboarding_add_video_feed' | 'post_threads_v2_unspecced' - | 'reengagement_features' | 'remove_show_latest_button' | 'test_gate_1' | 'test_gate_2' diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index 31af1be2b..d18e69122 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -443,4 +443,17 @@ export type MetricEvents = { [key: string]: any } 'thread:click:headerMenuOpen': {} + 'activitySubscription:enable': { + setting: 'posts' | 'posts_and_replies' + } + 'activitySubscription:disable': {} + 'activityPreference:changeChannels': { + name: string + push: boolean + list: boolean + } + 'activityPreference:changeFilter': { + name: string + value: string + } } diff --git a/src/routes.ts b/src/routes.ts index b66a0ae53..7fc673e2b 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -11,6 +11,7 @@ export const router = new Router<AllNavigatableRoutes>({ Search: '/search', Feeds: '/feeds', Notifications: '/notifications', + NotificationsActivityList: '/notifications/activity', LegacyNotificationSettings: '/notifications/settings', Settings: '/settings', Lists: '/lists', @@ -50,6 +51,7 @@ export const router = new Router<AllNavigatableRoutes>({ SavedFeeds: '/settings/saved-feeds', AccountSettings: '/settings/account', PrivacyAndSecuritySettings: '/settings/privacy-and-security', + ActivityPrivacySettings: '/settings/privacy-and-security/activity', ContentAndMediaSettings: '/settings/content-and-media', InterestsSettings: '/settings/interests', AboutSettings: '/settings/about', diff --git a/src/screens/Notifications/ActivityList.tsx b/src/screens/Notifications/ActivityList.tsx new file mode 100644 index 000000000..f87e34008 --- /dev/null +++ b/src/screens/Notifications/ActivityList.tsx @@ -0,0 +1,44 @@ +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {type NativeStackScreenProps} from '@react-navigation/native-stack' + +import {type AllNavigatorParams} from '#/lib/routes/types' +import {PostFeed} from '#/view/com/posts/PostFeed' +import {EmptyState} from '#/view/com/util/EmptyState' +import * as Layout from '#/components/Layout' +import {ListFooter} from '#/components/Lists' + +type Props = NativeStackScreenProps< + AllNavigatorParams, + 'NotificationsActivityList' +> +export function NotificationsActivityListScreen({ + route: { + params: {posts}, + }, +}: Props) { + const uris = decodeURIComponent(posts) + const {_} = useLingui() + + return ( + <Layout.Screen testID="NotificationsActivityListScreen"> + <Layout.Header.Outer> + <Layout.Header.BackButton /> + <Layout.Header.Content> + <Layout.Header.TitleText> + <Trans>Notifications</Trans> + </Layout.Header.TitleText> + </Layout.Header.Content> + <Layout.Header.Slot /> + </Layout.Header.Outer> + <PostFeed + feed={`posts|${uris}`} + disablePoll + renderEmptyState={() => ( + <EmptyState icon="growth" message={_(msg`No posts here`)} /> + )} + renderEndOfFeed={() => <ListFooter />} + /> + </Layout.Screen> + ) +} diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 1639abaf0..5dbf32c57 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<AppBskyActorDefs.ProfileViewDetailed> = - useProfileShadow(profileUnshadowed) + const profile = + useProfileShadow<AppBskyActorDefs.ProfileViewDetailed>(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 ( <ProfileHeaderShell profile={profile} @@ -198,6 +211,12 @@ let ProfileHeaderStandard = ({ ) ) : !profile.viewer?.blockedBy ? ( <> + {hasSession && subscriptionsAllowed && ( + <SubscribeProfileButton + profile={profile} + moderationOpts={moderationOpts} + /> + )} {hasSession && <MessageProfileButton profile={profile} />} <Button diff --git a/src/screens/Settings/AccessibilitySettings.tsx b/src/screens/Settings/AccessibilitySettings.tsx index ee26697d2..dbabd2f6f 100644 --- a/src/screens/Settings/AccessibilitySettings.tsx +++ b/src/screens/Settings/AccessibilitySettings.tsx @@ -1,8 +1,8 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {type NativeStackScreenProps} from '@react-navigation/native-stack' -import {CommonNavigatorParams} from '#/lib/routes/types' +import {type CommonNavigatorParams} from '#/lib/routes/types' import {isNative} from '#/platform/detection' import { useHapticsDisabled, @@ -16,12 +16,10 @@ import { } from '#/state/preferences/large-alt-badge' import * as SettingsList from '#/screens/Settings/components/SettingsList' import {atoms as a} from '#/alf' -import {Admonition} from '#/components/Admonition' import * as Toggle from '#/components/forms/Toggle' import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic' import * as Layout from '#/components/Layout' -import {InlineLinkText} from '#/components/Link' type Props = NativeStackScreenProps< CommonNavigatorParams, @@ -100,19 +98,6 @@ export function AccessibilitySettingsScreen({}: Props) { </SettingsList.Group> </> )} - <SettingsList.Item> - <Admonition type="info" style={[a.flex_1]}> - <Trans> - Autoplay options have moved to the{' '} - <InlineLinkText - to="/settings/content-and-media" - label={_(msg`Content and media`)}> - Content and Media settings - </InlineLinkText> - . - </Trans> - </Admonition> - </SettingsList.Item> </SettingsList.Container> </Layout.Content> </Layout.Screen> diff --git a/src/screens/Settings/ActivityPrivacySettings.tsx b/src/screens/Settings/ActivityPrivacySettings.tsx new file mode 100644 index 000000000..988195a36 --- /dev/null +++ b/src/screens/Settings/ActivityPrivacySettings.tsx @@ -0,0 +1,140 @@ +import {View} from 'react-native' +import {type AppBskyNotificationDeclaration} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import { + type AllNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import { + useNotificationDeclarationMutation, + useNotificationDeclarationQuery, +} from '#/state/queries/activity-subscriptions' +import {atoms as a, useTheme} from '#/alf' +import {Admonition} from '#/components/Admonition' +import * as Toggle from '#/components/forms/Toggle' +import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' +import * as Layout from '#/components/Layout' +import {Loader} from '#/components/Loader' +import * as SettingsList from './components/SettingsList' +import {ItemTextWithSubtitle} from './NotificationSettings/components/ItemTextWithSubtitle' + +type Props = NativeStackScreenProps< + AllNavigatorParams, + 'ActivityPrivacySettings' +> +export function ActivityPrivacySettingsScreen({}: Props) { + const { + data: notificationDeclaration, + isPending, + isError, + } = useNotificationDeclarationQuery() + + return ( + <Layout.Screen> + <Layout.Header.Outer> + <Layout.Header.BackButton /> + <Layout.Header.Content> + <Layout.Header.TitleText> + <Trans>Privacy and Security</Trans> + </Layout.Header.TitleText> + </Layout.Header.Content> + <Layout.Header.Slot /> + </Layout.Header.Outer> + <Layout.Content> + <SettingsList.Container> + <SettingsList.Item style={[a.align_start]}> + <SettingsList.ItemIcon icon={BellRingingIcon} /> + <ItemTextWithSubtitle + bold + titleText={ + <Trans>Allow others to be notified of your posts</Trans> + } + subtitleText={ + <Trans> + This feature allows users to receive notifications for your + new posts and replies. Who do you want to enable this for? + </Trans> + } + /> + </SettingsList.Item> + <View style={[a.px_xl, a.pt_md]}> + {isError ? ( + <Admonition type="error"> + <Trans>Failed to load preference.</Trans> + </Admonition> + ) : isPending ? ( + <View style={[a.w_full, a.pt_5xl, a.align_center]}> + <Loader size="xl" /> + </View> + ) : ( + <Inner notificationDeclaration={notificationDeclaration} /> + )} + </View> + </SettingsList.Container> + </Layout.Content> + </Layout.Screen> + ) +} + +export function Inner({ + notificationDeclaration, +}: { + notificationDeclaration: { + uri?: string + cid?: string + value: AppBskyNotificationDeclaration.Record + } +}) { + const t = useTheme() + const {_} = useLingui() + const {mutate} = useNotificationDeclarationMutation() + + const onChangeFilter = ([declaration]: string[]) => { + mutate({ + $type: 'app.bsky.notification.declaration', + allowSubscriptions: declaration, + }) + } + + return ( + <Toggle.Group + type="radio" + label={_( + msg`Filter who can opt to receive notifications for your activity`, + )} + values={[notificationDeclaration.value.allowSubscriptions]} + onChange={onChangeFilter}> + <View style={[a.gap_sm]}> + <Toggle.Item + label={_(msg`Anyone who follows me`)} + name="followers" + style={[a.flex_row, a.py_xs, a.gap_sm]}> + <Toggle.Radio /> + <Toggle.LabelText style={[t.atoms.text, a.font_normal, a.text_md]}> + <Trans>Anyone who follows me</Trans> + </Toggle.LabelText> + </Toggle.Item> + <Toggle.Item + label={_(msg`Only followers who I follow`)} + name="mutuals" + style={[a.flex_row, a.py_xs, a.gap_sm]}> + <Toggle.Radio /> + <Toggle.LabelText style={[t.atoms.text, a.font_normal, a.text_md]}> + <Trans>Only followers who I follow</Trans> + </Toggle.LabelText> + </Toggle.Item> + <Toggle.Item + label={_(msg`No one`)} + name="none" + style={[a.flex_row, a.py_xs, a.gap_sm]}> + <Toggle.Radio /> + <Toggle.LabelText style={[t.atoms.text, a.font_normal, a.text_md]}> + <Trans>No one</Trans> + </Toggle.LabelText> + </Toggle.Item> + </View> + </Toggle.Group> + ) +} diff --git a/src/screens/Settings/AppPasswords.tsx b/src/screens/Settings/AppPasswords.tsx index 9a900a3ee..05ebcd80d 100644 --- a/src/screens/Settings/AppPasswords.tsx +++ b/src/screens/Settings/AppPasswords.tsx @@ -7,12 +7,12 @@ import Animated, { LinearTransition, StretchOutY, } from 'react-native-reanimated' -import {ComAtprotoServerListAppPasswords} from '@atproto/api' +import {type ComAtprotoServerListAppPasswords} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {type NativeStackScreenProps} from '@react-navigation/native-stack' -import {CommonNavigatorParams} from '#/lib/routes/types' +import {type CommonNavigatorParams} from '#/lib/routes/types' import {cleanError} from '#/lib/strings/errors' import {isWeb} from '#/platform/detection' import { diff --git a/src/screens/Settings/AppearanceSettings.tsx b/src/screens/Settings/AppearanceSettings.tsx index 4a8a61cd2..d0158aaa8 100644 --- a/src/screens/Settings/AppearanceSettings.tsx +++ b/src/screens/Settings/AppearanceSettings.tsx @@ -1,4 +1,4 @@ -import React, {useCallback} from 'react' +import {useCallback} from 'react' import Animated, { FadeInUp, FadeOutUp, @@ -9,14 +9,17 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {IS_INTERNAL} from '#/lib/app-info' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' import {useGate} from '#/lib/statsig/statsig' import {isNative} from '#/platform/detection' import {useSetThemePrefs, useThemePrefs} from '#/state/shell' import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem' import {atoms as a, native, useAlf, useTheme} from '#/alf' import * as ToggleButton from '#/components/forms/ToggleButton' -import {Props as SVGIconProps} from '#/components/icons/common' +import {type Props as SVGIconProps} from '#/components/icons/common' import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon' import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone' import {TextSize_Stroke2_Corner0_Rounded as TextSize} from '#/components/icons/TextSize' diff --git a/src/screens/Settings/ExternalMediaPreferences.tsx b/src/screens/Settings/ExternalMediaPreferences.tsx index ae859295f..1f0040fb3 100644 --- a/src/screens/Settings/ExternalMediaPreferences.tsx +++ b/src/screens/Settings/ExternalMediaPreferences.tsx @@ -2,9 +2,12 @@ import {Fragment} from 'react' import {View} from 'react-native' import {Trans} from '@lingui/macro' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' import { - EmbedPlayerSource, + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import { + type EmbedPlayerSource, externalEmbedLabels, } from '#/lib/strings/embed-player' import { diff --git a/src/screens/Settings/FollowingFeedPreferences.tsx b/src/screens/Settings/FollowingFeedPreferences.tsx index ea9455ab1..7f1ae1d32 100644 --- a/src/screens/Settings/FollowingFeedPreferences.tsx +++ b/src/screens/Settings/FollowingFeedPreferences.tsx @@ -1,7 +1,10 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' import { usePreferencesQuery, useSetFeedViewPreferencesMutation, diff --git a/src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx new file mode 100644 index 000000000..b00170f3a --- /dev/null +++ b/src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx @@ -0,0 +1,263 @@ +import {useCallback, useMemo} from 'react' +import {type ListRenderItemInfo, Text as RNText, View} from 'react-native' +import {type ModerationOpts} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import { + type AllNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useActivitySubscriptionsQuery} from '#/state/queries/activity-subscriptions' +import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings' +import {List} from '#/view/com/util/List' +import {atoms as a, useTheme} from '#/alf' +import {SubscribeProfileDialog} from '#/components/activity-notifications/SubscribeProfileDialog' +import * as Admonition from '#/components/Admonition' +import {Button, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {BellRinging_Filled_Corner0_Rounded as BellRingingFilledIcon} from '#/components/icons/BellRinging' +import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' +import * as Layout from '#/components/Layout' +import {InlineLinkText} from '#/components/Link' +import {ListFooter} from '#/components/Lists' +import {Loader} from '#/components/Loader' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' +import * as SettingsList from '../components/SettingsList' +import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle' +import {PreferenceControls} from './components/PreferenceControls' + +type Props = NativeStackScreenProps< + AllNavigatorParams, + 'ActivityNotificationSettings' +> +export function ActivityNotificationSettingsScreen({}: Props) { + const t = useTheme() + const {_} = useLingui() + const {data: preferences, isError} = useNotificationSettingsQuery() + + const moderationOpts = useModerationOpts() + + const { + data: subscriptions, + isPending, + error, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + } = useActivitySubscriptionsQuery() + + const items = useMemo(() => { + if (!subscriptions) return [] + return subscriptions?.pages.flatMap(page => page.subscriptions) + }, [subscriptions]) + + const renderItem = useCallback( + ({item}: ListRenderItemInfo<bsky.profile.AnyProfileView>) => { + if (!moderationOpts) return null + return ( + <ActivitySubscriptionCard + profile={item} + moderationOpts={moderationOpts} + /> + ) + }, + [moderationOpts], + ) + + const onEndReached = useCallback(async () => { + if (isFetchingNextPage || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more likes', {message: err}) + } + }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) + + return ( + <Layout.Screen> + <Layout.Header.Outer> + <Layout.Header.BackButton /> + <Layout.Header.Content> + <Layout.Header.TitleText> + <Trans>Notifications</Trans> + </Layout.Header.TitleText> + </Layout.Header.Content> + <Layout.Header.Slot /> + </Layout.Header.Outer> + <List + ListHeaderComponent={ + <SettingsList.Container> + <SettingsList.Item style={[a.align_start]}> + <SettingsList.ItemIcon icon={BellRingingIcon} /> + <ItemTextWithSubtitle + bold + titleText={<Trans>Activity from others</Trans>} + subtitleText={ + <Trans> + Get notified about posts and replies from accounts you + choose. + </Trans> + } + /> + </SettingsList.Item> + {isError ? ( + <View style={[a.px_lg, a.pt_md]}> + <Admonition.Admonition type="error"> + <Trans>Failed to load notification settings.</Trans> + </Admonition.Admonition> + </View> + ) : ( + <PreferenceControls + name="subscribedPost" + preference={preferences?.subscribedPost} + /> + )} + </SettingsList.Container> + } + data={items} + keyExtractor={keyExtractor} + renderItem={renderItem} + onEndReached={onEndReached} + onEndReachedThreshold={4} + ListEmptyComponent={ + error ? null : ( + <View style={[a.px_xl, a.py_md]}> + {!isPending ? ( + <Admonition.Outer type="tip"> + <Admonition.Row> + <Admonition.Icon /> + <View style={[a.flex_1, a.gap_sm]}> + <Admonition.Text> + <Trans> + Enable notifications for an account by visiting their + profile and pressing the{' '} + <RNText + style={[a.font_bold, t.atoms.text_contrast_high]}> + bell icon + </RNText>{' '} + <BellRingingFilledIcon + size="xs" + style={t.atoms.text_contrast_high} + /> + . + </Trans> + </Admonition.Text> + <Admonition.Text> + <Trans> + If you want to restrict who can receive notifications + for your account's activity, you can change this in{' '} + <InlineLinkText + label={_(msg`Privacy and Security settings`)} + to={{screen: 'ActivityPrivacySettings'}} + style={[a.font_bold]}> + Settings → Privacy and Security + </InlineLinkText> + . + </Trans> + </Admonition.Text> + </View> + </Admonition.Row> + </Admonition.Outer> + ) : ( + <View style={[a.flex_1, a.align_center, a.pt_xl]}> + <Loader size="lg" /> + </View> + )} + </View> + ) + } + ListFooterComponent={ + <ListFooter + style={[items.length === 0 && a.border_transparent]} + isFetchingNextPage={isFetchingNextPage} + error={cleanError(error)} + onRetry={fetchNextPage} + hasNextPage={hasNextPage} + /> + } + windowSize={11} + /> + </Layout.Screen> + ) +} + +function keyExtractor(item: bsky.profile.AnyProfileView) { + return item.did +} + +function ActivitySubscriptionCard({ + profile: profileUnshadowed, + moderationOpts, +}: { + profile: bsky.profile.AnyProfileView + moderationOpts: ModerationOpts +}) { + const profile = useProfileShadow(profileUnshadowed) + const control = useDialogControl() + const {_} = useLingui() + const t = useTheme() + + const preview = useMemo(() => { + const actSub = profile.viewer?.activitySubscription + if (actSub?.post && actSub?.reply) { + return _(msg`Posts, Replies`) + } else if (actSub?.post) { + return _(msg`Posts`) + } else if (actSub?.reply) { + return _(msg`Replies`) + } + return _(msg`None`) + }, [_, profile.viewer?.activitySubscription]) + + return ( + <View style={[a.py_md, a.px_xl, a.border_t, t.atoms.border_contrast_low]}> + <ProfileCard.Outer> + <ProfileCard.Header> + <ProfileCard.Avatar + profile={profile} + moderationOpts={moderationOpts} + /> + <View style={[a.flex_1, a.gap_2xs]}> + <ProfileCard.NameAndHandle + profile={profile} + moderationOpts={moderationOpts} + inline + /> + <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}> + {preview} + </Text> + </View> + <Button + label={_( + msg`Edit notifications from ${createSanitizedDisplayName( + profile, + )}`, + )} + size="small" + color="primary" + variant="solid" + onPress={control.open}> + <ButtonText> + <Trans>Edit</Trans> + </ButtonText> + </Button> + </ProfileCard.Header> + </ProfileCard.Outer> + + <SubscribeProfileDialog + control={control} + profile={profile} + moderationOpts={moderationOpts} + includeProfile + /> + </View> + ) +} diff --git a/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx b/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx index 487827d66..ce46541fd 100644 --- a/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx +++ b/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx @@ -5,7 +5,7 @@ import {type FilterablePreference} from '@atproto/api/dist/client/types/app/bsky import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useGate} from '#/lib/statsig/statsig' +import {logger} from '#/logger' import {useNotificationSettingsUpdateMutation} from '#/state/queries/notifications/settings' import {atoms as a, platform, useTheme} from '#/alf' import * as Toggle from '#/components/forms/Toggle' @@ -28,10 +28,6 @@ export function PreferenceControls({ preference?: AppBskyNotificationDefs.Preference | FilterablePreference allowDisableInApp?: boolean }) { - const gate = useGate() - - if (!gate('reengagement_features')) return null - if (!preference) return ( <View style={[a.w_full, a.pt_5xl, a.align_center]}> @@ -78,6 +74,12 @@ export function Inner({ push: change.includes('push'), } satisfies typeof preference + logger.metric('activityPreference:changeChannels', { + name, + push: newPreference.push, + list: newPreference.list, + }) + mutate({ [name]: newPreference, ...Object.fromEntries(syncOthers.map(key => [key, newPreference])), @@ -93,6 +95,8 @@ export function Inner({ include: change, } satisfies typeof preference + logger.metric('activityPreference:changeFilter', {name, value: change}) + mutate({ [name]: newPreference, ...Object.fromEntries(syncOthers.map(key => [key, newPreference])), @@ -114,7 +118,7 @@ export function Inner({ a.py_xs, platform({ native: [a.justify_between], - web: [a.flex_row_reverse, a.gap_md], + web: [a.flex_row_reverse, a.gap_sm], }), ]}> <Toggle.LabelText @@ -131,7 +135,7 @@ export function Inner({ a.py_xs, platform({ native: [a.justify_between], - web: [a.flex_row_reverse, a.gap_md], + web: [a.flex_row_reverse, a.gap_sm], }), ]}> <Toggle.LabelText @@ -159,11 +163,7 @@ export function Inner({ <Toggle.Item label={_(msg`Everyone`)} name="all" - style={[ - a.flex_row, - a.py_xs, - platform({native: [a.gap_sm], web: [a.gap_md]}), - ]}> + style={[a.flex_row, a.py_xs, a.gap_sm]}> <Toggle.Radio /> <Toggle.LabelText style={[ @@ -177,11 +177,7 @@ export function Inner({ <Toggle.Item label={_(msg`People I follow`)} name="follows" - style={[ - a.flex_row, - a.py_xs, - platform({native: [a.gap_sm], web: [a.gap_md]}), - ]}> + style={[a.flex_row, a.py_xs, a.gap_sm]}> <Toggle.Radio /> <Toggle.LabelText style={[ diff --git a/src/screens/Settings/NotificationSettings/index.tsx b/src/screens/Settings/NotificationSettings/index.tsx index 800493575..df7c9a35b 100644 --- a/src/screens/Settings/NotificationSettings/index.tsx +++ b/src/screens/Settings/NotificationSettings/index.tsx @@ -16,7 +16,7 @@ import {useNotificationSettingsQuery} from '#/state/queries/notifications/settin import {atoms as a} from '#/alf' import {Admonition} from '#/components/Admonition' import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At' -// import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' +import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble' import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic' import { @@ -183,20 +183,19 @@ export function NotificationSettingsScreen({}: Props) { showSkeleton={!settings} /> </SettingsList.LinkItem> - {/* <SettingsList.LinkItem + <SettingsList.LinkItem label={_(msg`Settings for activity alerts`)} to={{screen: 'ActivityNotificationSettings'}} contentContainerStyle={[a.align_start]}> <SettingsList.ItemIcon icon={BellRingingIcon} /> - <ItemTextWithSubtitle - titleText={<Trans>Activity alerts</Trans>} + titleText={<Trans>Activity from others</Trans>} subtitleText={ <SettingPreview preference={settings?.subscribedPost} /> } showSkeleton={!settings} /> - </SettingsList.LinkItem> */} + </SettingsList.LinkItem> <SettingsList.LinkItem label={_( msg`Settings for notifications for likes of your reposts`, diff --git a/src/screens/Settings/PrivacyAndSecuritySettings.tsx b/src/screens/Settings/PrivacyAndSecuritySettings.tsx index 61a8f81cc..a85ad8372 100644 --- a/src/screens/Settings/PrivacyAndSecuritySettings.tsx +++ b/src/screens/Settings/PrivacyAndSecuritySettings.tsx @@ -1,14 +1,17 @@ import {View} from 'react-native' +import {type AppBskyNotificationDeclaration} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {type CommonNavigatorParams} from '#/lib/routes/types' +import {useNotificationDeclarationQuery} from '#/state/queries/activity-subscriptions' import {useAppPasswordsQuery} from '#/state/queries/app-passwords' import {useSession} from '#/state/session' import * as SettingsList from '#/screens/Settings/components/SettingsList' import {atoms as a, useTheme} from '#/alf' import * as Admonition from '#/components/Admonition' +import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash' import {Key_Stroke2_Corner2_Rounded as KeyIcon} from '#/components/icons/Key' import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield' @@ -16,6 +19,7 @@ import * as Layout from '#/components/Layout' import {InlineLinkText} from '#/components/Link' import {Email2FAToggle} from './components/Email2FAToggle' import {PwiOptOut} from './components/PwiOptOut' +import {ItemTextWithSubtitle} from './NotificationSettings/components/ItemTextWithSubtitle' type Props = NativeStackScreenProps< CommonNavigatorParams, @@ -26,6 +30,11 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) { const t = useTheme() const {data: appPasswords} = useAppPasswordsQuery() const {currentAccount} = useSession() + const { + data: notificationDeclaration, + isPending, + isError, + } = useNotificationDeclarationQuery() return ( <Layout.Screen> @@ -71,6 +80,24 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) { </SettingsList.BadgeText> )} </SettingsList.LinkItem> + <SettingsList.LinkItem + label={_(msg`Settings for activity alerts`)} + to={{screen: 'ActivityPrivacySettings'}} + contentContainerStyle={[a.align_start]}> + <SettingsList.ItemIcon icon={BellRingingIcon} /> + <ItemTextWithSubtitle + titleText={ + <Trans>Allow others to be notified of your posts</Trans> + } + subtitleText={ + <NotificationDeclaration + data={notificationDeclaration} + isError={isError} + /> + } + showSkeleton={isPending} + /> + </SettingsList.LinkItem> <SettingsList.Divider /> <SettingsList.Group> <SettingsList.ItemIcon icon={EyeSlashIcon} /> @@ -111,3 +138,26 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) { </Layout.Screen> ) } + +function NotificationDeclaration({ + data, + isError, +}: { + data?: { + value: AppBskyNotificationDeclaration.Record + } + isError?: boolean +}) { + if (isError) { + return <Trans>Error loading preference</Trans> + } + switch (data?.value?.allowSubscriptions) { + case 'mutuals': + return <Trans>Only followers who I follow</Trans> + case 'none': + return <Trans>No one</Trans> + case 'followers': + default: + return <Trans>Anyone who follows me</Trans> + } +} diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index e1d197070..aaba0b4b5 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -3,7 +3,7 @@ import {LayoutAnimation, Pressable, View} from 'react-native' import {Linking} from 'react-native' import {useReducedMotion} from 'react-native-reanimated' import {type AppBskyActorDefs, moderateProfile} from '@atproto/api' -import {msg, t, Trans} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {type NativeStackScreenProps} from '@react-navigation/native-stack' @@ -16,7 +16,6 @@ import { type CommonNavigatorParams, type NavigationProp, } from '#/lib/routes/types' -import {useGate} from '#/lib/statsig/statsig' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {useProfileShadow} from '#/state/cache/profile-shadow' @@ -64,6 +63,7 @@ import { shouldShowVerificationCheckButton, VerificationCheckButton, } from '#/components/verification/VerificationCheckButton' +import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> export function SettingsScreen({}: Props) { @@ -82,7 +82,6 @@ export function SettingsScreen({}: Props) { const {pendingDid, onPressSwitchAccount} = useAccountSwitcher() const [showAccounts, setShowAccounts] = useState(false) const [showDevOptions, setShowDevOptions] = useState(false) - const gate = useGate() return ( <Layout.Screen> @@ -183,16 +182,14 @@ export function SettingsScreen({}: Props) { <Trans>Moderation</Trans> </SettingsList.ItemText> </SettingsList.LinkItem> - {gate('reengagement_features') && ( - <SettingsList.LinkItem - to="/settings/notifications" - label={_(msg`Notifications`)}> - <SettingsList.ItemIcon icon={NotificationIcon} /> - <SettingsList.ItemText> - <Trans>Notifications</Trans> - </SettingsList.ItemText> - </SettingsList.LinkItem> - )} + <SettingsList.LinkItem + to="/settings/notifications" + label={_(msg`Notifications`)}> + <SettingsList.ItemIcon icon={NotificationIcon} /> + <SettingsList.ItemText> + <Trans>Notifications</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> <SettingsList.LinkItem to="/settings/content-and-media" label={_(msg`Content and media`)}> @@ -364,6 +361,7 @@ function DevOptions() { const onboardingDispatch = useOnboardingDispatch() const navigation = useNavigation<NavigationProp>() const {mutate: deleteChatDeclarationRecord} = useDeleteActorDeclaration() + const [actyNotifNudged, setActyNotifNudged] = useActivitySubscriptionsNudged() const resetOnboarding = async () => { navigation.navigate('Home') @@ -384,7 +382,11 @@ function DevOptions() { ...persisted.get('reminders'), lastEmailConfirm: lastEmailConfirm.toISOString(), }) - Toast.show(t`You probably want to restart the app now.`) + Toast.show(_(msg`You probably want to restart the app now.`)) + } + + const onPressActySubsUnNudge = () => { + setActyNotifNudged(false) } return ( @@ -431,6 +433,15 @@ function DevOptions() { <Trans>Unsnooze email reminder</Trans> </SettingsList.ItemText> </SettingsList.PressableItem> + {actyNotifNudged && ( + <SettingsList.PressableItem + onPress={onPressActySubsUnNudge} + label={_(msg`Reset activity subscription nudge`)}> + <SettingsList.ItemText> + <Trans>Reset activity subscription nudge</Trans> + </SettingsList.ItemText> + </SettingsList.PressableItem> + )} <SettingsList.PressableItem onPress={() => clearAllStorage()} label={_(msg`Clear all storage data`)}> diff --git a/src/screens/Settings/components/SettingsList.tsx b/src/screens/Settings/components/SettingsList.tsx index 520df4118..6d1799047 100644 --- a/src/screens/Settings/components/SettingsList.tsx +++ b/src/screens/Settings/components/SettingsList.tsx @@ -1,15 +1,20 @@ -import React, {useContext, useMemo} from 'react' -import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native' +import {createContext, useContext, useMemo} from 'react' +import { + type GestureResponderEvent, + type StyleProp, + View, + type ViewStyle, +} from 'react-native' import {HITSLOP_10} from '#/lib/constants' -import {atoms as a, useTheme, ViewStyleProp} from '#/alf' +import {atoms as a, useTheme, type ViewStyleProp} from '#/alf' import * as Button from '#/components/Button' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' -import {Link, LinkProps} from '#/components/Link' +import {Link, type LinkProps} from '#/components/Link' import {createPortalGroup} from '#/components/Portal' import {Text} from '#/components/Typography' -const ItemContext = React.createContext({ +const ItemContext = createContext({ destructive: false, withinGroup: false, }) @@ -91,7 +96,7 @@ export function Item({ a.px_xl, a.py_sm, a.align_center, - a.gap_md, + a.gap_sm, a.w_full, a.flex_row, {minHeight: 48}, @@ -100,9 +105,9 @@ export function Item({ // existing padding a.pl_xl.paddingLeft + // icon - 28 + + 24 + // gap - a.gap_md.gap, + a.gap_sm.gap, }, style, ]}> @@ -175,7 +180,7 @@ export function PressableItem({ export function ItemIcon({ icon: Comp, - size = 'xl', + size = 'lg', color: colorProp, }: Omit<React.ComponentProps<typeof Button.ButtonIcon>, 'position'> & { color?: string diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index 0d3bb1b17..1489e65fd 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -1,9 +1,10 @@ import {useEffect, useMemo, useState} from 'react' -import {type AppBskyActorDefs} from '@atproto/api' +import {type AppBskyActorDefs, type AppBskyNotificationDefs} from '@atproto/api' import {type QueryClient} from '@tanstack/react-query' import EventEmitter from 'eventemitter3' import {batchedUpdates} from '#/lib/batchedUpdates' +import {findAllProfilesInQueryData as findAllProfilesInActivitySubscriptionsQueryData} from '#/state/queries/activity-subscriptions' import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '#/state/queries/actor-search' import {findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews' import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers' @@ -33,6 +34,7 @@ export interface ProfileShadow { blockingUri: string | undefined verification: AppBskyActorDefs.VerificationState status: AppBskyActorDefs.StatusView | undefined + activitySubscription: AppBskyNotificationDefs.ActivitySubscription | undefined } const shadows: WeakMap< @@ -114,8 +116,8 @@ export function updateProfileShadow( value: Partial<ProfileShadow>, ) { const cachedProfiles = findProfilesInCache(queryClient, did) - for (let post of cachedProfiles) { - shadows.set(post, {...shadows.get(post), ...value}) + for (let profile of cachedProfiles) { + shadows.set(profile, {...shadows.get(profile), ...value}) } batchedUpdates(() => { emitter.emit(did, value) @@ -137,6 +139,10 @@ function mergeShadow<TProfileView extends bsky.profile.AnyProfileView>( muted: 'muted' in shadow ? shadow.muted : profile.viewer?.muted, blocking: 'blockingUri' in shadow ? shadow.blockingUri : profile.viewer?.blocking, + activitySubscription: + 'activitySubscription' in shadow + ? shadow.activitySubscription + : profile.viewer?.activitySubscription, }, verification: 'verification' in shadow ? shadow.verification : profile.verification, @@ -171,4 +177,5 @@ function* findProfilesInCache( yield* findAllProfilesInPostThreadV2QueryData(queryClient, did) yield* findAllProfilesInKnownFollowersQueryData(queryClient, did) yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did) + yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did) } diff --git a/src/state/queries/activity-subscriptions.ts b/src/state/queries/activity-subscriptions.ts new file mode 100644 index 000000000..a81a67226 --- /dev/null +++ b/src/state/queries/activity-subscriptions.ts @@ -0,0 +1,130 @@ +import { + type AppBskyActorDefs, + type AppBskyNotificationDeclaration, + type AppBskyNotificationListActivitySubscriptions, +} from '@atproto/api' +import {t} from '@lingui/macro' +import { + type InfiniteData, + type QueryClient, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' + +import {useAgent, useSession} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' + +export const RQKEY_getActivitySubscriptions = ['activity-subscriptions'] +export const RQKEY_getNotificationDeclaration = ['notification-declaration'] + +export function useActivitySubscriptionsQuery() { + const agent = useAgent() + + return useInfiniteQuery({ + queryKey: RQKEY_getActivitySubscriptions, + queryFn: async ({pageParam}) => { + const response = + await agent.app.bsky.notification.listActivitySubscriptions({ + cursor: pageParam, + }) + return response.data + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: prev => prev.cursor, + }) +} + +export function useNotificationDeclarationQuery() { + const agent = useAgent() + const {currentAccount} = useSession() + return useQuery({ + queryKey: RQKEY_getNotificationDeclaration, + queryFn: async () => { + try { + const response = await agent.app.bsky.notification.declaration.get({ + repo: currentAccount!.did, + rkey: 'self', + }) + return response + } catch (err) { + if ( + err instanceof Error && + err.message.startsWith('Could not locate record') + ) { + return { + value: { + $type: 'app.bsky.notification.declaration', + allowSubscriptions: 'followers', + } satisfies AppBskyNotificationDeclaration.Record, + } + } else { + throw err + } + } + }, + }) +} + +export function useNotificationDeclarationMutation() { + const agent = useAgent() + const {currentAccount} = useSession() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (record: AppBskyNotificationDeclaration.Record) => { + const response = await agent.app.bsky.notification.declaration.put( + { + repo: currentAccount!.did, + rkey: 'self', + }, + record, + ) + return response + }, + onMutate: value => { + queryClient.setQueryData( + RQKEY_getNotificationDeclaration, + (old?: { + uri: string + cid: string + value: AppBskyNotificationDeclaration.Record + }) => { + if (!old) return old + return { + value, + } + }, + ) + }, + onError: () => { + Toast.show(t`Failed to update notification declaration`) + queryClient.invalidateQueries({ + queryKey: RQKEY_getNotificationDeclaration, + }) + }, + }) +} + +export function* findAllProfilesInQueryData( + queryClient: QueryClient, + did: string, +): Generator<AppBskyActorDefs.ProfileView, void> { + const queryDatas = queryClient.getQueriesData< + InfiniteData<AppBskyNotificationListActivitySubscriptions.OutputSchema> + >({ + queryKey: RQKEY_getActivitySubscriptions, + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) { + continue + } + for (const page of queryData.pages) { + for (const subscription of page.subscriptions) { + if (subscription.did === did) { + yield subscription + } + } + } + } +} diff --git a/src/state/queries/list-members.ts b/src/state/queries/list-members.ts index 82c395518..152c7a5be 100644 --- a/src/state/queries/list-members.ts +++ b/src/state/queries/list-members.ts @@ -1,13 +1,13 @@ import { - AppBskyActorDefs, - AppBskyGraphDefs, - AppBskyGraphGetList, - BskyAgent, + type AppBskyActorDefs, + type AppBskyGraphDefs, + type AppBskyGraphGetList, + type BskyAgent, } from '@atproto/api' import { - InfiniteData, - QueryClient, - QueryKey, + type InfiniteData, + type QueryClient, + type QueryKey, useInfiniteQuery, useQuery, } from '@tanstack/react-query' @@ -100,21 +100,16 @@ export function* findAllProfilesInQueryData( queryKey: [RQKEY_ROOT], }) for (const [_queryKey, queryData] of queryDatas) { - if (!queryData) { + if (!queryData?.pages) { continue } - for (const [_queryKey, queryData] of queryDatas) { - if (!queryData?.pages) { - continue + for (const page of queryData?.pages) { + if (page.list.creator.did === did) { + yield page.list.creator } - for (const page of queryData?.pages) { - if (page.list.creator.did === did) { - yield page.list.creator - } - for (const item of page.items) { - if (item.subject.did === did) { - yield item.subject - } + for (const item of page.items) { + if (item.subject.did === did) { + yield item.subject } } } diff --git a/src/state/queries/messages/actor-declaration.ts b/src/state/queries/messages/actor-declaration.ts index 34fb10935..a5adb39d9 100644 --- a/src/state/queries/messages/actor-declaration.ts +++ b/src/state/queries/messages/actor-declaration.ts @@ -1,4 +1,4 @@ -import {AppBskyActorDefs} from '@atproto/api' +import {type AppBskyActorDefs} from '@atproto/api' import {useMutation, useQueryClient} from '@tanstack/react-query' import {logger} from '#/logger' @@ -19,7 +19,7 @@ export function useUpdateActorDeclaration({ return useMutation({ mutationFn: async (allowIncoming: 'all' | 'none' | 'following') => { if (!currentAccount) throw new Error('Not signed in') - const result = await agent.api.com.atproto.repo.putRecord({ + const result = await agent.com.atproto.repo.putRecord({ repo: currentAccount.did, collection: 'chat.bsky.actor.declaration', rkey: 'self', diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index fce7802bc..6010f11b4 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -18,30 +18,30 @@ import {useCallback, useEffect, useMemo, useRef} from 'react' import { - AppBskyActorDefs, + type AppBskyActorDefs, AppBskyFeedDefs, AppBskyFeedPost, AtUri, moderatePost, } from '@atproto/api' import { - InfiniteData, - QueryClient, - QueryKey, + type InfiniteData, + type QueryClient, + type QueryKey, useInfiniteQuery, useQueryClient, } from '@tanstack/react-query' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {STALE} from '#/state/queries' import {useAgent} from '#/state/session' import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies' -import {useModerationOpts} from '../../preferences/moderation-opts' -import {STALE} from '..' import { didOrHandleUriMatches, embedViewRecordToPostView, getEmbeddedPost, } from '../util' -import {FeedPage} from './types' +import {type FeedPage} from './types' import {useUnreadNotificationsApi} from './unread' import {fetchPage} from './util' diff --git a/src/state/queries/notifications/types.ts b/src/state/queries/notifications/types.ts index e05715f77..a7b837086 100644 --- a/src/state/queries/notifications/types.ts +++ b/src/state/queries/notifications/types.ts @@ -48,6 +48,7 @@ type OtherNotificationType = | 'unverified' | 'like-via-repost' | 'repost-via-repost' + | 'subscribed-post' | 'unknown' type FeedNotificationBase = { diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index 007f65cc7..faccd8087 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -28,6 +28,7 @@ const GROUPABLE_REASONS = [ 'follow', 'like-via-repost', 'repost-via-repost', + 'subscribed-post', ] const MS_1HR = 1e3 * 60 * 60 const MS_2DAY = MS_1HR * 48 @@ -144,7 +145,8 @@ export function groupNotifications( Math.abs(ts2 - ts) < MS_2DAY && notif.reason === groupedNotif.notification.reason && notif.reasonSubject === groupedNotif.notification.reasonSubject && - notif.author.did !== groupedNotif.notification.author.did + (notif.author.did !== groupedNotif.notification.author.did || + notif.reason === 'subscribed-post') ) { const nextIsFollowBack = notif.reason === 'follow' && notif.author.viewer?.following @@ -252,7 +254,8 @@ function toKnownType( notif.reason === 'verified' || notif.reason === 'unverified' || notif.reason === 'like-via-repost' || - notif.reason === 'repost-via-repost' + notif.reason === 'repost-via-repost' || + notif.reason === 'subscribed-post' ) { return notif.reason as NotificationType } @@ -263,7 +266,12 @@ function getSubjectUri( type: NotificationType, notif: AppBskyNotificationListNotifications.Notification, ): string | undefined { - if (type === 'reply' || type === 'quote' || type === 'mention') { + if ( + type === 'reply' || + type === 'quote' || + type === 'mention' || + type === 'subscribed-post' + ) { return notif.uri } else if ( type === 'post-like' || diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts index a44ffa4c5..1947f857f 100644 --- a/src/state/queries/nuxs/definitions.ts +++ b/src/state/queries/nuxs/definitions.ts @@ -6,6 +6,7 @@ export enum Nux { NeueTypography = 'NeueTypography', ExploreInterestsCard = 'ExploreInterestsCard', InitialVerificationAnnouncement = 'InitialVerificationAnnouncement', + ActivitySubscriptions = 'ActivitySubscriptions', } export const nuxNames = new Set(Object.values(Nux)) @@ -23,10 +24,15 @@ export type AppNux = BaseNux< id: Nux.InitialVerificationAnnouncement data: undefined } + | { + id: Nux.ActivitySubscriptions + data: undefined + } > export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = { [Nux.NeueTypography]: undefined, [Nux.ExploreInterestsCard]: undefined, [Nux.InitialVerificationAnnouncement]: undefined, + [Nux.ActivitySubscriptions]: undefined, } diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 920892924..361081e67 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -24,6 +24,7 @@ import {HomeFeedAPI} from '#/lib/api/feed/home' import {LikesFeedAPI} from '#/lib/api/feed/likes' import {ListFeedAPI} from '#/lib/api/feed/list' import {MergeFeedAPI} from '#/lib/api/feed/merge' +import {PostListFeedAPI} from '#/lib/api/feed/posts' import {type FeedAPI, type ReasonFeedSource} from '#/lib/api/feed/types' import {aggregateUserInterests} from '#/lib/api/feed/utils' import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip' @@ -53,6 +54,7 @@ export type AuthorFilter = | 'posts_with_video' type FeedUri = string type ListUri = string +type PostsUriList = string export type FeedDescriptor = | 'following' @@ -60,6 +62,7 @@ export type FeedDescriptor = | `feedgen|${FeedUri}` | `likes|${ActorDid}` | `list|${ListUri}` + | `posts|${PostsUriList}` | 'demo' export interface FeedParams { mergeFeedEnabled?: boolean @@ -488,6 +491,9 @@ function createApi({ } else if (feedDesc.startsWith('list')) { const [_, list] = feedDesc.split('|') return new ListFeedAPI({agent, feedParams: {list}}) + } else if (feedDesc.startsWith('posts')) { + const [_, uriList] = feedDesc.split('|') + return new PostListFeedAPI({agent, feedParams: {uris: uriList.split(',')}}) } else if (feedDesc === 'demo') { return new DemoFeedAPI({agent}) } else { diff --git a/src/storage/hooks/activity-subscriptions-nudged.ts b/src/storage/hooks/activity-subscriptions-nudged.ts new file mode 100644 index 000000000..0e9c1c39c --- /dev/null +++ b/src/storage/hooks/activity-subscriptions-nudged.ts @@ -0,0 +1,8 @@ +import {device, useStorage} from '#/storage' + +export function useActivitySubscriptionsNudged() { + const [activitySubscriptionsNudged = false, setActivitySubscriptionsNudged] = + useStorage(device, ['activitySubscriptionsNudged']) + + return [activitySubscriptionsNudged, setActivitySubscriptionsNudged] as const +} diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 7430532a9..19c31834b 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -11,6 +11,7 @@ export type Device = { trendingBetaEnabled: boolean devMode: boolean demoMode: boolean + activitySubscriptionsNudged?: boolean } export type Account = { diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index 85f67919a..89e2d20e7 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -52,6 +52,7 @@ import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, platform, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' +import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' import { ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, @@ -114,7 +115,9 @@ let NotificationFeedItem = ({ case 'unverified': { return makeProfileLink(item.notification.author) } - case 'reply': { + case 'reply': + case 'mention': + case 'quote': { const uripReply = new AtUri(item.notification.uri) return `/profile/${uripReply.host}/post/${uripReply.rkey}` } @@ -126,6 +129,13 @@ let NotificationFeedItem = ({ } break } + case 'subscribed-post': { + const posts: string[] = [] + for (const post of [item.notification, ...(item.additional ?? [])]) { + posts.push(post.uri) + } + return `/notifications/activity?posts=${encodeURIComponent(posts.slice(0, 25).join(','))}` + } } return '' @@ -155,7 +165,10 @@ let NotificationFeedItem = ({ href: makeProfileLink(author), moderation: moderateProfile(author, moderationOpts), })) || []), - ] + ].filter( + (author, index, arr) => + arr.findIndex(au => au.profile.did === author.profile.did) === index, + ) }, [item, moderationOpts]) const niceTimestamp = niceDate(i18n, item.notification.indexedAt) @@ -503,6 +516,42 @@ let NotificationFeedItem = ({ <Trans>{firstAuthorLink} reposted your repost</Trans> ) icon = <RepostIcon size="xl" style={{color: t.palette.positive_600}} /> + } else if (item.type === 'subscribed-post') { + const postsCount = 1 + (item.additional?.length || 0) + a11yLabel = hasMultipleAuthors + ? _( + msg`New posts from ${firstAuthorName} and ${plural( + additionalAuthorsCount, + { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + }, + )}`, + ) + : _( + msg`New ${plural(postsCount, { + one: 'post', + other: 'posts', + })} from ${firstAuthorName}`, + ) + notificationContent = hasMultipleAuthors ? ( + <Trans> + New posts from {firstAuthorLink} and{' '} + <Text style={[a.text_md, a.font_bold, a.leading_snug]}> + <Plural + value={additionalAuthorsCount} + one={`${formattedAuthorsCount} other`} + other={`${formattedAuthorsCount} others`} + /> + </Text>{' '} + </Trans> + ) : ( + <Trans> + New <Plural value={postsCount} one="post" other="posts" /> from{' '} + {firstAuthorLink} + </Trans> + ) + icon = <BellRingingIcon size="xl" style={{color: t.palette.primary_500}} /> } else { return null } @@ -613,7 +662,8 @@ let NotificationFeedItem = ({ {item.type === 'post-like' || item.type === 'repost' || item.type === 'like-via-repost' || - item.type === 'repost-via-repost' ? ( + item.type === 'repost-via-repost' || + item.type === 'subscribed-post' ? ( <View style={[a.pt_2xs]}> <AdditionalPostText post={item.subject} /> </View> diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index 30ebbf2c2..c1f804203 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -96,7 +96,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { a.font_bold, t.atoms.text, a.leading_tight, - {maxWidth: '70%', flexShrink: 0}, + a.flex_shrink_0, + {maxWidth: '70%'}, ]}> {forceLTR( sanitizeDisplayName( |