From bc072570d27e1f397406daea355570f5aec95647 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 2 Jul 2025 00:36:04 +0300 Subject: Activity notification settings (#8485) Co-authored-by: Eric Bailey Co-authored-by: Samuel Newman Co-authored-by: hailey --- assets/icons/bellPlus_stroke2_corner0_rounded.svg | 1 + .../icons/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 ++- .../moderation/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 +- src/screens/Settings/AccessibilitySettings.tsx | 19 +- src/screens/Settings/ActivityPrivacySettings.tsx | 140 ++++++++++ src/screens/Settings/AppPasswords.tsx | 6 +- src/screens/Settings/AppearanceSettings.tsx | 9 +- src/screens/Settings/ExternalMediaPreferences.tsx | 7 +- src/screens/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 ++- src/screens/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 +-- src/state/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 + src/storage/hooks/activity-subscriptions-nudged.ts | 8 + src/storage/schema.ts | 1 + .../com/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 000000000..885d1b7d1 Binary files /dev/null and b/assets/images/activity_notifications_announcement.webp differ 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" -- cgit 1.4.1