From bc072570d27e1f397406daea355570f5aec95647 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 2 Jul 2025 00:36:04 +0300 Subject: [PATCH] Activity notification settings (#8485) Co-authored-by: Eric Bailey Co-authored-by: Samuel Newman Co-authored-by: hailey --- .../bellPlus_stroke2_corner0_rounded.svg | 1 + .../bellRinging_filled_corner0_rounded.svg | 1 + .../activity_notifications_announcement.webp | Bin 0 -> 22058 bytes bskyweb/cmd/bskyweb/server.go | 2 + .../BackgroundNotificationHandler.kt | 4 +- package.json | 4 +- src/Navigation.tsx | 24 ++ src/components/ProfileCard.tsx | 73 ++++- src/components/Tooltip/index.tsx | 94 +++--- src/components/Tooltip/index.web.tsx | 10 +- .../SubscribeProfileButton.tsx | 89 +++++ .../SubscribeProfileDialog.tsx | 309 ++++++++++++++++++ .../dialogs/nuxs/ActivitySubscriptions.tsx | 177 ++++++++++ src/components/dialogs/nuxs/index.tsx | 19 +- src/components/dialogs/nuxs/utils.ts | 15 + src/components/icons/BellPlus.tsx | 5 + src/components/icons/BellRinging.tsx | 4 + src/lib/api/feed/list.ts | 12 +- src/lib/api/feed/posts.ts | 52 +++ src/lib/hooks/useNotificationHandler.ts | 44 ++- .../create-sanitized-display-name.ts | 7 +- src/lib/routes/types.ts | 2 + src/lib/statsig/gates.ts | 1 - src/logger/metrics.ts | 13 + src/routes.ts | 2 + src/screens/Notifications/ActivityList.tsx | 44 +++ .../Profile/Header/ProfileHeaderStandard.tsx | 27 +- .../Settings/AccessibilitySettings.tsx | 19 +- .../Settings/ActivityPrivacySettings.tsx | 140 ++++++++ src/screens/Settings/AppPasswords.tsx | 6 +- src/screens/Settings/AppearanceSettings.tsx | 9 +- .../Settings/ExternalMediaPreferences.tsx | 7 +- .../Settings/FollowingFeedPreferences.tsx | 5 +- .../ActivityNotificationSettings.tsx | 263 +++++++++++++++ .../components/PreferenceControls.tsx | 30 +- .../Settings/NotificationSettings/index.tsx | 9 +- .../Settings/PrivacyAndSecuritySettings.tsx | 50 +++ src/screens/Settings/Settings.tsx | 39 ++- .../Settings/components/SettingsList.tsx | 23 +- src/state/cache/profile-shadow.ts | 13 +- src/state/queries/activity-subscriptions.ts | 130 ++++++++ src/state/queries/list-members.ts | 33 +- .../queries/messages/actor-declaration.ts | 4 +- src/state/queries/notifications/feed.ts | 14 +- src/state/queries/notifications/types.ts | 1 + src/state/queries/notifications/util.ts | 14 +- src/state/queries/nuxs/definitions.ts | 6 + src/state/queries/post-feed.ts | 6 + .../hooks/activity-subscriptions-nudged.ts | 8 + src/storage/schema.ts | 1 + .../notifications/NotificationFeedItem.tsx | 56 +++- src/view/com/util/PostMeta.tsx | 3 +- yarn.lock | 174 +++++----- 53 files changed, 1810 insertions(+), 288 deletions(-) create mode 100644 assets/icons/bellPlus_stroke2_corner0_rounded.svg create mode 100644 assets/icons/bellRinging_filled_corner0_rounded.svg create mode 100644 assets/images/activity_notifications_announcement.webp create mode 100644 src/components/activity-notifications/SubscribeProfileButton.tsx create mode 100644 src/components/activity-notifications/SubscribeProfileDialog.tsx create mode 100644 src/components/dialogs/nuxs/ActivitySubscriptions.tsx create mode 100644 src/components/icons/BellPlus.tsx create mode 100644 src/lib/api/feed/posts.ts create mode 100644 src/screens/Notifications/ActivityList.tsx create mode 100644 src/screens/Settings/ActivityPrivacySettings.tsx create mode 100644 src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx create mode 100644 src/state/queries/activity-subscriptions.ts create mode 100644 src/storage/hooks/activity-subscriptions-nudged.ts diff --git a/assets/icons/bellPlus_stroke2_corner0_rounded.svg b/assets/icons/bellPlus_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..7e3349427 --- /dev/null +++ b/assets/icons/bellPlus_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/bellRinging_filled_corner0_rounded.svg b/assets/icons/bellRinging_filled_corner0_rounded.svg new file mode 100644 index 000000000..67be5d99d --- /dev/null +++ b/assets/icons/bellRinging_filled_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/images/activity_notifications_announcement.webp b/assets/images/activity_notifications_announcement.webp new file mode 100644 index 0000000000000000000000000000000000000000..885d1b7d1fee2f896f34d70a3b419e496ccc8c39 GIT binary patch literal 22058 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 M?4oxjFfcFx0J!Y(>Hq)$ literal 0 HcmV?d00001 diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index ef796920d..755f2fa3f 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -258,6 +258,7 @@ func serve(cctx *cli.Context) error { e.GET("/feeds", server.WebGeneric) e.GET("/notifications", server.WebGeneric) e.GET("/notifications/settings", server.WebGeneric) + e.GET("/notifications/activity", server.WebGeneric) e.GET("/lists", server.WebGeneric) e.GET("/moderation", server.WebGeneric) e.GET("/moderation/modlists", server.WebGeneric) @@ -275,6 +276,7 @@ func serve(cctx *cli.Context) error { e.GET("/settings/appearance", server.WebGeneric) e.GET("/settings/account", server.WebGeneric) e.GET("/settings/privacy-and-security", server.WebGeneric) + e.GET("/settings/privacy-and-security/activity", server.WebGeneric) e.GET("/settings/content-and-media", server.WebGeneric) e.GET("/settings/interests", server.WebGeneric) e.GET("/settings/about", server.WebGeneric) diff --git a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt index 9fdfcfd89..4f8a6b892 100644 --- a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt +++ b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt @@ -45,10 +45,10 @@ class BackgroundNotificationHandler( private fun mutateWithOtherReason(remoteMessage: RemoteMessage) { // If oreo or higher if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { - // If one of "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost" + // If one of "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost", "subscribed-post" // assign to it's eponymous channel. otherwise do nothing, let expo handle it when (remoteMessage.data["reason"]) { - "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost" -> { + "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost", "subscribed-post" -> { remoteMessage.data["channelId"] = remoteMessage.data["reason"] } } diff --git a/package.json b/package.json index 7c1c3dee9..aae35f96a 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.15.16", + "@atproto/api": "^0.15.21", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", @@ -218,7 +218,7 @@ "zod": "^3.20.2" }, "devDependencies": { - "@atproto/dev-env": "^0.3.144", + "@atproto/dev-env": "^0.3.150", "@babel/core": "^7.26.0", "@babel/preset-env": "^7.26.0", "@babel/runtime": "^7.26.0", 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' @@ -390,6 +393,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { requireAuth: true, }} /> + ActivityPrivacySettingsScreen} + options={{ + title: title(msg`Privacy and Security`), + requireAuth: true, + }} + /> NotificationSettingsScreen} @@ -459,6 +470,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { requireAuth: true, }} /> + ActivityNotificationSettingsScreen} + options={{ + title: title(msg`Activity notifications`), + requireAuth: true, + }} + /> MiscellaneousNotificationSettingsScreen} @@ -524,6 +543,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { getComponent={() => MessagesInboxScreen} options={{title: title(msg`Chat request inbox`), requireAuth: true}} /> + NotificationsActivityListScreen} + options={{title: title(msg`Notifications`), requireAuth: true}} + /> LegacyNotificationSettingsScreen} 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 ( + + ) + } else { + return ( + + + + + ) + } +} + +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 ( - - - + + + {forceLTR(name)} + + {verification.showBadge && ( + + + + )} + + {NON_BREAKING_SPACE + handle} + ) } 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 + targetMeasurements: TargetMeasurements | undefined + setTargetMeasurements: (measurements: TargetMeasurements) => void + shouldMeasure: boolean } const TooltipContext = createContext({ position: 'bottom', - ready: false, + visible: false, onVisibleChange: () => {}, }) const TargetContext = createContext({ targetMeasurements: undefined, - targetRef: {current: null}, + setTargetMeasurements: () => {}, + shouldMeasure: false, }) export function Outer({ @@ -68,21 +71,12 @@ export function Outer({ visible: boolean onVisibleChange: (visible: boolean) => void }) { - /** - * Whether we have measured the target and are ready to show the tooltip. - */ - const [ready, setReady] = useState(false) /** * Lagging state to track the externally-controlled visibility of the - * tooltip. + * tooltip, which needs to wait for the target to be measured before + * actually being shown. */ - const [prevRequestVisible, setPrevRequestVisible] = useState< - boolean | undefined - >() - /** - * Needs to reference the element this Tooltip is attached to. - */ - const targetRef = useRef(null) + const [visible, setVisible] = useState(false) const [targetMeasurements, setTargetMeasurements] = useState< | { x: number @@ -93,33 +87,24 @@ export function Outer({ | undefined >(undefined) - if (requestVisible && !prevRequestVisible) { - setPrevRequestVisible(true) - - if (targetRef.current) { - /* - * Once opened, measure the dimensions and position of the target - */ - targetRef.current.measure((_x, _y, width, height, pageX, pageY) => { - if (pageX !== undefined && pageY !== undefined && width && height) { - setTargetMeasurements({x: pageX, y: pageY, width, height}) - setReady(true) - } - }) - } - } else if (!requestVisible && prevRequestVisible) { - setPrevRequestVisible(false) + if (requestVisible && !visible && targetMeasurements) { + setVisible(true) + } else if (!requestVisible && visible) { + setVisible(false) setTargetMeasurements(undefined) - setReady(false) } const ctx = useMemo( - () => ({position, ready, onVisibleChange}), - [position, ready, onVisibleChange], + () => ({position, visible, onVisibleChange}), + [position, visible, onVisibleChange], ) const targetCtx = useMemo( - () => ({targetMeasurements, targetRef}), - [targetMeasurements, targetRef], + () => ({ + targetMeasurements, + setTargetMeasurements, + shouldMeasure: requestVisible, + }), + [requestVisible, targetMeasurements, setTargetMeasurements], ) return ( @@ -132,7 +117,20 @@ export function Outer({ } export function Target({children}: {children: React.ReactNode}) { - const {targetRef} = useContext(TargetContext) + const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext) + const targetRef = useRef(null) + + useEffect(() => { + if (!shouldMeasure) return + /* + * Once opened, measure the dimensions and position of the target + */ + targetRef.current?.measure((_x, _y, width, height, pageX, pageY) => { + if (pageX !== undefined && pageY !== undefined && width && height) { + setTargetMeasurements({x: pageX, y: pageY, width, height}) + } + }) + }, [shouldMeasure, setTargetMeasurements]) return ( @@ -148,13 +146,13 @@ export function Content({ children: React.ReactNode label: string }) { - const {position, ready, onVisibleChange} = useContext(TooltipContext) + const {position, visible, onVisibleChange} = useContext(TooltipContext) const {targetMeasurements} = useContext(TargetContext) const requestClose = useCallback(() => { onVisibleChange(false) }, [onVisibleChange]) - if (!ready || !targetMeasurements) return null + if (!visible || !targetMeasurements) return null return ( diff --git a/src/components/Tooltip/index.web.tsx b/src/components/Tooltip/index.web.tsx index 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({ position: 'bottom', + onVisibleChange: () => {}, }) export function Outer({ @@ -30,7 +32,10 @@ export function Outer({ visible: boolean onVisibleChange: (visible: boolean) => void }) { - const ctx = useMemo(() => ({position}), [position]) + const ctx = useMemo( + () => ({position, onVisibleChange}), + [position, onVisibleChange], + ) return ( {children} @@ -54,7 +59,7 @@ export function Content({ label: string }) { const t = useTheme() - const {position} = useContext(TooltipContext) + const {position, onVisibleChange} = useContext(TooltipContext) return ( onVisibleChange(false)} style={flatten([ a.rounded_sm, select(t.name, { diff --git a/src/components/activity-notifications/SubscribeProfileButton.tsx b/src/components/activity-notifications/SubscribeProfileButton.tsx new file mode 100644 index 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: [ + + Before you can get notifications for {name}'s posts, you must first + verify your email. + , + ], + }) + + const isSubscribed = + profile.viewer?.activitySubscription?.post || + profile.viewer?.activitySubscription?.reply + + const Icon = isSubscribed ? BellRingingIcon : BellPlusIcon + + return ( + <> + + + + + + + Get notified about new posts + + + + + + + ) +} diff --git a/src/components/activity-notifications/SubscribeProfileDialog.tsx b/src/components/activity-notifications/SubscribeProfileDialog.tsx new file mode 100644 index 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 ( + + + + + ) +} + +function DialogInner({ + profile, + moderationOpts, + includeProfile, +}: { + profile: bsky.profile.AnyProfileView + moderationOpts: ModerationOpts + includeProfile?: boolean +}) { + const {_} = useLingui() + const t = useTheme() + const agent = useAgent() + const control = Dialog.useDialogContext() + const queryClient = useQueryClient() + const initialState = parseActivitySubscription( + profile.viewer?.activitySubscription, + ) + const [state, setState] = useState(initialState) + + const values = useMemo(() => { + const {post, reply} = state + const res = [] + if (post) res.push('post') + if (reply) res.push('reply') + return res + }, [state]) + + const onChange = (newValues: string[]) => { + setState(oldValues => { + // ensure you can't have reply without post + if (!oldValues.reply && newValues.includes('reply')) { + return { + post: true, + reply: true, + } + } + + if (oldValues.post && !newValues.includes('post')) { + return { + post: false, + reply: false, + } + } + + return { + post: newValues.includes('post'), + reply: newValues.includes('reply'), + } + }) + } + + const { + mutate: saveChanges, + isPending: isSaving, + error, + } = useMutation({ + mutationFn: async ( + activitySubscription: Un$Typed, + ) => { + await agent.app.bsky.notification.putActivitySubscription({ + subject: profile.did, + activitySubscription, + }) + }, + onSuccess: (_data, activitySubscription) => { + control.close(() => { + updateProfileShadow(queryClient, profile.did, { + activitySubscription, + }) + + if (!activitySubscription.post && !activitySubscription.reply) { + logger.metric('activitySubscription:disable', {}) + Toast.show( + _( + msg`You will no longer receive notifications for ${sanitizeHandle(profile.handle, '@')}`, + ), + 'check', + ) + + // filter out the subscription + queryClient.setQueryData( + RQKEY_getActivitySubscriptions, + ( + old?: InfiniteData, + ) => { + 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 = useMemo(() => { + const isDirty = + state.post !== initialState.post || state.reply !== initialState.reply + const hasAny = state.post || state.reply + + if (isDirty) { + return { + label: _(msg`Save changes`), + color: hasAny ? 'primary' : 'negative', + onPress: () => saveChanges(state), + disabled: isSaving, + } + } else { + // on web, a disabled save button feels more natural than a massive close button + if (isWeb) { + return { + label: _(msg`Save changes`), + color: 'secondary', + disabled: true, + } + } else { + return { + label: _(msg`Cancel`), + color: 'secondary', + onPress: () => control.close(), + } + } + } + }, [state, initialState, control, _, isSaving, saveChanges]) + + const name = createSanitizedDisplayName(profile, false) + + return ( + + + + + Keep me posted + + + Get notified of this account’s activity + + + + {includeProfile && ( + + + + + )} + + + + + + Posts + + + + + + Replies + + + + + + + {error && ( + + Could not save changes: {cleanError(error)} + + )} + + + + + + + ) +} + +function parseActivitySubscription( + sub?: AppBskyNotificationDefs.ActivitySubscription, +): Un$Typed { + if (!sub) return {post: false, reply: false} + const {post, reply} = sub + return {post, reply} +} diff --git a/src/components/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 ( + + + + + + + + + New Feature + + + + + + + {_( + + + + + + + Get notified when someone posts + + + + You can now choose to be notified when specific people post. If + there’s someone you want timely updates from, go to their + profile and find the new bell icon near the follow button. + + + + + {!isWeb && ( + + )} + + + + + + ) +} 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 ( {/*For example, activeNux === Nux.NeueTypography && */} - {activeNux === Nux.InitialVerificationAnnouncement && ( - - )} + {activeNux === Nux.ActivitySubscriptions && } ) } diff --git a/src/components/dialogs/nuxs/utils.ts b/src/components/dialogs/nuxs/utils.ts index 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 { + if (this.peek) return this.peek + throw new Error('Has not fetched yet') + } + + async fetch({}: {}): Promise { + 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({ 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({ 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 ( + + + + + + Notifications + + + + + ( + + )} + renderEndOfFeed={() => } + /> + + ) +} 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 = - useProfileShadow(profileUnshadowed) + const profile = + useProfileShadow(profileUnshadowed) const {currentAccount, hasSession} = useSession() const {_} = useLingui() const moderation = useMemo( @@ -134,13 +134,26 @@ let ProfileHeaderStandard = ({ } }, [_, queueUnblock]) - const isMe = React.useMemo( + const isMe = useMemo( () => currentAccount?.did === profile.did, [currentAccount, profile], ) const {isActive: live} = useActorStatus(profile) + const subscriptionsAllowed = useMemo(() => { + switch (profile.associated?.activitySubscription?.allowSubscriptions) { + case 'followers': + case undefined: + return !!profile.viewer?.following + case 'mutuals': + return !!profile.viewer?.following && !!profile.viewer.followedBy + case 'none': + default: + return false + } + }, [profile]) + return ( + {hasSession && subscriptionsAllowed && ( + + )} {hasSession && } + + + + + + ) +} 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 ( @@ -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], }), ]}> + style={[a.flex_row, a.py_xs, a.gap_sm]}> + style={[a.flex_row, a.py_xs, a.gap_sm]}> - {/* - Activity alerts} + titleText={Activity from others} subtitleText={ } showSkeleton={!settings} /> - */} + @@ -71,6 +80,24 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) { )} + + + Allow others to be notified of your posts + } + subtitleText={ + + } + showSkeleton={isPending} + /> + @@ -111,3 +138,26 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) { ) } + +function NotificationDeclaration({ + data, + isError, +}: { + data?: { + value: AppBskyNotificationDeclaration.Record + } + isError?: boolean +}) { + if (isError) { + return Error loading preference + } + switch (data?.value?.allowSubscriptions) { + case 'mutuals': + return Only followers who I follow + case 'none': + return No one + case 'followers': + default: + return Anyone who follows me + } +} 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 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 ( @@ -183,16 +182,14 @@ export function SettingsScreen({}: Props) { Moderation - {gate('reengagement_features') && ( - - - - Notifications - - - )} + + + + Notifications + + @@ -364,6 +361,7 @@ function DevOptions() { const onboardingDispatch = useOnboardingDispatch() const navigation = useNavigation() 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() { Unsnooze email reminder + {actyNotifNudged && ( + + + Reset activity subscription nudge + + + )} 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, '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, ) { 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( 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 { + const queryDatas = queryClient.getQueriesData< + InfiniteData + >({ + 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 | 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 = ({ {firstAuthorLink} reposted your repost ) icon = + } 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 ? ( + + New posts from {firstAuthorLink} and{' '} + + + {' '} + + ) : ( + + New from{' '} + {firstAuthorLink} + + ) + icon = } 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' ? ( 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( diff --git a/yarn.lock b/yarn.lock index 3280cbb9b..90db949d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -63,10 +63,10 @@ "@atproto/xrpc" "^0.7.0" "@atproto/xrpc-server" "^0.8.0" -"@atproto/api@^0.15.16": - version "0.15.16" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.16.tgz#1962e7067e03a661e17c3164874596ef1e7ed7ad" - integrity sha512-ZNBrzBg2l0lHreKik1lJn8lrhAktwlY8NUPBU/hO9dwjAnDHQTiSzNFZt65dp9djmqZ75sX/VJ+heNuaJBvnhQ== +"@atproto/api@^0.15.21": + version "0.15.21" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.21.tgz#6cd450c49dc30ea7baca4905b9046abf69f9c1bd" + integrity sha512-/VsikzVqIjNrdCk3eoJAleNcPUAGOLW8GCU9ymQMyGg1bBOCDb2Gl4eCqvhJ7Zd/UUyU5o8bh2YwLsY8/ikkeA== dependencies: "@atproto/common-web" "^0.4.2" "@atproto/lexicon" "^0.4.11" @@ -77,14 +77,14 @@ tlds "^1.234.0" zod "^3.23.8" -"@atproto/aws@^0.2.22": - version "0.2.22" - resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.22.tgz#14a664c06e3569945e4ab143d3a8a03400c7d1de" - integrity sha512-xZ+0/zHHmpgzdLJGTDkFl5Wd39Wm5MyyMLdGYSzyt0wGTBmH6Ktp7ZgR8rmQVNYN1+VkMcdClAiNhg+BSH3mRw== +"@atproto/aws@^0.2.24": + version "0.2.24" + resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.24.tgz#c8e7a804710d70be3aa2fa292c1ece4c05127891" + integrity sha512-4XZQGitPJR56tFt1bzPJKOqp3vTVcfVsEAFo9FGWp7Es+jj742aVgfWEe64O0VoZp3ZTiD7XhwsLJArz7NJTlQ== dependencies: "@atproto/common" "^0.4.11" "@atproto/crypto" "^0.4.4" - "@atproto/repo" "^0.8.2" + "@atproto/repo" "^0.8.4" "@aws-sdk/client-cloudfront" "^3.261.0" "@aws-sdk/client-kms" "^3.196.0" "@aws-sdk/client-s3" "^3.224.0" @@ -94,21 +94,21 @@ multiformats "^9.9.0" uint8arrays "3.0.0" -"@atproto/bsky@^0.0.161": - version "0.0.161" - resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.161.tgz#671280c1d40f5c4eb0cc31d338a9e950acbf0ce0" - integrity sha512-L4uzadjt+oyVq3+W7rc1A+X2DyZDsTfeSD15w7k6+6JzICp32qavDuVjut3CIBqXCt7ykvSDujApyLsB/lcWJQ== +"@atproto/bsky@^0.0.167": + version "0.0.167" + resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.167.tgz#583eb404ef4de409e34d7c2485bf325e5d1f3ff0" + integrity sha512-VLgaVsx0fYeoXcFHP1KM6joda9Ovhb7LsE3JdES6+hhsAF74DFwW57mVzRfYhy1bwWn/m9poUMs1RkCjOR9ZJA== dependencies: "@atproto-labs/fetch-node" "0.1.9" "@atproto-labs/xrpc-utils" "0.0.16" - "@atproto/api" "^0.15.16" + "@atproto/api" "^0.15.21" "@atproto/common" "^0.4.11" "@atproto/crypto" "^0.4.4" "@atproto/did" "^0.1.5" "@atproto/identity" "^0.4.8" "@atproto/lexicon" "^0.4.11" - "@atproto/repo" "^0.8.2" - "@atproto/sync" "^0.1.26" + "@atproto/repo" "^0.8.4" + "@atproto/sync" "^0.1.28" "@atproto/syntax" "^0.4.0" "@atproto/xrpc-server" "^0.8.0" "@bufbuild/protobuf" "^1.5.0" @@ -218,21 +218,21 @@ "@noble/hashes" "^1.6.1" uint8arrays "3.0.0" -"@atproto/dev-env@^0.3.144": - version "0.3.144" - resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.144.tgz#cd2949ff870ca4cde23b4c377b08740a2e64151f" - integrity sha512-ND0oGp7itSnXxlAHlFxYjGFyCcu0f4eSucImVtKRxTcW8UeyyTtJcQP8OyNvtC8j13YjbW124r0g25Wlm0j9XQ== +"@atproto/dev-env@^0.3.150": + version "0.3.150" + resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.150.tgz#6443206352398be1e3dd8bcfe980e7a21d2cd93a" + integrity sha512-LOujaEmOVBCxSnKQqpJb238fe5vYGIgmTA+OMEFH3kZb+6Y6UXfW2Vhs79tP0DiX0VyoXwib/7PH3Lp5cC/ZFQ== dependencies: - "@atproto/api" "^0.15.16" - "@atproto/bsky" "^0.0.161" + "@atproto/api" "^0.15.21" + "@atproto/bsky" "^0.0.167" "@atproto/bsync" "^0.0.20" "@atproto/common-web" "^0.4.2" "@atproto/crypto" "^0.4.4" "@atproto/identity" "^0.4.8" "@atproto/lexicon" "^0.4.11" - "@atproto/ozone" "^0.1.121" - "@atproto/pds" "^0.4.150" - "@atproto/sync" "^0.1.26" + "@atproto/ozone" "^0.1.126" + "@atproto/pds" "^0.4.156" + "@atproto/sync" "^0.1.28" "@atproto/syntax" "^0.4.0" "@atproto/xrpc-server" "^0.8.0" "@did-plc/lib" "^0.0.1" @@ -259,18 +259,18 @@ "@atproto/common-web" "^0.4.2" "@atproto/crypto" "^0.4.4" -"@atproto/jwk-jose@0.1.8": - version "0.1.8" - resolved "https://registry.yarnpkg.com/@atproto/jwk-jose/-/jwk-jose-0.1.8.tgz#2dc8ad2cc900e7bc231add293f6518b06dc017ec" - integrity sha512-aoU2Q0GpIl388KhCcv9YvAxNscALUv3xzLq5gjVPdJ+zmqw94nGZNcjiNvpnbfS+VQM9e2DrrTuwmDXnxfrrSA== +"@atproto/jwk-jose@0.1.9": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@atproto/jwk-jose/-/jwk-jose-0.1.9.tgz#bd4a899ea2d497808300c40106795f5645c01f75" + integrity sha512-HT9GcUe6htDxI5OSYXWdeS6QZ9lpuDDvJk508ppi8a48E/1f8eumoM0QhgbFRF9IKAnnFrtnZDOAvljQzFKwwQ== dependencies: - "@atproto/jwk" "0.3.0" + "@atproto/jwk" "0.4.0" jose "^5.2.0" -"@atproto/jwk@0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@atproto/jwk/-/jwk-0.3.0.tgz#275fa676f6b5988ddedf4ee0475dd285de9b831b" - integrity sha512-MIAXyNMGu1tCNHjqW/8jqfE/wgWCIoK2cJ0mR6UxwhNPvkbe35TcpRYJdtQu/E6MUd7TziyDBa/GO4dKAiePhQ== +"@atproto/jwk@0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@atproto/jwk/-/jwk-0.4.0.tgz#f32265be172492c38434c556a124b954f249cee8" + integrity sha512-tvp4iZrzqEzKCeTOKz50/o6WdsZzOuWmWjF6On5QAp04fLwLpsFu2Hixgx/lA1KBO0O4sns7YSGcAqSSX6Rdog== dependencies: multiformats "^9.9.0" zod "^3.23.8" @@ -286,32 +286,32 @@ multiformats "^9.9.0" zod "^3.23.8" -"@atproto/oauth-provider-api@0.1.4": - version "0.1.4" - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.1.4.tgz#a775182e3648dc693a04e3cb604eb62cd9ddfd8c" - integrity sha512-3PRrf0gTAVMCETjtIH/3AaQaHBDbjsRBc/OYrlWBZ9IPplchBXtQGH/KcnjE4kK2Ef8p45qQSl3dNWg3EXsbHQ== +"@atproto/oauth-provider-api@0.1.6": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.1.6.tgz#769a70caaac9b5144f9f867518523d1568a6b47c" + integrity sha512-4Q6ZCnTmmdiWiA+KMrfbZmqjxTSgMe+YE68+3RccwOCIgPt171TiDHGKIayep9n1RDnuucVQoqvVXOT4kmAsjw== dependencies: - "@atproto/jwk" "0.3.0" - "@atproto/oauth-types" "0.3.0" + "@atproto/jwk" "0.4.0" + "@atproto/oauth-types" "0.4.0" -"@atproto/oauth-provider-frontend@0.1.8": - version "0.1.8" - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.1.8.tgz#21d944566c63f54524f239a10f7c65d150982f40" - integrity sha512-uqfHv+n2xq7vTpuBP1Red7PhpaAbbJbwSbRsSfplJQ16XmF5NCMU8dHGCGRTEHngLZ9UquuIefN3w1QTrNzD0w== +"@atproto/oauth-provider-frontend@0.1.10": + version "0.1.10" + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.1.10.tgz#d7176819d0ae1401ca5d70f7afec253621901a79" + integrity sha512-bOFpi5OIxWv4Q9ci1+PAXEzIZaiu5inepC7pRFYqgqgLoCO0MWH/5Qkn/f6jMpDwPdtBqAiPg9tjE7E3le6NJA== optionalDependencies: - "@atproto/oauth-provider-api" "0.1.4" + "@atproto/oauth-provider-api" "0.1.6" -"@atproto/oauth-provider-ui@0.1.9": - version "0.1.9" - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.1.9.tgz#8c43a1affa94ecb537072e6d569b8a24cdd42e72" - integrity sha512-a6/VAeQWRMxpgnqo/TuqXg3EW2tO68jLh8Mv1uyV1NiZbT7fNlgkII/djIl3fLoEa95I3p236NZxjhKELSBbGg== +"@atproto/oauth-provider-ui@0.1.11": + version "0.1.11" + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.1.11.tgz#cb6194ac0b93f1d4b5d6717f80c55a3a20a8c690" + integrity sha512-9fflyDt4Y3RDJIfbonxVeMbQtLLQrkQSDhWhPXp9xbZ/uYBddaAw+svBfFoMY7dxdlJbQeUPobsUctEm3qAILg== optionalDependencies: - "@atproto/oauth-provider-api" "0.1.4" + "@atproto/oauth-provider-api" "0.1.6" -"@atproto/oauth-provider@^0.9.1": - version "0.9.1" - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.9.1.tgz#0147b75d1ad444455159f0a687ce87b3b49a2894" - integrity sha512-2Gm3jv45cGLmUQV0C4/orCJBsHu4wajy+JTN9f/ATX3vvjnFtAd/1GRvAMKDGXtdF7VIjNFlD+4lqhoDxYJpng== +"@atproto/oauth-provider@^0.9.3": + version "0.9.3" + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.9.3.tgz#047b2e520e5cf127385adddc1dca47207b0ca113" + integrity sha512-TAhsCYDB/1twEA1vqjLAz7lxKI8W59eNs239MujE35Cc9l4lRHyMopoFv5JmgNnxDvloB5l6RxpTbXVC6wnKpQ== dependencies: "@atproto-labs/fetch" "0.2.3" "@atproto-labs/fetch-node" "0.1.9" @@ -320,12 +320,12 @@ "@atproto-labs/simple-store-memory" "0.1.3" "@atproto/common" "^0.4.11" "@atproto/did" "0.1.5" - "@atproto/jwk" "0.3.0" - "@atproto/jwk-jose" "0.1.8" - "@atproto/oauth-provider-api" "0.1.4" - "@atproto/oauth-provider-frontend" "0.1.8" - "@atproto/oauth-provider-ui" "0.1.9" - "@atproto/oauth-types" "0.3.0" + "@atproto/jwk" "0.4.0" + "@atproto/jwk-jose" "0.1.9" + "@atproto/oauth-provider-api" "0.1.6" + "@atproto/oauth-provider-frontend" "0.1.10" + "@atproto/oauth-provider-ui" "0.1.11" + "@atproto/oauth-types" "0.4.0" "@atproto/syntax" "0.4.0" "@hapi/accept" "^6.0.3" "@hapi/address" "^5.1.1" @@ -339,20 +339,20 @@ jose "^5.2.0" zod "^3.23.8" -"@atproto/oauth-types@0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.3.0.tgz#8d49d939486ac281bc13d0b1fe4462b7e519fdf0" - integrity sha512-ptfsJARKODXfuOoDQag4a6PpEkDEj4Urz3jOmnQZy2YspPc/TNm1o0HglU0YehELv1vfhh9gEz40BJztPPhiLA== +"@atproto/oauth-types@0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.4.0.tgz#fb110717dd1e8593adffc6eaa85e7ab4f0713740" + integrity sha512-FrRH9JsPw9H4JxfPDrbrI+pB102tbHTygajfHay7xwz78HPOjSbWPRgWW2hYS4w8vDYdB3PYbBj1jPoKetW7LA== dependencies: - "@atproto/jwk" "0.3.0" + "@atproto/jwk" "0.4.0" zod "^3.23.8" -"@atproto/ozone@^0.1.121": - version "0.1.121" - resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.121.tgz#309b7e876f3b598ed4e79bb5a79e2346931588fe" - integrity sha512-kc3NxiXSPqQmWz8yXlV5cFnZ469ViQd0AexEMw467AcB8ikK1WSxhLsa1EiNAQuLOOpyeXSmAKGAUFHzSOIMpw== +"@atproto/ozone@^0.1.126": + version "0.1.126" + resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.126.tgz#a4502121b9732a494a8b25a04be89b7eb0a4e2dd" + integrity sha512-h1yP1NArjjHlOam9wamGIUSrG9tGynkZ0+Y6t21u7dwrg1o/TRpXSXemCYZhtz3zqdd4Yu5VyavoWPtEFdr+rQ== dependencies: - "@atproto/api" "^0.15.16" + "@atproto/api" "^0.15.21" "@atproto/common" "^0.4.11" "@atproto/crypto" "^0.4.4" "@atproto/identity" "^0.4.8" @@ -377,21 +377,21 @@ undici "^6.14.1" ws "^8.12.0" -"@atproto/pds@^0.4.150": - version "0.4.150" - resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.150.tgz#45686b05b8ed46e265efa5231ab16e6eda72a8e8" - integrity sha512-CPT6H2uDTe4ZAyxQbws2dIlmdFFf6GQGwMc0OE3kI1wBBaLHprpexjM2Gd4ObtYNxGOOV0fwoCDAth8qqZ4XVw== +"@atproto/pds@^0.4.156": + version "0.4.156" + resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.156.tgz#1815ced4ab8b51cf9fe9a5712cd136a0b1d82392" + integrity sha512-/8j/ihTLRhCI1sxkEvs2kuX4ehPKvsnwDxhmhdVvYqbKrjmGRTsDIZDV1K7dVFcYdCypOEPXsgTReh2lVhcC8w== dependencies: "@atproto-labs/fetch-node" "0.1.9" "@atproto-labs/xrpc-utils" "0.0.16" - "@atproto/api" "^0.15.16" - "@atproto/aws" "^0.2.22" + "@atproto/api" "^0.15.21" + "@atproto/aws" "^0.2.24" "@atproto/common" "^0.4.11" "@atproto/crypto" "^0.4.4" "@atproto/identity" "^0.4.8" "@atproto/lexicon" "^0.4.11" - "@atproto/oauth-provider" "^0.9.1" - "@atproto/repo" "^0.8.2" + "@atproto/oauth-provider" "^0.9.3" + "@atproto/repo" "^0.8.4" "@atproto/syntax" "^0.4.0" "@atproto/xrpc" "^0.7.0" "@atproto/xrpc-server" "^0.8.0" @@ -424,10 +424,10 @@ undici "^6.19.8" zod "^3.23.8" -"@atproto/repo@^0.8.2": - version "0.8.2" - resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.2.tgz#7953cb2c637c94505da76f74a784b2aae050c204" - integrity sha512-lP0g5Uw3TUC2Tc7te8YKCpRoIhBYI+Uvn11fupGEaMcMjgLdYtB0Kc0AiqWXF42KqlBG9dAEoJITi2GRzDNHUg== +"@atproto/repo@^0.8.4": + version "0.8.4" + resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.4.tgz#f6a1b4bce8cf86cd1825069f9cd2916a5f86e774" + integrity sha512-WgyARo6UcOnhbRsRVuNjXOH5MPTTHVDsaIavPeQl5erq5foE/pQKC7B7FLTJmhpC6GPZHJ5M2doAyXRXv5UHGA== dependencies: "@atproto/common" "^0.4.11" "@atproto/common-web" "^0.4.2" @@ -439,15 +439,15 @@ varint "^6.0.0" zod "^3.23.8" -"@atproto/sync@^0.1.26": - version "0.1.26" - resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.26.tgz#6be2876be612d9cd704452598ee679b2e912cfe3" - integrity sha512-bpUIajtPrE3RgFW8mIfrI4EM/LJ4JjQhI5fsqc78zCHZawuflpllf1aH70roDWWiskMWoiLWnVRxdYXdeEgbXA== +"@atproto/sync@^0.1.28": + version "0.1.28" + resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.28.tgz#7c5c469dd899b4be86e5d993af66646c71d63eaf" + integrity sha512-faCsOwcYQHxHmNWRPykV0hTccXaG15XoUMZozfmoFOKFSliTgDETTovSAVe05mNSBUvMWUGl8fdEwHRzq1Q8sA== dependencies: "@atproto/common" "^0.4.11" "@atproto/identity" "^0.4.8" "@atproto/lexicon" "^0.4.11" - "@atproto/repo" "^0.8.2" + "@atproto/repo" "^0.8.4" "@atproto/syntax" "^0.4.0" "@atproto/xrpc-server" "^0.8.0" multiformats "^9.9.0" -- 2.51.2