WIP: new-activity notification #231
@@ -1,5 +1,114 @@
|
||||
<template>
|
||||
<section class="bg-slate-100 rounded-md overflow-hidden px-4 py-4">
|
||||
<!-- Backend testing -->
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-2 font-bold">Backend Testing</h2>
|
||||
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||
Notification Backend URL
|
||||
</label>
|
||||
<input
|
||||
v-model="backendUrlDraft"
|
||||
type="url"
|
||||
class="w-full text-sm px-3 py-2 rounded border border-slate-300 bg-white mb-1"
|
||||
placeholder="Leave empty for default (APP_SERVER)"
|
||||
:disabled="busy"
|
||||
@keydown.enter="onSaveBackendUrl"
|
||||
/>
|
||||
<p class="text-xs text-slate-500 mb-2">
|
||||
Active:
|
||||
<code class="text-[11px] break-all">{{ activeBackendUrl }}</code>
|
||||
</p>
|
||||
<button
|
||||
class="w-full text-sm mb-4 px-3 py-2 rounded border border-slate-300 bg-white"
|
||||
:disabled="busy"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': busy }"
|
||||
@click="onSaveBackendUrl"
|
||||
>
|
||||
Save Backend URL
|
||||
</button>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm mb-4 cursor-pointer">
|
||||
<input
|
||||
v-model="testModeEnabled"
|
||||
type="checkbox"
|
||||
class="rounded border-slate-300"
|
||||
:disabled="busy"
|
||||
@change="onTestModeChange"
|
||||
/>
|
||||
<span>Test Mode</span>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-col gap-2 mb-4">
|
||||
<button
|
||||
class="w-full text-md bg-gradient-to-b from-emerald-400 to-emerald-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="onRegisterToken"
|
||||
>
|
||||
Register Token Now
|
||||
</button>
|
||||
<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
|
||||
</button>
|
||||
<p class="text-xs text-slate-500">
|
||||
Local simulation only — calls the refresh API directly (no FCM push).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h3 class="text-sm font-bold mb-1">Current FCM Token</h3>
|
||||
<div
|
||||
v-if="fcmToken"
|
||||
class="bg-white rounded border border-slate-200 px-3 py-2 text-xs font-mono break-all flex gap-2 items-start"
|
||||
>
|
||||
<span class="min-w-0 flex-1">{{ truncatedFcmToken }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-sm px-2 py-1 rounded border border-slate-300 bg-slate-50"
|
||||
@click="onCopyFcmToken"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="text-sm text-slate-500 bg-white rounded px-3 py-2 border border-slate-200"
|
||||
>
|
||||
(not available — try Register Token Now on native)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<h3 class="text-sm font-bold mb-1">Backend Status</h3>
|
||||
<dl
|
||||
class="bg-white rounded border border-slate-200 px-3 py-2 text-xs space-y-1"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-slate-500 shrink-0">URL</dt>
|
||||
<dd class="break-all font-mono">{{ activeBackendUrl }}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-slate-500 shrink-0">testMode</dt>
|
||||
<dd>{{ testModeEnabled ? "true" : "false" }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION F: Mock Timing Presets -->
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-2 font-bold">Mock Timing Presets</h2>
|
||||
@@ -37,6 +146,10 @@
|
||||
<!-- 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"
|
||||
@@ -165,6 +278,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { copyToClipboard } from "@/services/ClipboardService";
|
||||
import { NotificationDebugService } from "@/services/notifications/NotificationDebugService";
|
||||
|
||||
type PendingInfo = {
|
||||
@@ -186,6 +300,24 @@ const intervalMs = ref<number>(60_000);
|
||||
const busy = ref(false);
|
||||
const pending = ref<PendingInfo[]>([]);
|
||||
const pendingInspectorMessage = ref<string | null>(null);
|
||||
const backendUrlDraft = ref("");
|
||||
const testModeEnabled = ref(NotificationDebugService.isTestModeEnabled());
|
||||
const fcmToken = ref<string | null>(NotificationDebugService.getFcmToken());
|
||||
|
||||
const activeBackendUrl = computed(() =>
|
||||
NotificationDebugService.getActiveBackendUrl(),
|
||||
);
|
||||
|
||||
const truncatedFcmToken = computed(() => {
|
||||
const t = fcmToken.value?.trim() ?? "";
|
||||
if (!t) {
|
||||
return "";
|
||||
}
|
||||
if (t.length <= 24) {
|
||||
return t;
|
||||
}
|
||||
return `${t.slice(0, 12)}…${t.slice(-8)}`;
|
||||
});
|
||||
|
||||
const eventLog = computed(() => NotificationDebugService.eventLog.value);
|
||||
|
||||
@@ -245,7 +377,58 @@ async function onClearNotifications(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function syncBackendState(): void {
|
||||
backendUrlDraft.value =
|
||||
NotificationDebugService.getBackendUrlOverride() ?? "";
|
||||
testModeEnabled.value = NotificationDebugService.isTestModeEnabled();
|
||||
fcmToken.value = NotificationDebugService.getFcmToken();
|
||||
}
|
||||
|
||||
function onSaveBackendUrl(): void {
|
||||
NotificationDebugService.saveBackendBaseUrl(backendUrlDraft.value);
|
||||
syncBackendState();
|
||||
}
|
||||
|
||||
function onTestModeChange(): void {
|
||||
NotificationDebugService.setTestModeEnabled(testModeEnabled.value);
|
||||
}
|
||||
|
||||
async function onRegisterToken(): Promise<void> {
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
await NotificationDebugService.registerTokenNow();
|
||||
} catch {
|
||||
// logged in panel
|
||||
} finally {
|
||||
fcmToken.value = NotificationDebugService.getFcmToken();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
async function onCopyFcmToken(): Promise<void> {
|
||||
const token = fcmToken.value?.trim();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
await copyToClipboard(token);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncBackendState();
|
||||
void refreshPending();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -549,13 +549,24 @@ 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 refreshNotifications(): Promise<void> {
|
||||
export async function refreshNotificationsWithDiagnostics(): Promise<RefreshNotificationsResult> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return;
|
||||
return {
|
||||
ok: false,
|
||||
scheduledCount: 0,
|
||||
errorMessage: "not a native platform",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -574,16 +585,32 @@ export async function refreshNotifications(): Promise<void> {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
});
|
||||
return;
|
||||
return {
|
||||
ok: false,
|
||||
scheduledCount: 0,
|
||||
status: res.status,
|
||||
errorMessage: res.statusText || `HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data: unknown = await res.json();
|
||||
const payload = data as NotificationRefreshPayload;
|
||||
const scheduledCount = Array.isArray(payload?.nextNotifications)
|
||||
? payload.nextNotifications.length
|
||||
: 0;
|
||||
await applyNotificationRefreshPayload(data);
|
||||
return { ok: true, scheduledCount };
|
||||
} catch (err) {
|
||||
logger.error("[NativeNotificationService] Refresh failed", err);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, scheduledCount: 0, errorMessage: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshNotifications(): Promise<void> {
|
||||
await refreshNotificationsWithDiagnostics();
|
||||
}
|
||||
|
||||
export type NotificationRefreshPayload = {
|
||||
shouldNotify?: boolean;
|
||||
nextNotifications?: Array<{ timestamp?: number }>;
|
||||
|
||||
@@ -11,9 +11,21 @@ import { Capacitor } from "@capacitor/core";
|
||||
import { ref, readonly, type Ref } from "vue";
|
||||
import type { PushNotificationSchema } from "@capacitor/push-notifications";
|
||||
import { logger } from "@/utils/logger";
|
||||
import {
|
||||
getBackendBaseUrl,
|
||||
getNotificationApiBaseUrl,
|
||||
getTestMode,
|
||||
setBackendBaseUrl,
|
||||
setTestMode,
|
||||
} from "./NotificationDebugConfig";
|
||||
import {
|
||||
getLastKnownFcmToken,
|
||||
reregisterFcmTokenNow,
|
||||
} from "./firebaseMessagingClient";
|
||||
import {
|
||||
applyNotificationRefreshPayload,
|
||||
handleCapacitorPushNotificationReceived,
|
||||
refreshNotificationsWithDiagnostics,
|
||||
type NotificationRefreshPayload,
|
||||
} from "./NativeNotificationService";
|
||||
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
||||
@@ -55,11 +67,21 @@ function logLine(message: string): string {
|
||||
return `[${formatTime(new Date())}] ${message}`;
|
||||
}
|
||||
|
||||
function truncateToken(token: string): string {
|
||||
const t = token.trim();
|
||||
if (t.length <= 24) {
|
||||
return t;
|
||||
}
|
||||
return `${t.slice(0, 12)}…${t.slice(-8)}`;
|
||||
}
|
||||
|
||||
const _eventLog: Ref<string[]> = ref([]);
|
||||
|
||||
const EVENT_LOG_MAX = 100;
|
||||
|
||||
function append(message: string): void {
|
||||
const line = logLine(message);
|
||||
_eventLog.value = [..._eventLog.value, line].slice(-250);
|
||||
_eventLog.value = [..._eventLog.value, line].slice(-EVENT_LOG_MAX);
|
||||
logger.debug(`${LOG} ${message}`);
|
||||
}
|
||||
|
||||
@@ -70,6 +92,67 @@ export const NotificationDebugService = {
|
||||
_eventLog.value = [];
|
||||
},
|
||||
|
||||
getActiveBackendUrl(): string {
|
||||
return getNotificationApiBaseUrl();
|
||||
},
|
||||
|
||||
getBackendUrlOverride(): string | null {
|
||||
return getBackendBaseUrl();
|
||||
},
|
||||
|
||||
saveBackendBaseUrl(url: string): void {
|
||||
setBackendBaseUrl(url);
|
||||
append(
|
||||
url.trim()
|
||||
? `Backend URL saved (${getNotificationApiBaseUrl()})`
|
||||
: "Backend URL cleared (using default)",
|
||||
);
|
||||
},
|
||||
|
||||
setTestModeEnabled(enabled: boolean): void {
|
||||
setTestMode(enabled);
|
||||
append(`Test mode ${enabled ? "enabled" : "disabled"}`);
|
||||
},
|
||||
|
||||
isTestModeEnabled(): boolean {
|
||||
return getTestMode();
|
||||
},
|
||||
|
||||
getFcmToken(): string | null {
|
||||
return getLastKnownFcmToken();
|
||||
},
|
||||
|
||||
async registerTokenNow(): Promise<void> {
|
||||
append("Register token started");
|
||||
try {
|
||||
const token = await reregisterFcmTokenNow();
|
||||
append(`Register token success (${truncateToken(token)})`);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
append(`Register token failure: ${msg}`);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
async triggerBackendRefresh(): Promise<void> {
|
||||
append("Refresh started");
|
||||
const result = await refreshNotificationsWithDiagnostics();
|
||||
if (result.ok) {
|
||||
append(`Refresh success`);
|
||||
append(`Scheduled ${result.scheduledCount} notifications`);
|
||||
} else {
|
||||
const detail = result.errorMessage ?? "unknown error";
|
||||
const status = result.status != null ? ` (HTTP ${result.status})` : "";
|
||||
append(`Refresh failure: ${detail}${status}`);
|
||||
}
|
||||
},
|
||||
|
||||
/** Local simulation: same API call as a WAKEUP_PING handler (no push payload). */
|
||||
async simulateWakeupViaRefresh(): Promise<void> {
|
||||
append("WAKEUP_PING simulation started (local refresh API only)");
|
||||
await this.triggerBackendRefresh();
|
||||
},
|
||||
|
||||
generateMockNotifications(
|
||||
intervalMs: number = 60_000,
|
||||
): NotificationRefreshPayload {
|
||||
|
||||
@@ -30,19 +30,94 @@ let firebaseAppSingleton: FirebaseApp | null = null;
|
||||
let nativeInitPromise: Promise<void> | null = null;
|
||||
/** Avoid duplicate POSTs when the same token is delivered more than once. */
|
||||
let lastRegisteredFcmToken: string | null = null;
|
||||
/** Last token received from Capacitor/Firebase (may match registered). */
|
||||
let lastSeenFcmToken: string | null = null;
|
||||
|
||||
async function registerRetrievedToken(token: string): Promise<void> {
|
||||
async function registerRetrievedToken(
|
||||
token: string,
|
||||
options?: { force?: boolean },
|
||||
): Promise<void> {
|
||||
const trimmed = token.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
if (trimmed === lastRegisteredFcmToken) {
|
||||
lastSeenFcmToken = trimmed;
|
||||
if (!options?.force && trimmed === lastRegisteredFcmToken) {
|
||||
return;
|
||||
}
|
||||
await registerToken(trimmed);
|
||||
lastRegisteredFcmToken = trimmed;
|
||||
}
|
||||
|
||||
/** Most recent FCM token from native/web push registration (for debug UI). */
|
||||
export function getLastKnownFcmToken(): string | null {
|
||||
return lastSeenFcmToken ?? lastRegisteredFcmToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-runs token registration immediately (debug). Bypasses duplicate-token skip.
|
||||
*/
|
||||
export async function reregisterFcmTokenNow(): Promise<string> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
throw new Error("FCM registration is only available on native platforms");
|
||||
}
|
||||
|
||||
lastRegisteredFcmToken = null;
|
||||
|
||||
const cached = lastSeenFcmToken?.trim();
|
||||
if (cached) {
|
||||
await registerRetrievedToken(cached, { force: true });
|
||||
return cached;
|
||||
}
|
||||
|
||||
const app = ensureFirebaseApp();
|
||||
if (app && (await isSupported())) {
|
||||
const messaging = getMessaging(app);
|
||||
const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY as
|
||||
| string
|
||||
| undefined;
|
||||
const token = await getToken(
|
||||
messaging,
|
||||
vapidKey ? { vapidKey } : undefined,
|
||||
);
|
||||
if (!token?.trim()) {
|
||||
throw new Error("Firebase getToken returned an empty token");
|
||||
}
|
||||
await registerRetrievedToken(token, { force: true });
|
||||
return token.trim();
|
||||
}
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const timeoutMs = 15_000;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void listenerPromise.then((h) => h.remove());
|
||||
reject(new Error("Timed out waiting for push registration token"));
|
||||
}, timeoutMs);
|
||||
|
||||
const listenerPromise = PushNotifications.addListener(
|
||||
"registration",
|
||||
(token) => {
|
||||
window.clearTimeout(timeoutId);
|
||||
void listenerPromise.then((h) => h.remove());
|
||||
const value = token.value?.trim() ?? "";
|
||||
if (!value) {
|
||||
reject(new Error("Capacitor registration returned an empty token"));
|
||||
return;
|
||||
}
|
||||
void registerRetrievedToken(value, { force: true })
|
||||
.then(() => resolve(value))
|
||||
.catch(reject);
|
||||
},
|
||||
);
|
||||
|
||||
void PushNotifications.register().catch((err) => {
|
||||
window.clearTimeout(timeoutId);
|
||||
void listenerPromise.then((h) => h.remove());
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readFirebaseOptions(): FirebaseOptions | null {
|
||||
const env = import.meta.env;
|
||||
const apiKey = env.VITE_FIREBASE_API_KEY as string | undefined;
|
||||
@@ -149,6 +224,9 @@ async function initializeNativePushAndFirebaseMessagingImpl(): Promise<void> {
|
||||
const app = ensureFirebaseApp();
|
||||
|
||||
await PushNotifications.addListener("registration", (token) => {
|
||||
if (token.value?.trim()) {
|
||||
lastSeenFcmToken = token.value.trim();
|
||||
}
|
||||
logger.info(`${LOG} Capacitor registration token`, {
|
||||
valuePrefix: token.value ? `${token.value.slice(0, 12)}…` : "(empty)",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user