diff --git a/src/interfaces/notifyApi.ts b/src/interfaces/notifyApi.ts index 9beb04b2..db839dc4 100644 --- a/src/interfaces/notifyApi.ts +++ b/src/interfaces/notifyApi.ts @@ -192,6 +192,13 @@ export interface AlertAuthorizationRequestBody { timezone?: string; /** Exactly 100 entries. */ jwts: DelegatedAlertJwt[]; + /** + * True stores the batch's JWT for a UTC day that already ran, so a reset can + * fire again today; absent skips such days. Sent by deliberate resets, not by + * the automatic re-upload after a New Activity visit. The push route ignores + * it. + */ + rerunToday?: boolean; } /** diff --git a/src/libs/newActivity.ts b/src/libs/newActivity.ts index 5ed63d66..efe6bccc 100644 --- a/src/libs/newActivity.ts +++ b/src/libs/newActivity.ts @@ -265,6 +265,39 @@ function startTime( return Number.isNaN(time) ? 0 : time; } +/** + * When the cursor that {@link windowStart} returns was recorded, as stored; + * undefined when there is no start or its time was never recorded (a first + * visit from a notification link). + */ +export function windowStartSeenAt( + window: ActivityWindow | undefined, + now: Date, +): string | undefined { + if (!window) return undefined; + if (isSameVisit(window, now)) { + return window.from !== undefined ? window.fromSeenAt : undefined; + } + return window.seenAt ?? window.at; +} + +/** + * The state after the person marks everything up to `end` read: a visit that + * starts and ends at `end`, and no list windows, so the summary, Home and + * every list count from `end`. + */ +export function markedReadState( + state: NewActivityCursorState, + end: CursorPair, + now: Date, +): NewActivityCursorState { + const at = now.toISOString(); + return { + ...(state.uploaded ? { uploaded: state.uploaded } : {}), + visit: { from: end, fromSeenAt: at, to: end, at, seenAt: at }, + }; +} + /** * Where the summary counts a bucket from: the visit's start (or `fallback`, * the notification link's cursors, when the visit has none), unless this diff --git a/src/services/notifications/smsNotificationApi.ts b/src/services/notifications/smsNotificationApi.ts index 6286395d..3ce011cd 100644 --- a/src/services/notifications/smsNotificationApi.ts +++ b/src/services/notifications/smsNotificationApi.ts @@ -330,16 +330,20 @@ export async function deleteSmsPhone( * @param notifyMinuteUtc UTC minute, 0-59 * @param searchState cursors to count from, and the starred handle IDs and * area the daily search uses; see {@link AlertSearchUploadState} + * @param rerunToday true for a deliberate reset (turning texts on, a new + * time), so today can fire again even if it already did */ export async function authorizeSmsAlertSearch( did: string, notifyHourUtc: number, notifyMinuteUtc: number, searchState?: AlertSearchUploadState, + rerunToday = false, ): Promise { const body = { ...(await buildAlertAuthorizationBody(did, notifyHourUtc, notifyMinuteUtc)), ...searchState, + ...(rerunToday ? { rerunToday: true } : {}), }; return smsRequest(did, { method: "POST", diff --git a/src/test/newActivity.test.ts b/src/test/newActivity.test.ts index ec684b77..f3379bd6 100644 --- a/src/test/newActivity.test.ts +++ b/src/test/newActivity.test.ts @@ -21,6 +21,7 @@ import { } from "@/libs/alertSearch"; import { joinCursorPair, + markedReadState, nextWindow, parseCursorParam, parseNewActivityCursorState, @@ -31,6 +32,7 @@ import { ulidJustBefore, unseenStart, windowStart, + windowStartSeenAt, } from "@/libs/newActivity"; import { alertSearchUploadState, @@ -93,6 +95,65 @@ describe("visit windows", () => { }); }); +describe("counted-since time and mark all read", () => { + it("is when the start was recorded, inside and after a visit", () => { + const inside = { + from: "10", + fromSeenAt: minutesAgo(60), + to: "20", + at: minutesAgo(5), + seenAt: minutesAgo(5), + }; + expect(windowStartSeenAt(inside, NOW)).toEqual(minutesAgo(60)); + const after = { ...inside, at: minutesAgo(40), seenAt: minutesAgo(35) }; + expect(windowStartSeenAt(after, NOW)).toEqual(minutesAgo(35)); + expect(windowStartSeenAt({ to: "20", at: minutesAgo(40) }, NOW)).toEqual( + minutesAgo(40), + ); + }); + + it("is unknown without a start or its time", () => { + expect(windowStartSeenAt(undefined, NOW)).toBeUndefined(); + // a first visit: no start + expect( + windowStartSeenAt({ to: "20", at: minutesAgo(5) }, NOW), + ).toBeUndefined(); + // a first visit from a notification link: a start with no time + expect( + windowStartSeenAt({ from: "10", to: "20", at: minutesAgo(5) }, NOW), + ).toBeUndefined(); + }); + + it("marks every list read up to the end, keeping the uploaded cursors", () => { + const end = { endorser: "40", partner: "4-40" }; + const uploaded = { sms: { endorser: "10", partner: "1-10" } }; + const state = markedReadState( + { + visit: { + from: { endorser: "10", partner: "1-10" }, + to: { endorser: "20", partner: "2-20" }, + at: minutesAgo(5), + }, + buckets: { + claims: { to: "30", at: minutesAgo(2), seenAt: minutesAgo(2) }, + }, + uploaded, + }, + end, + NOW, + ); + expect(state.buckets).toBeUndefined(); + expect(state.uploaded).toEqual(uploaded); + expect(windowStartSeenAt(state.visit, NOW)).toEqual(NOW.toISOString()); + expect(summaryStart(state, "claims", NOW)).toEqual("40"); + expect(summaryStart(state, "profilesNearby", NOW)).toEqual("4-40"); + expect(unseenStart(state, "claims")).toEqual("40"); + // and after the visit, from the same place + const later = new Date(NOW.getTime() + 40 * 60 * 1000); + expect(summaryStart(state, "claims", later)).toEqual("40"); + }); +}); + describe("notification link cursors", () => { it("round-trips the endorser and partner cursors through 'c'", () => { const pair = { endorser: "48213", partner: "912-48213" }; diff --git a/src/test/smsNotificationApi.test.ts b/src/test/smsNotificationApi.test.ts index 88904ef9..e1612819 100644 --- a/src/test/smsNotificationApi.test.ts +++ b/src/test/smsNotificationApi.test.ts @@ -1,10 +1,12 @@ import { + authorizeSmsAlertSearch, listSmsPhones, registerSmsPhone, SmsApiError, smsErrorMessage, } from "@/services/notifications/smsNotificationApi"; import { createEndorserJwtForDid } from "@/libs/endorserServer"; +import { buildAlertAuthorizationBody } from "@/services/notifications/alertAuthorizationBatch"; jest.mock("@/libs/endorserServer", () => ({ createEndorserJwtForDid: jest.fn(), @@ -180,3 +182,46 @@ describe("refusals", () => { ); }); }); + +describe("authorizeSmsAlertSearch", () => { + const BODY = { + batchId: "b", + notifyHourUtc: 12, + notifyMinuteUtc: 0, + jwts: [], + }; + + beforeEach(() => { + (buildAlertAuthorizationBody as jest.Mock).mockResolvedValue(BODY); + }); + + async function sentBody( + ...args: Parameters extends [ + string, + number, + number, + ...infer Rest, + ] + ? Rest + : never + ): Promise> { + answer(200, { success: true }); + await authorizeSmsAlertSearch(DID, 12, 0, ...args); + const [, init] = mockedFetch.mock.calls[0]; + return JSON.parse(String(init.body)); + } + + it("asks for a rerun today only when told to", async () => { + expect(await sentBody({ endorserAfterCursor: "4" }, true)).toEqual({ + ...BODY, + endorserAfterCursor: "4", + rerunToday: true, + }); + }); + + it("leaves rerunToday out by default, as the visit refresh uploads", async () => { + const body = await sentBody({ endorserAfterCursor: "4" }); + expect(body).toEqual({ ...BODY, endorserAfterCursor: "4" }); + expect(body).not.toHaveProperty("rerunToday"); + }); +}); diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index a5117561..cbdaa77f 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -144,175 +144,79 @@ - +
+

+ More advanced notifications, such as alerts about new activity for + you, are available once someone registers you. +

+ +

+ Your New Activity alerts are still on. + +

+
+ @@ -1604,33 +1629,71 @@ export default class AccountViewView extends Vue { if (!success) { return; } - // Revoke before clearing the local setting. A failed delete leaves - // the toggle on so the dialog does not claim the subscription is gone. - const revoked = await revokeInAppNewActivity((message) => { - this.reportAlertSearchAuthorizationFailure("revoke", message); - }); - if (!revoked) { + if (!(await this.turnOffInAppNewActivity())) { return false; } - if ( - Capacitor.isNativePlatform() && - DailyNotification?.cancelDualSchedule - ) { - try { - await DailyNotification.cancelDualSchedule(); - } catch (e) { - logger.error("[AccountViewView] cancelDualSchedule failed:", e); - } - } - await this.$saveSettings({ - notifyingNewActivityTime: "", - }); - this.notifyingNewActivity = false; - this.notifyingNewActivityTime = ""; }); } } + /** + * Revoke the in-app New Activity authorization, then cancel the device + * schedule and clear the setting. Revoking first means a failed delete + * leaves the toggle on, so nothing claims the subscription is gone. + * + * @returns false when the revoke failed and nothing changed + */ + private async turnOffInAppNewActivity(): Promise { + const revoked = await revokeInAppNewActivity((message) => { + this.reportAlertSearchAuthorizationFailure("revoke", message); + }); + if (!revoked) { + return false; + } + if (Capacitor.isNativePlatform() && DailyNotification?.cancelDualSchedule) { + try { + await DailyNotification.cancelDualSchedule(); + } catch (e) { + logger.error("[AccountViewView] cancelDualSchedule failed:", e); + } + } + await this.$saveSettings({ + notifyingNewActivityTime: "", + }); + this.notifyingNewActivity = false; + this.notifyingNewActivityTime = ""; + return true; + } + + /** + * Turn off every New Activity channel that is on, for an identity that is + * no longer registered and so sees none of their controls. A channel whose + * revoke fails stays on, and so does the link, for another try. The verified + * phone number is kept, as when the SMS switch is turned off. + */ + async turnOffNewActivityAlerts(): Promise { + this.notify.confirm( + "Turn off your New Activity alerts? You can turn them on again once someone registers you.", + async () => { + if (this.smsRequestInProgress) { + return; + } + if (this.notifyingNewActivity) { + await this.turnOffInAppNewActivity(); + } + if (this.notifyingNewActivitySms && (await this.stopSmsAlerts())) { + await this.$saveSettings({ + notifyingNewActivitySmsTime: "", + smsNotificationsEnabled: false, + }); + this.smsEnabled = false; + this.notifyingNewActivitySms = false; + this.notifyingNewActivitySmsTime = ""; + } + }, + ); + } + /** * Surface an AlertSearch upload or revoke failure. The message states the * failure; callers must not follow it with a success announcement. @@ -1964,6 +2027,9 @@ export default class AccountViewView extends Vue { * @param startFromNow true when turning texts on: the first count starts * now. False for a new time, which keeps the service's cursors so nothing * waiting for the next text is dropped. + * + * Both are deliberate resets, so the upload asks for `rerunToday`: the new + * time fires today even when today's text already went. * @returns true when the service accepted the authorization */ private async authorizeSmsAlerts( @@ -1999,6 +2065,7 @@ export default class AccountViewView extends Vue { ), undefined, ), + true, ); return true; } catch (error) { diff --git a/src/views/NewActivityView.vue b/src/views/NewActivityView.vue index 7a6b60e8..c0b32d52 100644 --- a/src/views/NewActivityView.vue +++ b/src/views/NewActivityView.vue @@ -31,12 +31,28 @@