allow resending of an SMS today if they change the time

This commit is contained in:
2026-10-25 13:40:11 -06:00
parent 500dffee63
commit 1cfe91aae6
7 changed files with 479 additions and 183 deletions
+7
View File
@@ -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;
}
/**
+33
View File
@@ -265,6 +265,39 @@ function startTime<T>(
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<T>(
window: ActivityWindow<T> | 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
@@ -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<AlertAuthorizationResponse> {
const body = {
...(await buildAlertAuthorizationBody(did, notifyHourUtc, notifyMinuteUtc)),
...searchState,
...(rerunToday ? { rerunToday: true } : {}),
};
return smsRequest<AlertAuthorizationResponse>(did, {
method: "POST",
+61
View File
@@ -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" };
+45
View File
@@ -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<typeof authorizeSmsAlertSearch> extends [
string,
number,
number,
...infer Rest,
]
? Rest
: never
): Promise<Record<string, unknown>> {
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");
});
});
+244 -177
View File
@@ -144,175 +144,79 @@
</div>
</div>
<!--
<!-- The notify-api accepts only registered users for both channels. -->
<div
v-if="!isRegistered"
class="mt-4 text-sm"
data-testId="advancedNotificationsUnregistered"
>
<p class="text-slate-500">
More advanced notifications, such as alerts about new activity for
you, are available once someone registers you.
</p>
<!-- alerts turned on while registered: the only way left to stop them -->
<p v-if="notifyingNewActivity || notifyingNewActivitySms" class="mt-2">
Your New Activity alerts are still on.
<button
class="text-blue-500"
:disabled="smsRequestInProgress"
data-testId="turnOffNewActivityAlertsUnregistered"
@click="turnOffNewActivityAlerts"
>
Turn them off
</button>
</p>
</div>
<template v-else>
<!--
New Activity Notification: two delivery channels for the same alert.
Both let the user pick a time; they differ only in how the alert is
delivered (and therefore in the parameters passed to the notify-api):
- In-App: on-device notification via the daily-notification-plugin.
- SMS: text message sent by the notify-api (opt-in).
-->
<div class="mt-4 mb-1">
<div class="font-semibold">
New Activity Notification
<button
class="text-slate-400 fa-fw cursor-pointer"
aria-label="Learn more about New Activity notifications"
@click.stop="showNewActivityNotificationInfo(false)"
>
<font-awesome icon="question-circle" aria-hidden="true" />
</button>
</div>
<p class="text-xs text-slate-500">
Get told about new relevant activity for you. Choose a delivery
channel below -- you can turn on either one, or both.
</p>
</div>
<!-- Channel 1: In-App notification (only with general advanced functions on) -->
<div
v-if="showGeneralAdvanced"
class="flex items-center justify-between mt-3 mb-2 pl-1"
>
<!-- label -->
<div>
<font-awesome
icon="comment"
class="fa-fw text-slate-400"
aria-hidden="true"
/>
In-App Notification
</div>
<!-- toggle -->
<div
class="relative ml-2 cursor-pointer"
role="switch"
:aria-checked="notifyingNewActivity"
aria-label="Toggle New Activity notifications in app"
tabindex="0"
@click.stop.prevent="showNewActivityNotificationChoice()"
>
<input
:checked="notifyingNewActivity"
type="checkbox"
class="sr-only"
readonly
@click.stop.prevent
@change.stop.prevent
/>
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</div>
<div v-if="showGeneralAdvanced" class="mb-2 pl-1">
<router-link class="text-sm text-blue-500" to="/help-notifications">
Troubleshoot your in-app notifications&hellip;
</router-link>
</div>
<div
v-if="notifyingNewActivity && showGeneralAdvanced"
class="w-full pl-1"
>
<div
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
>
<div>
<b>Time:</b> {{ notifyingNewActivityTime.replace(" ", "&nbsp;") }}
</div>
</div>
<div class="mt-2 text-center">
<button
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@click="editNewActivityNotification"
>
Edit In-App Time…
</button>
</div>
</div>
<!-- Channel 2: SMS text messages (opt-in; gated on a verified phone) -->
<div class="flex items-center justify-between mt-3 mb-2 pl-1">
<!-- label -->
<div>
<font-awesome
icon="message"
class="fa-fw text-slate-400"
aria-hidden="true"
/>
{{
notifyingNewActivitySmsPhone
? "SMS Text Message"
: "Enable Text Messages"
}}
</div>
<!-- master toggle: enables the SMS channel (verifies phone on first use) -->
<div
class="relative ml-2 cursor-pointer"
role="switch"
:aria-checked="smsEnabled"
aria-label="Toggle text message delivery"
tabindex="0"
@click.stop.prevent="toggleSmsEnabled()"
>
<input
:checked="smsEnabled"
type="checkbox"
class="sr-only"
readonly
@click.stop.prevent
@change.stop.prevent
/>
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</div>
<div v-if="notifyingNewActivitySmsPhone" class="w-full pl-1">
<div
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200 flex items-center justify-between gap-2 flex-wrap"
>
<!-- Label and value must share one line: Vue's whitespace
condensing deletes a newline-only text node between elements,
which would glue "Texting:" to the number. -->
<div>
<b>Texting:</b> <i>{{ notifyingNewActivitySmsPhone }}</i>
</div>
<button
class="text-red-600"
aria-label="Forget phone number and disable text messages"
@click="forgetSmsPhoneNumber()"
>
<font-awesome icon="trash-can" class="fa-fw" aria-hidden="true" />
Forget Phone Number
</button>
</div>
</div>
<!-- Per-notification SMS toggles: only available while SMS is enabled -->
<div v-if="smsEnabled" class="w-full pl-4">
<div class="flex items-center justify-between mt-1 mb-2">
<div>
New Activity Text
<div class="mt-4 mb-1">
<div class="font-semibold">
New Activity Notification
<button
class="text-slate-400 fa-fw cursor-pointer"
aria-label="Learn more about New Activity text messages"
@click.stop="showNewActivityNotificationInfo(true)"
aria-label="Learn more about New Activity notifications"
@click.stop="showNewActivityNotificationInfo(false)"
>
<font-awesome icon="question-circle" aria-hidden="true" />
</button>
</div>
<p class="text-xs text-slate-500">
Get told about new relevant activity for you. Choose a delivery
channel below -- you can turn on either one, or both.
</p>
</div>
<!-- Channel 1: In-App notification (only with general advanced functions on) -->
<div
v-if="showGeneralAdvanced"
class="flex items-center justify-between mt-3 mb-2 pl-1"
>
<!-- label -->
<div>
<font-awesome
icon="comment"
class="fa-fw text-slate-400"
aria-hidden="true"
/>
In-App Notification
</div>
<!-- toggle -->
<div
class="relative ml-2 cursor-pointer"
role="switch"
:aria-checked="notifyingNewActivitySms"
aria-label="Toggle New Activity text messages"
:aria-checked="notifyingNewActivity"
aria-label="Toggle New Activity notifications in app"
tabindex="0"
@click.stop.prevent="showNewActivitySmsNotificationChoice()"
@click.stop.prevent="showNewActivityNotificationChoice()"
>
<input
:checked="notifyingNewActivitySms"
:checked="notifyingNewActivity"
type="checkbox"
class="sr-only"
readonly
@@ -325,25 +229,146 @@
></div>
</div>
</div>
<div v-if="notifyingNewActivitySms" class="w-full">
<div v-if="showGeneralAdvanced" class="mb-2 pl-1">
<router-link class="text-sm text-blue-500" to="/help-notifications">
Troubleshoot your in-app notifications&hellip;
</router-link>
</div>
<div
v-if="notifyingNewActivity && showGeneralAdvanced"
class="w-full pl-1"
>
<div
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
>
<div>
<b>Time:</b>
{{ notifyingNewActivitySmsTime.replace(" ", "&nbsp;") }}
<b>Time:</b> {{ notifyingNewActivityTime.replace(" ", "&nbsp;") }}
</div>
</div>
<div class="mt-2 text-center">
<button
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@click="editNewActivitySmsNotification"
@click="editNewActivityNotification"
>
Edit SMS Time…
Edit In-App Time…
</button>
</div>
</div>
</div>
<!-- Channel 2: SMS text messages (opt-in; gated on a verified phone) -->
<div class="flex items-center justify-between mt-3 mb-2 pl-1">
<!-- label -->
<div>
<font-awesome
icon="message"
class="fa-fw text-slate-400"
aria-hidden="true"
/>
{{
notifyingNewActivitySmsPhone
? "SMS Text Message"
: "Enable Text Messages"
}}
</div>
<!-- master toggle: enables the SMS channel (verifies phone on first use) -->
<div
class="relative ml-2 cursor-pointer"
role="switch"
:aria-checked="smsEnabled"
aria-label="Toggle text message delivery"
tabindex="0"
@click.stop.prevent="toggleSmsEnabled()"
>
<input
:checked="smsEnabled"
type="checkbox"
class="sr-only"
readonly
@click.stop.prevent
@change.stop.prevent
/>
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</div>
<div v-if="notifyingNewActivitySmsPhone" class="w-full pl-1">
<div
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200 flex items-center justify-between gap-2 flex-wrap"
>
<!-- Label and value must share one line: Vue's whitespace
condensing deletes a newline-only text node between elements,
which would glue "Texting:" to the number. -->
<div>
<b>Texting:</b> <i>{{ notifyingNewActivitySmsPhone }}</i>
</div>
<button
class="text-red-600"
aria-label="Forget phone number and disable text messages"
@click="forgetSmsPhoneNumber()"
>
<font-awesome icon="trash-can" class="fa-fw" aria-hidden="true" />
Forget Phone Number
</button>
</div>
</div>
<!-- Per-notification SMS toggles: only available while SMS is enabled -->
<div v-if="smsEnabled" class="w-full pl-4">
<div class="flex items-center justify-between mt-1 mb-2">
<div>
New Activity Text
<button
class="text-slate-400 fa-fw cursor-pointer"
aria-label="Learn more about New Activity text messages"
@click.stop="showNewActivityNotificationInfo(true)"
>
<font-awesome icon="question-circle" aria-hidden="true" />
</button>
</div>
<!-- toggle -->
<div
class="relative ml-2 cursor-pointer"
role="switch"
:aria-checked="notifyingNewActivitySms"
aria-label="Toggle New Activity text messages"
tabindex="0"
@click.stop.prevent="showNewActivitySmsNotificationChoice()"
>
<input
:checked="notifyingNewActivitySms"
type="checkbox"
class="sr-only"
readonly
@click.stop.prevent
@change.stop.prevent
/>
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</div>
<div v-if="notifyingNewActivitySms" class="w-full">
<div
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
>
<div>
<b>Time:</b>
{{ notifyingNewActivitySmsTime.replace(" ", "&nbsp;") }}
</div>
</div>
<div class="mt-2 text-center">
<button
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@click="editNewActivitySmsNotification"
>
Edit SMS Time…
</button>
</div>
</div>
</div>
</template>
</section>
<PushNotificationPermission ref="pushNotificationPermission" />
<SmsVerificationDialog ref="smsVerificationDialog" />
@@ -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<boolean> {
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<void> {
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) {
+85 -6
View File
@@ -31,12 +31,28 @@
</div>
<template v-else>
<p class="mb-2" data-testId="newActivityTotal">
<span class="text-lg font-semibold"
>{{ total }}{{ anyHitLimit ? "+" : "" }}</span
<div class="flex items-baseline gap-4 mb-2">
<p class="grow" data-testId="newActivityTotal">
<span class="text-lg font-semibold"
>{{ total }}{{ anyHitLimit ? "+" : "" }}</span
>
new since
<template v-if="countedSince"
>{{ formatTime(countedSince) }}.</template
>
<template v-else>your last visit here.</template>
</p>
<button
v-if="total > 0 || anyHitLimit"
type="button"
class="text-sm text-blue-500 whitespace-nowrap"
:disabled="markingRead"
data-testId="newActivityMarkAllRead"
@click="confirmMarkAllRead"
>
new since your last visit here.
</p>
Mark all read
</button>
</div>
<p
v-if="notificationNote"
@@ -121,12 +137,14 @@ import {
NewActivityBucket,
NewActivityCursorState,
NewActivityPrerequisite,
markedReadState,
nextWindow,
parseCursorParam,
parseNewActivityCursorState,
sameCursorPair,
serializeNewActivityCursorState,
windowStart,
windowStartSeenAt,
} from "../libs/newActivity";
import {
alertSearchUploadState,
@@ -150,7 +168,8 @@ import { createNotifyHelpers } from "@/utils/notify";
/**
* New Activity summary: one count per alertSearch bucket since the last visit,
* the same window the daily notification counts. Opening it starts a new
* visit and re-authorizes the notifications from its end.
* visit and re-authorizes the notifications from its end. "Mark all read"
* moves every start to the end of the counts on screen.
*/
@Component({
components: { QuickNav },
@@ -168,6 +187,18 @@ export default class NewActivityView extends Vue {
counts: NewActivityCounts | null = null;
loading = true;
notificationNote = "";
/** When the visit's start cursor was recorded: the counts are new since then. */
countedSince: string | undefined = undefined;
/** When the counts on screen were read; "Mark all read" stops there. */
countedAt: Date | undefined = undefined;
markingRead = false;
formatTime(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
get total(): number {
return this.counts ? totalNewActivity(this.counts) : 0;
@@ -245,9 +276,11 @@ export default class NewActivityView extends Vue {
// becomes the start of the next visit
const from: CursorPair | undefined =
windowStart(state.visit, now) ?? notified;
this.countedSince = windowStartSeenAt(state.visit, now);
// a bucket whose list was opened since then counts from that list's end
this.counts = await countSummaryActivity(context, state, now, notified);
this.countedAt = now;
this.loading = false;
if (notified && from && !sameCursorPair(notified, from)) {
@@ -308,6 +341,52 @@ export default class NewActivityView extends Vue {
}
}
confirmMarkAllRead(): void {
if (!this.countedAt) return;
this.notify.confirm(
"Mark everything counted here as read? Every list starts over from " +
this.formatTime(this.countedAt.toISOString()) +
", and so do your notifications.",
() => this.markAllRead(),
);
}
/**
* Mark everything up to the end of the counts on screen as read, recount
* from there (catching what arrived since), and re-authorize the
* notifications from there.
*/
async markAllRead(): Promise<void> {
const end = this.counts && countsEndPair(this.counts);
if (!this.context || !end) {
this.notify.error("Could not check every server, so nothing changed.");
return;
}
this.markingRead = true;
try {
const now = new Date();
const settings = await this.$accountSettings();
const state = markedReadState(
parseNewActivityCursorState(settings.newActivityCursors),
end,
now,
);
await this.saveCursorState(state);
this.notificationNote = "";
this.countedSince = now.toISOString();
this.counts = await countSummaryActivity(this.context, state, now);
this.countedAt = now;
await this.refreshNotifications(settings, state, end, end);
} catch (err: unknown) {
logger.error("[NewActivityView] Error marking all read:", err);
this.notify.error(
(err as Error)?.message || "There was an error marking all read.",
);
} finally {
this.markingRead = false;
}
}
async saveCursorState(state: NewActivityCursorState): Promise<void> {
await this.$saveUserSettings(this.activeDid, {
newActivityCursors: serializeNewActivityCursorState(state),