allow resending of an SMS today if they change the time
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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
@@ -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…
|
||||
</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(" ", " ") }}
|
||||
</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…
|
||||
</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(" ", " ") }}
|
||||
<b>Time:</b> {{ notifyingNewActivityTime.replace(" ", " ") }}
|
||||
</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(" ", " ") }}
|
||||
</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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user