Skip to content

Commit

Permalink
notifs: Add snoozable banner for notifs-soon-to-expire
Browse files Browse the repository at this point in the history
With a snooze interval of 8 days.
  • Loading branch information
chrisbobbe committed Jan 26, 2024
1 parent a737cfa commit 8b384ac
Show file tree
Hide file tree
Showing 11 changed files with 231 additions and 8 deletions.
4 changes: 4 additions & 0 deletions src/__tests__/lib/exampleData.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export const makeAccount = (
zulipVersion?: ZulipVersion | null,
ackedPushToken?: string | null,
lastDismissedServerPushSetupNotice?: Date | null,
lastDismissedServerNotifsExpiringBanner?: Date | null,
|} = Object.freeze({}),
): Account => {
const {
Expand All @@ -237,6 +238,7 @@ export const makeAccount = (
zulipVersion: zulipVersionInner = recentZulipVersion,
ackedPushToken = null,
lastDismissedServerPushSetupNotice = null,
lastDismissedServerNotifsExpiringBanner = null,
} = args;
return deepFreeze({
realm: realmInner,
Expand All @@ -247,6 +249,7 @@ export const makeAccount = (
zulipVersion: zulipVersionInner,
ackedPushToken,
lastDismissedServerPushSetupNotice,
lastDismissedServerNotifsExpiringBanner,
silenceServerPushSetupWarnings: false,
});
};
Expand Down Expand Up @@ -648,6 +651,7 @@ export const plusReduxState: GlobalState & PerAccountState = reduxState({
zulipVersion: recentZulipVersion,
zulipFeatureLevel: recentZulipFeatureLevel,
lastDismissedServerPushSetupNotice: null,
lastDismissedServerNotifsExpiringBanner: null,
silenceServerPushSetupWarnings: false,
},
],
Expand Down
78 changes: 78 additions & 0 deletions src/account/__tests__/accountsReducer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,31 @@ describe('accountsReducer', () => {
);
expect(actualState).toEqual([account]);
});

test('when realm_push_notifications_enabled_end_timestamp is null, clears lastDismissedServerNotifsExpiringBanner', () => {
const account = { ...eg.selfAccount, lastDismissedServerNotifsExpiringBanner: new Date() };
const actualState = accountsReducer(
[account],
eg.mkActionRegisterComplete({ realm_push_notifications_enabled_end_timestamp: null }),
);
expect(actualState).toEqual([{ ...account, lastDismissedServerNotifsExpiringBanner: null }]);
});

test('when realm_push_notifications_enabled_end_timestamp is not null, preserves lastDismissedServerNotifsExpiringBanner', () => {
const account = { ...eg.selfAccount, lastDismissedServerNotifsExpiringBanner: new Date() };
const actualState = accountsReducer(
[account],
eg.mkActionRegisterComplete({ realm_push_notifications_enabled_end_timestamp: 1705616035 }),
);
expect(actualState).toEqual([account]);
});

// TODO(server-8.0)
test('legacy: when realm_push_notifications_enabled_end_timestamp missing, preserves lastDismissedServerNotifsExpiringBanner: null', () => {
const account = { ...eg.selfAccount, lastDismissedServerNotifsExpiringBanner: null };
const actualState = accountsReducer([account], eg.mkActionRegisterComplete({}));
expect(actualState).toEqual([account]);
});
});

describe('ACCOUNT_SWITCH', () => {
Expand Down Expand Up @@ -296,5 +321,58 @@ describe('accountsReducer', () => {
expect(actualState).toEqual(stateWithoutDismissedNotice);
});
});

