From 5aa041fdf290c48615e32838d70bd302fa788afa Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 11 Jan 2024 14:54:15 -0800 Subject: [PATCH 1/5] types: Update GetText jsdoc for Hooks-based use And drop the reference to withGetText, which we shouldn't need in a Hooks-based world. Indeed that HOC isn't even used anymore; we'll remove it next. --- src/types.js | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/types.js b/src/types.js index 0ef111b16ab..40481327dc6 100644 --- a/src/types.js +++ b/src/types.js @@ -372,15 +372,8 @@ export type LocalizableReactText = /** * Usually called `_`, and invoked like `_('Message')` -> `'Nachricht'`. * - * To use, put these two lines at the top of a React component's body: - * - * static contextType = TranslationContext; - * context: GetText; - * - * and then in methods, say `const _ = this.context`. - * - * Alternatively, for when `context` is already in use: use `withGetText` - * and then say `const { _ } = this.props`. + * To use: + * const _ = React.useContext(TranslationContext); */ export type GetText = {| (message: string, values?: {| +[string]: MessageFormatPrimitiveValue |}): string, From 165b1d4d0defe8b46e16edb54d80276cc04abc32 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 11 Jan 2024 14:52:45 -0800 Subject: [PATCH 2/5] TranslationProvider [nfc]: Remove unused withGetText It looks like our last use of this was removed in 8a0e48694. --- src/boot/TranslationProvider.js | 21 --------------------- src/webview/MessageList.js | 4 ++-- 2 files changed, 2 insertions(+), 23 deletions(-) diff --git a/src/boot/TranslationProvider.js b/src/boot/TranslationProvider.js index 8dcfb27f2a2..48c14d10d76 100644 --- a/src/boot/TranslationProvider.js +++ b/src/boot/TranslationProvider.js @@ -12,27 +12,6 @@ import messages from '../i18n/messages'; // $FlowFixMe[incompatible-type] could put a well-typed mock value here, to help write tests export const TranslationContext: React.Context = React.createContext(undefined); -/** - * Provide `_` to the wrapped component, passing other props through. - * - * This can be useful when the component is already using its `context` - * property for a different context provider. When that isn't the case, - * simply saying `context: TranslationContext` may be more convenient. - * Or in a function component, `const _ = useContext(TranslationContext);`. - */ -export function withGetText>( - WrappedComponent: C, -): React.ComponentType<$ReadOnly<$Exact<$Diff, {| _: GetText |}>>>> { - // eslint-disable-next-line func-names - return function (props: $Exact<$Diff, {| _: GetText |}>>): React.Node { - return ( - - {_ => } - - ); - }; -} - const makeGetText = (intl: IntlShape): GetText => { const _ = (message, values_) => { const text = typeof message === 'object' ? message.text : message; diff --git a/src/webview/MessageList.js b/src/webview/MessageList.js index 4bc75af33b8..cf18b22d4ca 100644 --- a/src/webview/MessageList.js +++ b/src/webview/MessageList.js @@ -65,8 +65,8 @@ type OuterProps = $ReadOnly<{| * * This can be thought of -- hence the name -- as the React props for a * notional inner component, like we'd have if we obtained this data through - * HOCs like `connect` and `withGetText`. (Instead, we use Hooks, and don't - * have a separate inner component.) + * HOCs like `connect`. (Instead, we use Hooks, and don't have a separate + * inner component.) */ export type Props = $ReadOnly<{| ...OuterProps, From 0dabf13c656416a2887f86646befef0543666ae6 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 10 Jan 2024 14:34:44 -0800 Subject: [PATCH 3/5] realm state: Add pushNotificationsEnabledEndTimestamp --- src/__tests__/lib/exampleData.js | 1 + src/realm/__tests__/realmReducer-test.js | 12 ++++++++++++ src/realm/realmReducer.js | 7 +++++++ src/reduxTypes.js | 1 + src/storage/__tests__/migrations-test.js | 6 +++--- src/storage/migrations.js | 3 +++ 6 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/__tests__/lib/exampleData.js b/src/__tests__/lib/exampleData.js index 55ae2cf435d..768ad8f2680 100644 --- a/src/__tests__/lib/exampleData.js +++ b/src/__tests__/lib/exampleData.js @@ -817,6 +817,7 @@ export const action = Object.freeze({ realm_presence_disabled: true, realm_private_message_policy: 3, realm_push_notifications_enabled: true, + realm_push_notifications_enabled_end_timestamp: 1704926069, realm_send_welcome_emails: true, realm_signup_notifications_stream_id: 3, realm_upload_quota_mib: 10, diff --git a/src/realm/__tests__/realmReducer-test.js b/src/realm/__tests__/realmReducer-test.js index 8ca4299698a..732336ca68a 100644 --- a/src/realm/__tests__/realmReducer-test.js +++ b/src/realm/__tests__/realmReducer-test.js @@ -60,6 +60,8 @@ describe('realmReducer', () => { messageContentDeleteLimitSeconds: action.data.realm_message_content_delete_limit_seconds, messageContentEditLimitSeconds: action.data.realm_message_content_edit_limit_seconds, pushNotificationsEnabled: action.data.realm_push_notifications_enabled, + pushNotificationsEnabledEndTimestamp: + action.data.realm_push_notifications_enabled_end_timestamp, webPublicStreamsEnabled: action.data.server_web_public_streams_enabled, createPublicStreamPolicy: action.data.realm_create_public_stream_policy, createPrivateStreamPolicy: action.data.realm_create_private_stream_policy, @@ -408,6 +410,16 @@ describe('realmReducer', () => { check(false, false); }); + describe('pushNotificationsEnabledEndTimestamp / push_notifications_enabled_end_timestamp', () => { + const check = mkCheck( + 'pushNotificationsEnabledEndTimestamp', + 'push_notifications_enabled_end_timestamp', + ); + check(123, null); + check(null, 123); + check(123, 234); + }); + describe('create{Private,Public}StreamPolicy / create_stream_policy', () => { // TODO(server-5.0): Stop expecting create_stream_policy; remove. diff --git a/src/realm/realmReducer.js b/src/realm/realmReducer.js index 59f78ca2f13..8371401d417 100644 --- a/src/realm/realmReducer.js +++ b/src/realm/realmReducer.js @@ -44,6 +44,7 @@ const initialState = { messageContentDeleteLimitSeconds: null, messageContentEditLimitSeconds: 1, pushNotificationsEnabled: true, + pushNotificationsEnabledEndTimestamp: null, createPublicStreamPolicy: CreatePublicOrPrivateStreamPolicy.MemberOrAbove, createPrivateStreamPolicy: CreatePublicOrPrivateStreamPolicy.MemberOrAbove, webPublicStreamsEnabled: false, @@ -146,6 +147,8 @@ export default ( messageContentDeleteLimitSeconds: action.data.realm_message_content_delete_limit_seconds, messageContentEditLimitSeconds: action.data.realm_message_content_edit_limit_seconds, pushNotificationsEnabled: action.data.realm_push_notifications_enabled, + pushNotificationsEnabledEndTimestamp: + action.data.realm_push_notifications_enabled_end_timestamp ?? null, createPublicStreamPolicy: action.data.realm_create_public_stream_policy ?? action.data.realm_create_stream_policy @@ -255,6 +258,10 @@ export default ( if (data.push_notifications_enabled !== undefined) { result.pushNotificationsEnabled = data.push_notifications_enabled; } + if (data.push_notifications_enabled_end_timestamp !== undefined) { + result.pushNotificationsEnabledEndTimestamp = + data.push_notifications_enabled_end_timestamp; + } if (data.create_stream_policy !== undefined) { // TODO(server-5.0): Stop expecting create_stream_policy; simplify. result.createPublicStreamPolicy = data.create_stream_policy; diff --git a/src/reduxTypes.js b/src/reduxTypes.js index 8a88a78de67..6bd34307f0f 100644 --- a/src/reduxTypes.js +++ b/src/reduxTypes.js @@ -302,6 +302,7 @@ export type RealmState = {| +messageContentDeleteLimitSeconds: number | null, +messageContentEditLimitSeconds: number, +pushNotificationsEnabled: boolean, + +pushNotificationsEnabledEndTimestamp: number | null, +createPublicStreamPolicy: CreatePublicOrPrivateStreamPolicyT, +createPrivateStreamPolicy: CreatePublicOrPrivateStreamPolicyT, +webPublicStreamsEnabled: boolean, diff --git a/src/storage/__tests__/migrations-test.js b/src/storage/__tests__/migrations-test.js index 95a68a2454d..5e545092c85 100644 --- a/src/storage/__tests__/migrations-test.js +++ b/src/storage/__tests__/migrations-test.js @@ -112,7 +112,7 @@ describe('migrations', () => { // What `base` becomes after all migrations. const endBase = { ...base62, - migrations: { version: 64 }, + migrations: { version: 65 }, }; for (const [desc, before, after] of [ @@ -135,9 +135,9 @@ describe('migrations', () => { // redundant with this one, because none of the migration steps notice // whether any properties outside `storeKeys` are present or not. [ - 'check dropCache at 64', + 'check dropCache at 65', // Just before the `dropCache`, plus a `cacheKeys` property, plus junk. - { ...base62, migrations: { version: 63 }, mute: [], nonsense: [1, 2, 3] }, + { ...base62, migrations: { version: 64 }, mute: [], nonsense: [1, 2, 3] }, // Should wind up with the same result as without the extra properties. endBase, ], diff --git a/src/storage/migrations.js b/src/storage/migrations.js index c1f249c231c..cec93ff111a 100644 --- a/src/storage/migrations.js +++ b/src/storage/migrations.js @@ -532,6 +532,9 @@ const migrationsInner: {| [string]: (LessPartialState) => LessPartialState |} = // Add enableGuestUserIndicator to state.realm '64': dropCache, + // Add pushNotificationsEnabledEndTimestamp to state.realm + '65': dropCache, + // TIP: When adding a migration, consider just using `dropCache`. // (See its jsdoc for guidance on when that's the right answer.) }; From cd1eebd1880df7e0d26776cbb603247b317fc73b Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 11 Jan 2024 14:04:32 -0800 Subject: [PATCH 4/5] reactUtils: Add and use useDateRefreshedAtInterval We'll also start using this in useNotificationReportsByIdentityKey, to check for a new NotificationProblem that will apply when the server is about to stop enabling push notifications. --- package.json | 1 + src/__tests__/reactUtils-test.js | 45 ++++++++++++++++++++++++++++- src/common/ServerPushSetupBanner.js | 7 +++-- src/reactUtils.js | 26 +++++++++++++++++ yarn.lock | 15 ++++++++++ 5 files changed, 90 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 2974e1492ac..c30dc189dda 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "@octokit/core": "^3.4.0", "@rollup/plugin-babel": "^5.2.0", "@rollup/plugin-node-resolve": "^13.0.4", + "@testing-library/react-hooks": "^8.0.1", "@types/react-native": "~0.67.6", "@vusion/webfonts-generator": "^0.8.0", "ast-types": "^0.16.1", diff --git a/src/__tests__/reactUtils-test.js b/src/__tests__/reactUtils-test.js index 402625bdf15..c53e880165a 100644 --- a/src/__tests__/reactUtils-test.js +++ b/src/__tests__/reactUtils-test.js @@ -3,9 +3,11 @@ import React from 'react'; import type { ComponentType } from 'react'; // $FlowFixMe[untyped-import] import { create, act } from 'react-test-renderer'; +// $FlowFixMe[untyped-import] +import * as ReactHooksTesting from '@testing-library/react-hooks'; import { fakeSleep } from './lib/fakeTimers'; -import { useHasStayedTrueForMs } from '../reactUtils'; +import { useDateRefreshedAtInterval, useHasStayedTrueForMs } from '../reactUtils'; describe('useHasNotChangedForMs', () => { // This gets indirect coverage because useHasStayedTrueForMs calls it, and @@ -33,6 +35,11 @@ describe('useHasStayedTrueForMs', () => { * repeatedly * - boring details like how the mock component is implemented */ + // We wrote these tests a long time before our first experiment with + // @testing-library/react-hooks (for useDateRefreshedAtInterval), in which + // we let the library take care of defining and rendering a component. The + // setup for these older tests is much more verbose. + // // I'm not totally clear on everything `act` does, but react-test-renderer // seems to recommend it strongly enough that we actually get errors if we // don't use it. Following links -- @@ -249,3 +256,39 @@ describe('useHasStayedTrueForMs', () => { }); } }); + +test('useDateRefreshedAtInterval', async () => { + const interval = 60_000; + + function sleep(ms: number): Promise { + return ReactHooksTesting.act(() => fakeSleep(ms)); + } + + // https://react-hooks-testing-library.com/reference/api#renderhook + const { result } = ReactHooksTesting.renderHook(() => useDateRefreshedAtInterval(interval)); + + let value = result.current; + expect(result.error).toBeUndefined(); + + await sleep(interval / 10); + expect(result.error).toBeUndefined(); + expect(result.current).toBe(value); + + await sleep(interval / 10); + expect(result.error).toBeUndefined(); + expect(result.current).toBe(value); + + await sleep(interval); + expect(result.error).toBeUndefined(); + expect(result.current).not.toBe(value); + expect((result.current - value) * 1000).toBeGreaterThanOrEqual(interval); + value = result.current; + + await sleep(interval / 10); + expect(result.error).toBeUndefined(); + expect(result.current).toBe(value); + + await sleep(interval / 10); + expect(result.error).toBeUndefined(); + expect(result.current).toBe(value); +}); diff --git a/src/common/ServerPushSetupBanner.js b/src/common/ServerPushSetupBanner.js index 37449645955..716f84c082f 100644 --- a/src/common/ServerPushSetupBanner.js +++ b/src/common/ServerPushSetupBanner.js @@ -15,6 +15,7 @@ import { notifProblemShortReactText, } from '../settings/NotifTroubleshootingScreen'; import type { AppNavigationMethods } from '../nav/AppNavigator'; +import { useDateRefreshedAtInterval } from '../reactUtils'; type Props = $ReadOnly<{| navigation: AppNavigationMethods, @@ -41,6 +42,8 @@ export default function ServerPushSetupBanner(props: Props): Node { const silenceServerPushSetupWarnings = useSelector(getSilenceServerPushSetupWarnings); const realmName = useSelector(getRealmName); + const dateNow = useDateRefreshedAtInterval(60_000); + let visible = false; let text = ''; if (pushNotificationsEnabled) { @@ -49,9 +52,7 @@ export default function ServerPushSetupBanner(props: Props): Node { // don't show } else if ( lastDismissedServerPushSetupNotice !== null - // TODO: Could rerender this component on an interval, to give an - // upper bound on how outdated this `new Date()` can be. - && lastDismissedServerPushSetupNotice >= subWeeks(new Date(), 2) + && lastDismissedServerPushSetupNotice >= subWeeks(dateNow, 2) ) { // don't show } else { diff --git a/src/reactUtils.js b/src/reactUtils.js index b7ba00b4aaf..12e9f6e93dc 100644 --- a/src/reactUtils.js +++ b/src/reactUtils.js @@ -149,3 +149,29 @@ export const useHasStayedTrueForMs = (value: boolean, duration: number): boolean // https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects export const useConditionalEffect = (cb: () => void | (() => void), value: boolean): void => React.useEffect(() => (value ? cb() : undefined), [value, cb]); + +/** + * A Date, as React state that refreshes regularly at an interval. + * + * Use this in a React component that relies on the current date, to make it + * periodically rerender with a refreshed date value. + * + * Don't change the value of refreshIntervalMs. + * + * This uses `setTimeout`, which is subject to slightly late timeouts: + * https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#reasons_for_delays_longer_than_specified + * so don't expect millisecond precision. + */ +export const useDateRefreshedAtInterval = (refreshIntervalMs: number): Date => { + useDebugAssertConstant(refreshIntervalMs); + + const [date, setDate] = React.useState(new Date()); + useConditionalEffect( + React.useCallback(() => { + setDate(new Date()); + }, []), + useHasNotChangedForMs(date, refreshIntervalMs), + ); + + return date; +}; diff --git a/yarn.lock b/yarn.lock index 1652cb94321..768517621a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2595,6 +2595,14 @@ dependencies: defer-to-connect "^2.0.0" +"@testing-library/react-hooks@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12" + integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -10460,6 +10468,13 @@ react-dom@^16.13.1: prop-types "^15.6.2" scheduler "^0.19.1" +react-error-boundary@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + react-freeze@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.3.tgz#5e3ca90e682fed1d73a7cb50c2c7402b3e85618d" From b30969b9b686a7acfc5476ff9f776a705116c69a Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 10 Jan 2024 13:54:09 -0800 Subject: [PATCH 5/5] 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 ( props.onSelect(props.account)}> @@ -139,7 +185,7 @@ export default function AccountItem(props: Props): Node { )} - {singleNotifProblem != null && ( + {(singleNotifProblem != null || expiryWarning != null) && ( {({ pressed }) => ( { + 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 +349,8 @@ export function useNotificationReportsByIdentityKey(): Map new Map( @@ -324,6 +373,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 (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",