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",