describe('lastDismissedServerNotifsExpiringBanner', () => {
const stateWithDismissedBanner = [
{ ...eg.plusReduxState.accounts[0], lastDismissedServerNotifsExpiringBanner: new Date() },
];
const stateWithoutDismissedBanner = [
{ ...eg.plusReduxState.accounts[0], lastDismissedServerNotifsExpiringBanner: null },
];

const someTimestamp = 1705616035;

test('data.push_notifications_enabled_end_timestamp is null, on state with dismissed banner', () => {
const actualState = accountsReducer(
stateWithDismissedBanner,
eventWith({ push_notifications_enabled_end_timestamp: null }),
);
expect(actualState).toEqual(stateWithoutDismissedBanner);
});

test('data.push_notifications_enabled_end_timestamp is null, on state without dismissed banner', () => {
const actualState = accountsReducer(
stateWithoutDismissedBanner,
eventWith({ push_notifications_enabled_end_timestamp: null }),
);
expect(actualState).toEqual(stateWithoutDismissedBanner);
});

test('data.push_notifications_enabled_end_timestamp is non-null, on state with dismissed banner', () => {
const actualState = accountsReducer(
stateWithDismissedBanner,
eventWith({ push_notifications_enabled_end_timestamp: someTimestamp }),
);
expect(actualState).toEqual(stateWithDismissedBanner);
});

test('data.push_notifications_enabled_end_timestamp is non-null, on state without dismissed banner', () => {
const actualState = accountsReducer(
stateWithoutDismissedBanner,
eventWith({ push_notifications_enabled_end_timestamp: someTimestamp }),
);
expect(actualState).toEqual(stateWithoutDismissedBanner);
});

test('data.push_notifications_enabled_end_timestamp is absent, on state with dismissed banner', () => {
const actualState = accountsReducer(stateWithDismissedBanner, eventWith({}));
expect(actualState).toEqual(stateWithDismissedBanner);
});

test('data.push_notifications_enabled_end_timestamp is absent, on state without dismissed banner', () => {
const actualState = accountsReducer(stateWithoutDismissedBanner, eventWith({}));
expect(actualState).toEqual(stateWithoutDismissedBanner);
});
});
});
});
10 changes: 10 additions & 0 deletions src/account/accountActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
LOGIN_SUCCESS,
DISMISS_SERVER_PUSH_SETUP_NOTICE,
SET_SILENCE_SERVER_PUSH_SETUP_WARNINGS,
DISMISS_SERVER_NOTIFS_EXPIRING_BANNER,
} from '../actionConstants';
import { registerAndStartPolling } from '../events/eventActions';
import { resetToMainTabs } from '../nav/navActions';
Expand All @@ -23,6 +24,15 @@ export const dismissServerPushSetupNotice = (): PerAccountAction => ({
date: new Date(),
});

export const dismissServerNotifsExpiringBanner = (): PerAccountAction => ({
type: DISMISS_SERVER_NOTIFS_EXPIRING_BANNER,

// We don't compute this in a reducer function. Those should be pure
// functions of their params:
// https://redux.js.org/tutorials/fundamentals/part-3-state-actions-reducers#rules-of-reducers
date: new Date(),
});

export const setSilenceServerPushSetupWarnings = (value: boolean): PerAccountAction => ({
type: SET_SILENCE_SERVER_PUSH_SETUP_WARNINGS,
value,
Expand Down
17 changes: 17 additions & 0 deletions src/account/accountsReducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
DISMISS_SERVER_PUSH_SETUP_NOTICE,
ACCOUNT_REMOVE,
SET_SILENCE_SERVER_PUSH_SETUP_WARNINGS,
DISMISS_SERVER_NOTIFS_EXPIRING_BANNER,
} from '../actionConstants';
import { EventTypes } from '../api/eventTypes';
import type { AccountsState, Identity, Action, Account } from '../types';
Expand Down Expand Up @@ -51,6 +52,10 @@ export default (state: AccountsState = initialState, action: Action): AccountsSt
lastDismissedServerPushSetupNotice: action.data.realm_push_notifications_enabled
? null
: current.lastDismissedServerPushSetupNotice,
lastDismissedServerNotifsExpiringBanner:
action.data.realm_push_notifications_enabled_end_timestamp == null
? null
: current.lastDismissedServerNotifsExpiringBanner,
}));

case ACCOUNT_SWITCH: {
Expand Down Expand Up @@ -79,6 +84,7 @@ export default (state: AccountsState = initialState, action: Action): AccountsSt
zulipVersion: null,
zulipFeatureLevel: null,
lastDismissedServerPushSetupNotice: null,
lastDismissedServerNotifsExpiringBanner: null,
silenceServerPushSetupWarnings: false,
},
...state,
Expand Down Expand Up @@ -136,6 +142,13 @@ export default (state: AccountsState = initialState, action: Action): AccountsSt
}));
}

