From e6731b8820c693f05f52ec2f8e49e13f57264ae0 Mon Sep 17 00:00:00 2001 From: Chris Bobbe <cbobbe@zulip.com> 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 | 92 ++++++++++++++----- src/settings/NotifTroubleshootingScreen.js | 56 ++++++++++- .../PerAccountNotificationSettingsGroup.js | 22 +++++ src/settings/SettingsScreen.js | 27 ++++-- static/translations/messages_en.json | 2 + 5 files changed, 169 insertions(+), 30 deletions(-) diff --git a/src/account/AccountItem.js b/src/account/AccountItem.js index c8f83470e5c..cdbe5665d08 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,73 @@ 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 = silenceServerPushSetupWarnings + ? null + : 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 (singleNotifProblem != null) { + Alert.alert( + _('Notifications'), + _(notifProblemShortText(singleNotifProblem, realmName)), + [ + { text: _('Cancel'), style: 'cancel' }, + { + text: _('Details'), + onPress: () => { + dispatch(accountSwitch({ realm, email })); + navigation.push('notifications'); + }, + 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 (expiryWarning != null) { + Alert.alert( + _('Notifications'), + _(expiryWarning.text), + [ + { text: _('Cancel'), style: 'cancel' }, + { + text: _('Details'), + onPress: () => { + openLinkWithUserPreference(kPushNotificationsEnabledEndDoc, globalSettings); + }, + style: 'default', }, - style: 'default', - }, - ], - { cancelable: true }, - ); - }, [email, singleNotifProblem, realm, realmName, navigation, dispatch, _]); + ], + { cancelable: true }, + ); + } + }, [ + email, + singleNotifProblem, + expiryWarning, + realm, + realmName, + globalSettings, + navigation, + dispatch, + _, + ]); return ( <Touchable style={styles.wrapper} onPress={() => props.onSelect(props.account)}> @@ -139,7 +185,7 @@ export default function AccountItem(props: Props): Node { <ZulipTextIntl style={styles.signedOutText} text="Signed out" numberOfLines={1} /> )} </View> - {singleNotifProblem != null && ( + {(singleNotifProblem != null || expiryWarning != null) && ( <Pressable style={styles.icon} hitSlop={12} onPress={handlePressNotificationWarning}> {({ pressed }) => ( <IconAlertTriangle diff --git a/src/settings/NotifTroubleshootingScreen.js b/src/settings/NotifTroubleshootingScreen.js index 053572f667c..11c61a460ff 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,48 @@ function jsonifyNotificationReport(report: NotificationReport): string { ); } +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: ( + <ZulipText inheritColor inheritFontSize style={{ fontWeight: 'bold' }} text={realmName} /> + ), + }, + }, + }; +}; + /** * Generate and return a NotificationReport for all accounts we know about. */ @@ -302,6 +349,8 @@ export function useNotificationReportsByIdentityKey(): Map<string, NotificationR const accounts = useGlobalSelector(getAccounts); const activeAccountState = useGlobalSelector(tryGetActiveAccountState); + const dateNow = useDateRefreshedAtInterval(60_000); + return React.useMemo( () => new Map( @@ -324,6 +373,11 @@ export function useNotificationReportsByIdentityKey(): Map<string, NotificationR zulipVersion: getServerVersion(activeAccountState), zulipFeatureLevel: getZulipFeatureLevel(activeAccountState), pushNotificationsEnabled: getRealm(activeAccountState).pushNotificationsEnabled, + pushNotificationsEnabledEndTimestamp: + getRealm(activeAccountState).pushNotificationsEnabledEndTimestamp, + endTimestampIsNear: + pushNotificationsEnabledEndTimestampWarning(activeAccountState, dateNow) + != null, offlineNotification: getSettings(activeAccountState).offlineNotification, onlineNotification: getSettings(activeAccountState).onlineNotification, streamNotification: getSettings(activeAccountState).streamNotification, @@ -375,7 +429,7 @@ export function useNotificationReportsByIdentityKey(): Map<string, NotificationR ]; }), ), - [nativeState, accounts, activeAccountState, pushToken, platform], + [nativeState, accounts, activeAccountState, pushToken, platform, dateNow], ); } diff --git a/src/settings/PerAccountNotificationSettingsGroup.js b/src/settings/PerAccountNotificationSettingsGroup.js index 9db00ebc6a3..d6f91aa667d 100644 --- a/src/settings/PerAccountNotificationSettingsGroup.js +++ b/src/settings/PerAccountNotificationSettingsGroup.js @@ -27,6 +27,8 @@ import { NotificationProblem, notifProblemShortReactText, chooseNotifProblemForShortText, + pushNotificationsEnabledEndTimestampWarning, + kPushNotificationsEnabledEndDoc, } from './NotifTroubleshootingScreen'; import { keyOfIdentity } from '../account/accountMisc'; import { ApiError } from '../api/apiErrors'; @@ -34,6 +36,7 @@ import { showErrorAlert } from '../utils/info'; import * as logging from '../utils/logging'; import { TranslationContext } from '../boot/TranslationProvider'; import { setSilenceServerPushSetupWarnings } from '../account/accountActions'; +import { useDateRefreshedAtInterval } from '../reactUtils'; type Props = $ReadOnly<{| navigation: AppNavigationMethods, @@ -48,6 +51,8 @@ export default function PerAccountNotificationSettingsGroup(props: Props): Node const dispatch = useDispatch(); const _ = React.useContext(TranslationContext); + const dateNow = useDateRefreshedAtInterval(60_000); + const auth = useSelector(getAuth); const identity = useSelector(getIdentity); const notificationReportsByIdentityKey = useNotificationReportsByIdentityKey(); @@ -59,6 +64,8 @@ export default function PerAccountNotificationSettingsGroup(props: Props): Node const realmName = useSelector(getRealmName); const zulipFeatureLevel = useSelector(getZulipFeatureLevel); const pushNotificationsEnabled = useSelector(state => 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( + <NavRow + key="expiry" + type="external" + leftElement={{ type: 'icon', Component: IconAlertTriangle, color: kWarningColor }} + title={expiryWarning.reactText} + onPress={handleExpiryWarningPress} + />, + ); + } if (pushNotificationsEnabled) { children.push( <SwitchRow diff --git a/src/settings/SettingsScreen.js b/src/settings/SettingsScreen.js index ab340be9d07..556a242f64d 100644 --- a/src/settings/SettingsScreen.js +++ b/src/settings/SettingsScreen.js @@ -33,11 +33,13 @@ import { useNotificationReportsByIdentityKey, chooseNotifProblemForShortText, notifProblemShortReactText, + pushNotificationsEnabledEndTimestampWarning, } from './NotifTroubleshootingScreen'; import { noTranslation } from '../i18n/i18n'; import { keyOfIdentity } from '../account/accountMisc'; import languages from './languages'; import { getRealmName } from '../directSelectors'; +import { useDateRefreshedAtInterval } from '../reactUtils'; type Props = $ReadOnly<{| navigation: AppNavigationProp<'settings'>, @@ -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 (problem != null) { + subtitle = notifProblemShortReactText(problem, realmName); + } else if (expiryWarning != null) { + subtitle = expiryWarning.reactText; + } + 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",