Retire app consumption of WAKEUP_PING → /notifications/refresh → api_* scheduling.

Phase 2 of legacy notification retirement: drop the production push refresh chain and dead debug/composable paths while keeping FCM registration, AlertSearch, daily reminder, and dual notifications intact.
This commit is contained in:
Jose Olarte III
2026-09-22 20:39:37 +08:00
parent e77d08e3a8
commit 724131d9a8
6 changed files with 13 additions and 600 deletions
+2 -133
View File
@@ -88,25 +88,6 @@
Manually mints and uploads 100 delegated day JWTs. Requires an active
did:ethr identity and JWT authentication; Test Mode is not used.
</p>
<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"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onBackendRefresh"
>
Refresh Notifications
</button>
<button
class="w-full text-md bg-gradient-to-b from-violet-400 to-violet-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onSimulateWakeupRefresh"
>
Simulate WAKEUP_PING (Local)
</button>
<p class="text-xs text-slate-500">
Local simulation only — calls the refresh API directly (no FCM push).
</p>
<button
class="w-full text-md bg-gradient-to-b from-amber-400 to-amber-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
@@ -128,8 +109,8 @@
{{ realWakeupStatus.message }}
</p>
<p v-else class="text-xs text-slate-500">
Full pipeline — backend `/debug/send-wakeup` → FCM → WAKEUP_PING
handler.
FCM delivery diagnostic only — backend `/debug/send-wakeup`. The app
no longer schedules api_* notifications from WAKEUP_PING.
</p>
</div>
@@ -177,70 +158,6 @@
</div>
</div>
<!-- SECTION F: Mock Timing Presets -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Mock Timing Presets</h2>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in presets"
:key="preset.ms"
class="px-3 py-2 rounded border border-slate-300 bg-white text-sm"
:class="{
'border-blue-500 ring-1 ring-blue-300': intervalMs === preset.ms,
}"
@click="intervalMs = preset.ms"
>
{{ preset.label }}
</button>
</div>
<div class="text-xs text-slate-500 mt-2">
Selected interval: <b>{{ intervalLabel }}</b>
</div>
</div>
<!-- SECTION A: Mock Refresh Controls -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Mock Refresh Controls</h2>
<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"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onMockRefresh"
>
Trigger Mock Refresh
</button>
</div>
<!-- SECTION B: Wakeup Ping Simulator -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Wakeup Ping Simulator</h2>
<p class="text-xs text-slate-500 mb-2">
Exercises the production push handler (not the refresh API shortcut
above).
</p>
<button
class="w-full text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onWakeupPing"
>
Simulate WAKEUP_PING
</button>
</div>
<!-- SECTION C: Flood Test -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Flood Test</h2>
<button
class="w-full text-md bg-gradient-to-b from-rose-400 to-rose-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onFloodTest"
>
Run 20 Refreshes
</button>
</div>
<!-- SECTION D: Pending Notification Inspector -->
<div class="mb-6">
<div class="flex items-center gap-3 mb-2">
@@ -358,14 +275,6 @@ type PendingInfo = {
wallClockSource?: string | null;
};
const presets = [
{ label: "30 sec", ms: 30_000 },
{ label: "1 min", ms: 60_000 },
{ label: "5 min", ms: 5 * 60_000 },
{ label: "10 min", ms: 10 * 60_000 },
];
const intervalMs = ref<number>(60_000);
const busy = ref(false);
const pending = ref<PendingInfo[]>([]);
const pendingInspectorMessage = ref<string | null>(null);
@@ -395,11 +304,6 @@ const truncatedFcmToken = computed(() => {
const eventLog = ref<string[]>([]);
let unsubscribeEventLog: (() => void) | undefined;
const intervalLabel = computed(() => {
const preset = presets.find((p) => p.ms === intervalMs.value);
return preset?.label ?? `${intervalMs.value}ms`;
});
function formatIsoMs(ms: number | null | undefined): string {
if (ms == null || !Number.isFinite(ms)) {
return "";
@@ -423,27 +327,6 @@ async function refreshPending(): Promise<void> {
pendingInspectorMessage.value = result.inspectorUnavailableMessage ?? null;
}
async function onMockRefresh(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.triggerMockRefresh(intervalMs.value);
await refreshPending();
});
}
async function onWakeupPing(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.simulateWakeupPing();
await refreshPending();
});
}
async function onFloodTest(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.runFloodTest(intervalMs.value);
await refreshPending();
});
}
async function onClearNotifications(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.clearNotifications();
@@ -514,20 +397,6 @@ async function onUploadAlertAuthorization(): Promise<void> {
});
}
async function onBackendRefresh(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.triggerBackendRefresh();
await refreshPending();
});
}
async function onSimulateWakeupRefresh(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.simulateWakeupViaRefresh();
await refreshPending();
});
}
function formatRealWakeupStatusMessage(
result: Awaited<
ReturnType<typeof NotificationDebugService.sendRealWakeupPing>
-134
View File
@@ -1,134 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { inject, onBeforeUnmount, onMounted } from "vue";
import { NotificationIface } from "../constants/app";
import { registerToken } from "@/services/notifications/NotificationService";
import { refreshNotifications } from "@/services/notifications/NativeNotificationService";
/**
* Vue 3 composable for notifications
* Provides a concise API for common notification patterns
*/
export const NOTIFICATION_TIMEOUTS = {
BRIEF: 1000, // Very brief toasts ("Sent..." messages)
SHORT: 2000, // Short notifications (clipboard copies, quick confirmations)
STANDARD: 3000, // Standard notifications (success messages, general info)
LONG: 5000, // Longer notifications (errors, warnings, important info)
VERY_LONG: 7000, // Very long notifications (complex operations)
MODAL: -1, // Modal confirmations (no auto-dismiss)
} as const;
export function useNotifications() {
// Inject the notify function from the app
const notify =
inject<(notification: NotificationIface, timeout?: number) => void>(
"notify",
);
if (!notify) {
throw new Error(
"useNotifications must be used within a component that has $notify available",
);
}
let refreshTimer: number | undefined = undefined;
let refreshInFlight: Promise<void> | null = null;
async function refreshNotificationsDebounced(): Promise<void> {
if (refreshTimer != null) {
window.clearTimeout(refreshTimer);
}
refreshTimer = window.setTimeout(() => {
if (!refreshInFlight) {
refreshInFlight = refreshNotifications().finally(() => {
refreshInFlight = null;
});
}
}, 300);
}
const onResume = () => {
void refreshNotificationsDebounced();
};
onMounted(() => {
void refreshNotificationsDebounced();
document.addEventListener("resume", onResume);
});
onBeforeUnmount(() => {
document.removeEventListener("resume", onResume);
if (refreshTimer != null) {
window.clearTimeout(refreshTimer);
}
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function success(_notification: NotificationIface, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function error(_notification: NotificationIface, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function warning(_notification: NotificationIface, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function info(_notification: NotificationIface, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function toast(_title: string, _text?: string, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function copied(_item: string, _timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function sent(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function confirm(
_text: string,
_onYes: () => Promise<void>,
_timeout?: number,
) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function confirmationSubmitted(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function genericError(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function genericSuccess(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function alreadyConfirmed(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function cannotConfirmIssuer(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function cannotConfirmHidden(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function notRegistered(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function notAGive(_timeout?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function notificationOff(
_title: string,
_callback: (success: boolean) => Promise<void>,
_timeout?: number,
) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function downloadStarted(_format: string = "Dexie", _timeout?: number) {}
return {
success,
error,
warning,
info,
toast,
copied,
sent,
confirm,
confirmationSubmitted,
genericError,
genericSuccess,
alreadyConfirmed,
cannotConfirmIssuer,
cannotConfirmHidden,
notRegistered,
notAGive,
notificationOff,
downloadStarted,
/** POST FCM token to `/notifications/register` (same as startup native hook). */
registerFcmToken: registerToken,
refreshNotifications: refreshNotificationsDebounced,
};
}
+1 -1
View File
@@ -23,7 +23,7 @@ import { LRUCache } from "lru-cache";
import * as R from "ramda";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import { NOTIFICATION_TIMEOUTS } from "../composables/useNotifications";
import { NOTIFICATION_TIMEOUTS } from "../utils/notificationUtils";
import { createNotifyHelpers } from "../utils/notify";
import { NOTIFY_PERSONAL_DATA_ERROR } from "../constants/notifications";
import { Contact } from "../db/tables/contacts";
@@ -13,30 +13,8 @@
import { Capacitor } from "@capacitor/core";
import type { PushNotificationSchema } from "@capacitor/push-notifications";
import type {
NotificationRefreshRequest,
NotificationRefreshResponse,
} from "@/interfaces/notifyApi";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import { getOrCreateDeviceId } from "./deviceId";
import { REMINDER_ID_DAILY_REMINDER } from "./reminderIds";
import { configureNativeFetcherIfReady } from "./nativeFetcherConfig";
import {
getNotificationApiBaseUrl,
getTestMode,
} from "./NotificationDebugConfig";
import {
logRefreshFailure,
logRefreshStarted,
logRefreshSuccess,
logScheduleReplacement,
} from "./notificationLog";
import {
getNotificationApiHeaders,
logSkippingRefreshDueToMissingAuth,
notificationApiFailureMessage,
readNotificationApiBody,
} from "./notificationApiAuth";
import { logNotification } from "./NotificationDebugEvents";
/**
@@ -563,200 +541,14 @@ export class NativeNotificationService implements NotificationServiceInterface {
}
}
export type RefreshNotificationsResult = {
ok: boolean;
scheduledCount: number;
status?: number;
errorMessage?: string;
};
/**
* Re-applies native API fetcher credentials (JWT pool, active DID) so background
* notification workers can run. No UI; safe from push handlers while backgrounded.
*/
export async function refreshNotificationsWithDiagnostics(options?: {
source?: string;
}): Promise<RefreshNotificationsResult> {
const startedAt = performance.now();
const source = options?.source;
logRefreshStarted(source);
if (!Capacitor.isNativePlatform()) {
const errorMessage = "not a native platform";
logRefreshFailure(startedAt, errorMessage, undefined, source);
return {
ok: false,
scheduledCount: 0,
errorMessage,
};
}
try {
const auth = await getNotificationApiHeaders("refresh");
if (!auth.ok) {
logSkippingRefreshDueToMissingAuth();
logRefreshFailure(startedAt, auth.message, undefined, source);
return {
ok: false,
scheduledCount: 0,
errorMessage: auth.message,
};
}
let deviceId: string | undefined;
try {
deviceId = await getOrCreateDeviceId();
} catch (err) {
logger.warn(
"[NativeNotificationService] Could not obtain deviceId; skipping refresh",
err,
);
}
if (!deviceId) {
// The service finds the device by deviceId or fcmToken and answers 400
// without either, so there is no request worth sending.
const errorMessage = "no deviceId (cannot identify this device)";
logRefreshFailure(startedAt, errorMessage, undefined, source);
return { ok: false, scheduledCount: 0, errorMessage };
}
const body: NotificationRefreshRequest = {
deviceId,
platform: Capacitor.getPlatform(),
testMode: getTestMode(),
};
const baseUrl = getNotificationApiBaseUrl();
const res = await fetch(`${baseUrl}/notifications/refresh`, {
method: "POST",
headers: auth.headers,
body: JSON.stringify(body),
});
if (!res.ok) {
const errorMessage = notificationApiFailureMessage(
res.status,
await readNotificationApiBody(res),
);
logger.warn("[NativeNotificationService] refreshNotifications failed", {
status: res.status,
statusText: res.statusText,
errorMessage,
});
logRefreshFailure(startedAt, errorMessage, res.status, source);
return {
ok: false,
scheduledCount: 0,
status: res.status,
errorMessage,
};
}
const payload = (await res.json()) as NotificationRefreshResponse;
const scheduledCount = Array.isArray(payload?.nextNotifications)
? payload.nextNotifications.length
: 0;
await applyNotificationRefreshPayload(payload);
logRefreshSuccess(startedAt, scheduledCount, source);
return { ok: true, scheduledCount };
} catch (err) {
logger.error("[NativeNotificationService] Refresh failed", err);
const message = err instanceof Error ? err.message : String(err);
logRefreshFailure(startedAt, message, undefined, source);
return { ok: false, scheduledCount: 0, errorMessage: message };
}
}
export async function refreshNotifications(): Promise<void> {
await refreshNotificationsWithDiagnostics();
}
export type NotificationRefreshPayload = {
shouldNotify?: boolean;
nextNotifications?: Array<{ timestamp?: number }>;
};
// `handleCapacitorPushNotificationReceived` and `applyNotificationRefreshPayload` are used by
// DEV notification simulation tooling; they must stay production-safe because that tooling
// exercises real flows. (`applyNotificationRefreshPayload` is also used by production refresh.)
/**
* Apply a "refresh notifications" payload by clearing and scheduling timestamps via the native plugin.
*
* This is the shared implementation used by:
* - production refresh flow (`refreshNotifications` fetching from backend)
* - dev-only debug flows (mock refresh with local payloads)
*
* Important: This function intentionally mirrors production behavior and does not introduce
* any scheduling logic in UI layers.
*/
export async function applyNotificationRefreshPayload(
payload: unknown,
): Promise<void> {
if (!Capacitor.isNativePlatform()) {
return;
}
const data = payload as NotificationRefreshPayload;
const nextNotifications = data?.nextNotifications;
if (!Array.isArray(nextNotifications)) {
return;
}
const timestamps = nextNotifications
.map((n) => (n as { timestamp?: unknown })?.timestamp)
.filter((t): t is number => typeof t === "number" && Number.isFinite(t));
if (timestamps.length === 0) {
logNotification("Schedule replacement skipped (no valid timestamps)");
return;
}
// Keep existing behavior: ensure background worker credentials are current.
await configureNativeFetcherIfReady();
logScheduleReplacement(timestamps.length);
if (typeof DailyNotification.clearApiNotifications !== "function") {
logger.warn(
"[NativeNotificationService] API notification clear unavailable (plugin clearApiNotifications missing); cannot replace schedule",
);
logNotification(
"Schedule replacement aborted (API notification clear unavailable on plugin)",
);
return;
}
logNotification("Clearing API notifications before refresh");
await DailyNotification.clearApiNotifications();
logNotification("Cleared API notifications");
if (typeof DailyNotification.scheduleApiNotifications !== "function") {
logger.warn(
"[NativeNotificationService] scheduleApiNotifications not available on plugin; cannot apply timestamps",
);
logNotification(
"Schedule replacement aborted (scheduleApiNotifications unavailable)",
);
return;
}
await DailyNotification.scheduleApiNotifications({ timestamps });
logNotification(
`Schedule replacement applied (${timestamps.length} timestamp(s))`,
);
}
/**
* Silent FCM/APNs data push: refresh native notification pipeline when requested by backend.
* Capacitor push delivery hook. Legacy WAKEUP_PING → /notifications/refresh
* consumption was retired; AlertSearch uses visible FCM notifications and is
* not handled here. Keep logging for diagnostics without scheduling api_* work.
*/
export async function handleCapacitorPushNotificationReceived(
notification: PushNotificationSchema,
): Promise<void> {
if (notification.data?.type === "WAKEUP_PING") {
logNotification("WAKEUP_PING handler — invoking refresh");
await refreshNotificationsWithDiagnostics({ source: "WAKEUP_PING" });
return;
}
const type =
typeof notification.data?.type === "string"
? notification.data.type
@@ -1,14 +1,12 @@
/**
* DEV-only notification testing utilities.
*
* IMPORTANT:
* This service intentionally routes through the same production notification
* orchestration paths used by refresh flows, wakeup pushes, and replacement.
* Avoid adding duplicate scheduling logic here.
* Legacy WAKEUP_PING → /notifications/refresh → api_* tooling was removed in
* the Phase 2 retirement. Remaining helpers cover FCM registration, AlertSearch
* authorization upload, backend URL overrides, and pending-notification inspection.
*/
import { Capacitor } from "@capacitor/core";
import type { PushNotificationSchema } from "@capacitor/push-notifications";
import type {
DebugSendWakeupRequest,
DebugSendWakeupResponse,
@@ -38,12 +36,6 @@ import {
notificationApiFailureMessage,
readNotificationApiBody,
} from "./notificationApiAuth";
import {
applyNotificationRefreshPayload,
handleCapacitorPushNotificationReceived,
refreshNotificationsWithDiagnostics,
type NotificationRefreshPayload,
} from "./NativeNotificationService";
import { truncateFcmTokenForLog } from "./notificationLog";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import { NotificationInspector } from "@/plugins/NotificationInspectorPlugin";
@@ -179,19 +171,10 @@ export const NotificationDebugService = {
return result;
},
async triggerBackendRefresh(): Promise<void> {
await refreshNotificationsWithDiagnostics({ source: "debug panel" });
},
/** Local simulation: same API call as a WAKEUP_PING handler (no push payload). */
async simulateWakeupViaRefresh(): Promise<void> {
logNotification("WAKEUP_PING simulation (local refresh API only)");
await refreshNotificationsWithDiagnostics({
source: "WAKEUP_PING simulation",
});
},
/** Full pipeline: backend `/debug/send-wakeup` → FCM → native WAKEUP_PING handler. */
/**
* Backend `/debug/send-wakeup` → FCM only. App no longer consumes WAKEUP_PING
* for api_* scheduling; kept for FCM delivery diagnostics until backend Phase 3.
*/
async sendRealWakeupPing(): Promise<SendRealWakeupPingResult> {
logNotification("Real WAKEUP_PING requested");
@@ -264,62 +247,6 @@ export const NotificationDebugService = {
}
},
generateMockNotifications(
intervalMs: number = 60_000,
): NotificationRefreshPayload {
const now = Date.now();
const future1 = now + intervalMs;
const future2 = now + intervalMs * 2;
return {
shouldNotify: true,
nextNotifications: [{ timestamp: future1 }, { timestamp: future2 }],
};
},
async triggerMockRefresh(intervalMs?: number): Promise<void> {
logNotification("Mock refresh requested");
const payload = this.generateMockNotifications(intervalMs);
const timestamps = payload.nextNotifications?.map((n) => n.timestamp) ?? [];
logNotification(`Mock payload generated (${timestamps.length} timestamps)`);
if (!Capacitor.isNativePlatform()) {
logNotification("Mock refresh skipped: not running on native platform");
return;
}
await applyNotificationRefreshPayload(payload);
logNotification("Mock refresh applied");
},
async simulateWakeupPing(): Promise<void> {
logNotification("Simulating WAKEUP_PING (production push handler)");
if (!Capacitor.isNativePlatform()) {
logNotification("WAKEUP_PING simulation skipped: not native platform");
return;
}
const notification = {
title: "WAKEUP_PING",
body: "",
id: "dev_wakeup_ping",
data: { type: "WAKEUP_PING" },
} as unknown as PushNotificationSchema;
await handleCapacitorPushNotificationReceived(notification);
},
async runFloodTest(intervalMs?: number): Promise<void> {
logNotification("Flood test started (20 sequential refreshes)");
for (let i = 0; i < 20; i++) {
logNotification(`Flood iteration ${i + 1}/20`);
await this.triggerMockRefresh(intervalMs);
}
logNotification("Flood test completed");
},
async clearNotifications(): Promise<void> {
logNotification("Clear notifications (debug panel)");
@@ -24,9 +24,6 @@ export function logPushNotificationReceived(notification: {
title: notification.title,
dataType: type,
});
if (type === "WAKEUP_PING") {
logNotification("WAKEUP_PING received — will trigger refresh");
}
}
export function logPushNotificationActionPerformed(action: {
@@ -74,44 +71,6 @@ export function logTokenRegistrationFailure(
});
}
export function logRefreshStarted(source?: string): void {
logNotification(source ? `Refresh started (${source})` : "Refresh started");
}
function elapsedMsSince(startedAt: number): number {
return performance.now() - startedAt;
}
export function logRefreshSuccess(
startedAt: number,
scheduledCount: number,
source?: string,
): void {
const elapsedMs = Math.round(elapsedMsSince(startedAt));
const message = source
? `Refresh completed (${source}) in ${elapsedMs}ms (scheduled ${scheduledCount})`
: `Refresh completed in ${elapsedMs}ms (scheduled ${scheduledCount})`;
logNotification(message);
}
export function logRefreshFailure(
startedAt: number,
errorMessage: string,
status?: number,
source?: string,
): void {
const statusPart = status != null ? ` HTTP ${status}` : "";
const elapsedMs = Math.round(elapsedMsSince(startedAt));
const message = source
? `Refresh failed (${source}) in ${elapsedMs}ms: ${errorMessage}${statusPart}`
: `Refresh failed in ${elapsedMs}ms: ${errorMessage}${statusPart}`;
logNotification(message);
}
export function logNotificationClearing(method: string): void {
logNotification(`Clearing notifications via ${method}`);
}
export function logScheduleReplacement(count: number): void {
logNotification(`Schedule replacement: ${count} notification(s)`);
}