case DISMISS_SERVER_NOTIFS_EXPIRING_BANNER: {
return updateActiveAccount(state, current => ({
...current,
lastDismissedServerNotifsExpiringBanner: action.date,
}));
}

case SET_SILENCE_SERVER_PUSH_SETUP_WARNINGS: {
return updateActiveAccount(state, current => ({
...current,
Expand Down Expand Up @@ -178,6 +191,10 @@ export default (state: AccountsState = initialState, action: Action): AccountsSt
event.data.push_notifications_enabled === true
? null
: current.lastDismissedServerPushSetupNotice,
lastDismissedServerNotifsExpiringBanner:
event.data.push_notifications_enabled_end_timestamp === null
? null
: current.lastDismissedServerNotifsExpiringBanner,
}));
}

Expand Down
2 changes: 2 additions & 0 deletions src/actionConstants.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export const REFRESH_SERVER_EMOJI_DATA: 'REFRESH_SERVER_EMOJI_DATA' = 'REFRESH_S

export const DISMISS_SERVER_PUSH_SETUP_NOTICE: 'DISMISS_SERVER_PUSH_SETUP_NOTICE' =
'DISMISS_SERVER_PUSH_SETUP_NOTICE';
export const DISMISS_SERVER_NOTIFS_EXPIRING_BANNER: 'DISMISS_SERVER_NOTIFS_EXPIRING_BANNER' =
'DISMISS_SERVER_NOTIFS_EXPIRING_BANNER';
export const SET_SILENCE_SERVER_PUSH_SETUP_WARNINGS: 'SET_SILENCE_SERVER_PUSH_SETUP_WARNINGS' =
'SET_SILENCE_SERVER_PUSH_SETUP_WARNINGS';

Expand Down
8 changes: 8 additions & 0 deletions src/actionTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
REGISTER_PUSH_TOKEN_START,
REGISTER_PUSH_TOKEN_END,
SET_SILENCE_SERVER_PUSH_SETUP_WARNINGS,
DISMISS_SERVER_NOTIFS_EXPIRING_BANNER,
} from './actionConstants';

import type { UserMessageFlag } from './api/modelTypes';
Expand Down Expand Up @@ -177,6 +178,11 @@ type DismissServerPushSetupNoticeAction = $ReadOnly<{|
date: Date,
|}>;

type DismissServerNotifsExpiringBannerAction = $ReadOnly<{|
type: typeof DISMISS_SERVER_NOTIFS_EXPIRING_BANNER,
date: Date,
|}>;

