From 0f421ee6190fc7afc156730295161a699ca5f8a9 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 10 Jan 2024 13:54:09 -0800 Subject: [PATCH] notifs: Warn about notifications soon to be disabled (not yet with banner) Soon, we'll add a snoozable banner on the home screen. But for now: - Warning icon / subtitle text on the "Notifications" row on the settings screen - Warning row in the notification-settings screen - Warning icon on the "Pick account" screen --- src/account/AccountItem.js | 90 ++++++++++++++----- src/api/registerForEvents.js | 2 + src/settings/NotifTroubleshootingScreen.js | 57 +++++++++++- .../PerAccountNotificationSettingsGroup.js | 22 +++++ src/settings/SettingsScreen.js | 27 ++++-- static/translations/messages_en.json | 2 + 6 files changed, 170 insertions(+), 30 deletions(-) diff --git a/src/account/AccountItem.js b/src/account/AccountItem.js index c8f83470e5c..ac28e8823a3 100644 --- a/src/account/AccountItem.js +++ b/src/account/AccountItem.js @@ -19,10 +19,15 @@ import { accountSwitch } from './accountActions'; import { useNavigation } from '../react-navigation'; import { chooseNotifProblemForShortText, + kPushNotificationsEnabledEndDoc, notifProblemShortText, + pushNotificationsEnabledEndTimestampWarning, } from '../settings/NotifTroubleshootingScreen'; -import { getRealmName } from '../directSelectors'; +import { getGlobalSettings, getRealmName } from '../directSelectors'; import { getHaveServerData } from '../haveServerDataSelectors'; +import { useDateRefreshedAtInterval } from '../reactUtils'; +import { openLinkWithUserPreference } from '../utils/openLink'; +import * as logging from '../utils/logging'; const styles = createStyleSheet({ wrapper: { @@ -69,6 +74,8 @@ export default function AccountItem(props: Props): Node { const navigation = useNavigation(); const dispatch = useGlobalDispatch(); + const globalSettings = useGlobalSelector(getGlobalSettings); + const isActiveAccount = useGlobalSelector(state => getIsActiveAccount(state, { email, realm })); // Don't show the "remove account" button (the "trash" icon) for the @@ -80,6 +87,8 @@ export default function AccountItem(props: Props): Node { const backgroundItemColor = isLoggedIn ? 'hsla(177, 70%, 47%, 0.1)' : 'hsla(0,0%,50%,0.1)'; const textColor = isLoggedIn ? BRAND_COLOR : 'gray'; + const dateNow = useDateRefreshedAtInterval(60_000); + const activeAccountState = useGlobalSelector(tryGetActiveAccountState); // The fallback text '(unknown organization name)' is never expected to // appear in the UI. As of writing, notifProblemShortText doesn't use its @@ -88,36 +97,71 @@ export default function AccountItem(props: Props): Node { // `realmName` will be the real realm name, not the fallback. // TODO(#5005) look for server data even when this item's account is not // the active one. - const realmName = - isActiveAccount && activeAccountState != null && getHaveServerData(activeAccountState) - ? getRealmName(activeAccountState) - : '(unknown organization name)'; + let realmName = '(unknown organization name)'; + let expiryWarning = null; + if (isActiveAccount && activeAccountState != null && getHaveServerData(activeAccountState)) { + realmName = getRealmName(activeAccountState); + expiryWarning = pushNotificationsEnabledEndTimestampWarning(activeAccountState, dateNow); + } const singleNotifProblem = chooseNotifProblemForShortText({ report: notificationReport, ignoreServerHasNotEnabled: silenceServerPushSetupWarnings, }); const handlePressNotificationWarning = React.useCallback(() => { - if (singleNotifProblem == null) { + if (expiryWarning == null && singleNotifProblem == null) { + logging.warn('AccountItem: Notification warning pressed with nothing to show'); + return; + } + + if (expiryWarning != null) { + Alert.alert( + _('Notifications'), + _(expiryWarning.text), + [ + { text: _('Cancel'), style: 'cancel' }, + { + text: _('Details'), + onPress: () => { + openLinkWithUserPreference(kPushNotificationsEnabledEndDoc, globalSettings); + }, + style: 'default', + }, + ], + { cancelable: true }, + ); return; } - Alert.alert( - _('Notifications'), - _(notifProblemShortText(singleNotifProblem, realmName)), - [ - { text: _('Cancel'), style: 'cancel' }, - { - text: _('Details'), - onPress: () => { - dispatch(accountSwitch({ realm, email })); - navigation.push('notifications'); + + if (singleNotifProblem != null) { + Alert.alert( + _('Notifications'), + _(notifProblemShortText(singleNotifProblem, realmName)), + [ + { text: _('Cancel'), style: 'cancel' }, + { + text: _('Details'), + onPress: () => { + dispatch(accountSwitch({ realm, email })); + navigation.push('notifications'); + }, + style: 'default', }, - style: 'default', - }, - ], - { cancelable: true }, - ); - }, [email, singleNotifProblem, realm, realmName, navigation, dispatch, _]); + ], + { cancelable: true }, + ); + } + }, [ + email, + singleNotifProblem, + expiryWarning, + realm, + realmName, + globalSettings, + navigation, + dispatch, + _, + ]); return ( props.onSelect(props.account)}> @@ -139,7 +183,7 @@ export default function AccountItem(props: Props): Node { )} - {singleNotifProblem != null && ( + {(singleNotifProblem != null || expiryWarning != null) && ( {({ pressed }) => ( { return { ...rawInitialData, + realm_push_notifications_enabled_end_timestamp: 1706037926, + zulip_feature_level: rawInitialData.zulip_feature_level ?? 0, zulip_version: zulipVersion, diff --git a/src/settings/NotifTroubleshootingScreen.js b/src/settings/NotifTroubleshootingScreen.js index 053572f667c..f8198c37074 100644 --- a/src/settings/NotifTroubleshootingScreen.js +++ b/src/settings/NotifTroubleshootingScreen.js @@ -9,6 +9,7 @@ import * as MailComposer from 'expo-mail-composer'; import { nativeApplicationVersion } from 'expo-application'; // $FlowFixMe[untyped-import] import uniq from 'lodash.uniq'; +import subDays from 'date-fns/subDays'; import type { RouteProp } from '../react-navigation'; import type { AppNavigationProp } from '../nav/AppNavigator'; @@ -49,6 +50,8 @@ import { ApiError } from '../api/apiErrors'; import NavRow from '../common/NavRow'; import RowGroup from '../common/RowGroup'; import TextRow from '../common/TextRow'; +import type { PerAccountState } from '../reduxTypes'; +import { useDateRefreshedAtInterval } from '../reactUtils'; const { Notifications, // android @@ -272,6 +275,8 @@ export type NotificationReport = {| +zulipVersion: ZulipVersion, +zulipFeatureLevel: number, +pushNotificationsEnabled: boolean, + +pushNotificationsEnabledEndTimestamp: number | null, + +endTimestampIsNear: boolean, +offlineNotification: boolean, +onlineNotification: boolean, +streamNotification: boolean, @@ -289,6 +294,49 @@ function jsonifyNotificationReport(report: NotificationReport): string { ); } +// See https://chat.zulip.org/#narrow/stream/107-kandra/topic/notification.20plan.20expiration.20warnings/near/1719677 +export const kPushNotificationsEnabledEndDoc: URL = new URL( + 'https://zulip.com/help/self-hosted-billing#upgrades-for-legacy-customers', +); + +export const pushNotificationsEnabledEndTimestampWarning = ( + state: PerAccountState, + dateNow: Date, +): {| text: LocalizableText, reactText: LocalizableReactText |} | null => { + if (!getHaveServerData(state)) { + return null; + } + const realmState = getRealm(state); + const timestamp = realmState.pushNotificationsEnabledEndTimestamp; + if (timestamp == null) { + return null; + } + const timestampMs = timestamp * 1000; + if (subDays(new Date(timestampMs), 15) > dateNow) { + return null; + } + const realmName = realmState.name; + const twentyFourHourTime = realmState.twentyFourHourTime; + const message = twentyFourHourTime + ? 'On {endTimestamp, date, short} at {endTimestamp, time, ::H:mm z}, push notifications will be disabled for {realmName}.' + : 'On {endTimestamp, date, short} at {endTimestamp, time, ::h:mm z}, push notifications will be disabled for {realmName}.'; + return { + text: { + text: message, + values: { endTimestamp: timestampMs, realmName }, + }, + reactText: { + text: message, + values: { + endTimestamp: timestampMs, + realmName: ( + + ), + }, + }, + }; +}; + /** * Generate and return a NotificationReport for all accounts we know about. */ @@ -302,6 +350,8 @@ export function useNotificationReportsByIdentityKey(): Map new Map( @@ -324,6 +374,11 @@ export function useNotificationReportsByIdentityKey(): Map getRealm(state).pushNotificationsEnabled); + const perAccountState = useSelector(state => state); + const expiryWarning = pushNotificationsEnabledEndTimestampWarning(perAccountState, dateNow); const silenceServerPushSetupWarnings = useSelector(getSilenceServerPushSetupWarnings); const offlineNotification = useSelector(state => getSettings(state).offlineNotification); const onlineNotification = useSelector(state => getSettings(state).onlineNotification); @@ -68,6 +75,10 @@ export default function PerAccountNotificationSettingsGroup(props: Props): Node const pushToken = useGlobalSelector(state => getGlobalSession(state).pushToken); + const handleExpiryWarningPress = React.useCallback(() => { + openLinkWithUserPreference(kPushNotificationsEnabledEndDoc, globalSettings); + }, [globalSettings]); + const handleSilenceWarningsChange = React.useCallback(() => { dispatch(setSilenceServerPushSetupWarnings(!silenceServerPushSetupWarnings)); }, [dispatch, silenceServerPushSetupWarnings]); @@ -163,6 +174,17 @@ export default function PerAccountNotificationSettingsGroup(props: Props): Node } const children = []; + if (expiryWarning != null) { + children.push( + , + ); + } if (pushNotificationsEnabled) { children.push( , @@ -45,12 +47,17 @@ type Props = $ReadOnly<{| |}>; export default function SettingsScreen(props: Props): Node { + const dateNow = useDateRefreshedAtInterval(60_000); + const theme = useGlobalSelector(state => getGlobalSettings(state).theme); const browser = useGlobalSelector(state => getGlobalSettings(state).browser); const globalSettings = useGlobalSelector(getGlobalSettings); const markMessagesReadOnScroll = globalSettings.markMessagesReadOnScroll; const language = useGlobalSelector(state => getGlobalSettings(state).language); + const perAccountState = useSelector(state => state); + const expiryWarning = pushNotificationsEnabledEndTimestampWarning(perAccountState, dateNow); + const zulipVersion = useSelector(getServerVersion); const identity = useSelector(getIdentity); const notificationReportsByIdentityKey = useNotificationReportsByIdentityKey(); @@ -101,12 +108,20 @@ export default function SettingsScreen(props: Props): Node { title="Notifications" {...(() => { const problem = chooseNotifProblemForShortText({ report: notificationReport }); - return ( - problem != null && { - leftElement: { type: 'icon', Component: IconAlertTriangle, color: kWarningColor }, - subtitle: notifProblemShortReactText(problem, realmName), - } - ); + if (expiryWarning == null && problem == null) { + return; + } + let subtitle = undefined; + if (expiryWarning != null) { + subtitle = expiryWarning.reactText; + } else if (problem != null) { + subtitle = notifProblemShortReactText(problem, realmName); + } + invariant(subtitle != null, 'expected non-null `expiryWarning` or `problem`'); + return { + leftElement: { type: 'icon', Component: IconAlertTriangle, color: kWarningColor }, + subtitle, + }; })()} onPress={() => { navigation.push('notifications'); diff --git a/static/translations/messages_en.json b/static/translations/messages_en.json index 32dfb574ba2..764b771cd26 100644 --- a/static/translations/messages_en.json +++ b/static/translations/messages_en.json @@ -77,6 +77,8 @@ "Terms for {realmName}": "Terms for {realmName}", "Dismiss": "Dismiss", "Push notifications are not enabled for {realmName}.": "Push notifications are not enabled for {realmName}.", + "On {endTimestamp, date, short} at {endTimestamp, time, ::H:mm z}, push notifications will be disabled for {realmName}.": "On {endTimestamp, date, short} at {endTimestamp, time, ::H:mm z}, push notifications will be disabled for {realmName}.", + "On {endTimestamp, date, short} at {endTimestamp, time, ::h:mm z}, push notifications will be disabled for {realmName}.": "On {endTimestamp, date, short} at {endTimestamp, time, ::h:mm z}, push notifications will be disabled for {realmName}.", "The Zulip server at {realm} has not yet registered your device token. A request is in progress.": "The Zulip server at {realm} has not yet registered your device token. A request is in progress.", "The Zulip server at {realm} has not yet registered your device token.": "The Zulip server at {realm} has not yet registered your device token.", "Registration failed": "Registration failed",