type SetSilenceServerPushSetupWarningsAction = $ReadOnly<{|
type: typeof SET_SILENCE_SERVER_PUSH_SETUP_WARNINGS,
value: boolean,
Expand Down Expand Up @@ -677,6 +683,7 @@ export type PerAccountAction =
// state.session
| DismissServerCompatNoticeAction
| DismissServerPushSetupNoticeAction
| DismissServerNotifsExpiringBannerAction
| SetSilenceServerPushSetupWarningsAction
| ToggleOutboxSendingAction
;
Expand Down Expand Up @@ -804,6 +811,7 @@ export function isPerAccountApplicableAction(action: Action): boolean {
case CLEAR_TYPING:
case DISMISS_SERVER_COMPAT_NOTICE:
case DISMISS_SERVER_PUSH_SETUP_NOTICE:
case DISMISS_SERVER_NOTIFS_EXPIRING_BANNER:
case SET_SILENCE_SERVER_PUSH_SETUP_WARNINGS:
case TOGGLE_OUTBOX_SENDING:
(action: PerAccountAction);
Expand Down
76 changes: 76 additions & 0 deletions src/common/ServerNotifsExpiringBanner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/* @flow strict-local */

import React from 'react';
import type { Node } from 'react';
import subDays from 'date-fns/subDays';

import ZulipBanner from './ZulipBanner';
import { useSelector, useDispatch, useGlobalSelector } from '../react-redux';
import { getAccount, getSilenceServerPushSetupWarnings } from '../account/accountsSelectors';
import { dismissServerNotifsExpiringBanner } from '../account/accountActions';
import {
kPushNotificationsEnabledEndDoc,
pushNotificationsEnabledEndTimestampWarning,
} from '../settings/NotifTroubleshootingScreen';
import { useDateRefreshedAtInterval } from '../reactUtils';
import { openLinkWithUserPreference } from '../utils/openLink';
import { getGlobalSettings } from '../directSelectors';

type Props = $ReadOnly<{||}>;

/**
* A "nag banner" saying the server will soon disable notifications, if so.
*
* Offers a dismiss button. If this notice is dismissed, it sleeps for a
* week, then reappears if the warning still applies.
*/
export default function ServerNotifsExpiringBanner(props: Props): Node {
const dispatch = useDispatch();

const globalSettings = useGlobalSelector(getGlobalSettings);

const lastDismissedServerNotifsExpiringBanner = useSelector(
state => getAccount(state).lastDismissedServerNotifsExpiringBanner,
);

const perAccountState = useSelector(state => state);
const dateNow = useDateRefreshedAtInterval(60_000);

const expiryWarning = pushNotificationsEnabledEndTimestampWarning(perAccountState, dateNow);

const silenceServerPushSetupWarnings = useSelector(getSilenceServerPushSetupWarnings);

let visible = false;
let text = '';
if (expiryWarning == null) {
// don't show
} else if (silenceServerPushSetupWarnings) {
// don't show
} else if (
lastDismissedServerNotifsExpiringBanner !== null
&& lastDismissedServerNotifsExpiringBanner >= subDays(dateNow, 8)
) {
// don't show
} else {
visible = true;
text = expiryWarning.reactText;
}

const buttons = [];
buttons.push({
id: 'dismiss',
label: 'Dismiss',
onPress: () => {
dispatch(dismissServerNotifsExpiringBanner());
},
});
buttons.push({
id: 'learn-more',
label: 'Learn more',
onPress: () => {
openLinkWithUserPreference(kPushNotificationsEnabledEndDoc, globalSettings);
},
});

return <ZulipBanner visible={visible} text={text} buttons={buttons} />;
}
2 changes: 2 additions & 0 deletions src/main/HomeScreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import LoadingBanner from '../common/LoadingBanner';
import ServerCompatBanner from '../common/ServerCompatBanner';
import ServerNotifsDisabledBanner from '../common/ServerNotifsDisabledBanner';
import { OfflineNoticePlaceholder } from '../boot/OfflineNoticeProvider';
import ServerNotifsExpiringBanner from '../common/ServerNotifsExpiringBanner';

const styles = createStyleSheet({
wrapper: {
Expand Down Expand Up @@ -71,6 +72,7 @@ export default function HomeScreen(props: Props): Node {
</View>
<ServerCompatBanner />
<ServerNotifsDisabledBanner navigation={navigation} />
<ServerNotifsExpiringBanner />
<LoadingBanner />
<UnreadCards />
</SafeAreaView>
Expand Down
17 changes: 14 additions & 3 deletions src/storage/__tests__/migrations-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,24 @@ describe('migrations', () => {
const base62 = {
...base52,
migrations: { version: 62 },
accounts: base52.accounts.map(a => ({ ...a, silenceServerPushSetupWarnings: false })),
// $FlowIgnore[prop-missing] same type-lie as in the test, below at end
accounts: base52.accounts.map(a => ({
...a,
silenceServerPushSetupWarnings: false,
})),
};

// What `base` becomes after migrations up through 66.
const base66 = {
...base62,
migrations: { version: 66 },
accounts: base62.accounts.map(a => ({ ...a, lastDismissedServerNotifsExpiringBanner: null })),
};

// What `base` becomes after all migrations.
const endBase = {
...base62,
migrations: { version: 65 },
...base66,
migrations: { version: 66 },
};

for (const [desc, before, after] of [
Expand Down
6 changes: 6 additions & 0 deletions src/storage/migrations.js
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,12 @@ const migrationsInner: {| [string]: (LessPartialState) => LessPartialState |} =
// Add pushNotificationsEnabledEndTimestamp to state.realm
'65': dropCache,

// Add `accounts[].lastDismissedServerNotifsExpiringBanner`, as Date | null.
'66': state => ({
...state,
accounts: state.accounts.map(a => ({ ...a, lastDismissedServerNotifsExpiringBanner: null })),
}),

// TIP: When adding a migration, consider just using `dropCache`.
// (See its jsdoc for guidance on when that's the right answer.)
};
Expand Down
Loading

0 comments on commit 8b384ac

Please sign in to comment.