feat(FN-1117): add mobile push and dashboard plugin manager infrastructure
- Add dashboard Capacitor plugin manager layer with splash screen, status bar, and network managers plus initialization exports - Add comprehensive dashboard plugin tests covering manager behavior and initialization flows - Implement mobile push notifications manager with permission/token registration, notification handling, and polling lifecycle support - Add mobile push manager tests and update package docs/exports/configuration for plugin usage
This commit is contained in:
61
packages/mobile/README.md
Normal file
61
packages/mobile/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# @fusion/mobile
|
||||
|
||||
## Push Notifications
|
||||
|
||||
`PushNotificationManager` supports two complementary notification channels:
|
||||
|
||||
1. **Native push notifications** via Capacitor Push Notifications (`@capacitor/push-notifications`) for FCM/APNs token registration and notification tap handling.
|
||||
2. **ntfy.sh streaming subscription** via polling-driven topic management, so the app can receive in-app notifications without server-side FCM/APNs setup.
|
||||
|
||||
### Initialization
|
||||
|
||||
```ts
|
||||
import { PushNotificationManager } from "@fusion/mobile";
|
||||
|
||||
const manager = new PushNotificationManager({
|
||||
settingsFetcher: fetchGlobalSettings,
|
||||
});
|
||||
|
||||
await manager.start();
|
||||
```
|
||||
|
||||
You can also initialize through `initializePlugins({ pushNotifications: { ... } })` if you want plugin bootstrapping from a single entrypoint.
|
||||
|
||||
### Event API
|
||||
|
||||
```ts
|
||||
manager.on("notification:tapped", ({ taskId }) => {
|
||||
if (taskId) {
|
||||
navigateToTask(taskId);
|
||||
}
|
||||
});
|
||||
|
||||
manager.on("notification:received", ({ title, body }) => {
|
||||
console.log("Foreground notification", title, body);
|
||||
});
|
||||
|
||||
manager.on("ntfy:message", ({ taskId, message }) => {
|
||||
console.log("ntfy message", taskId, message);
|
||||
});
|
||||
```
|
||||
|
||||
### ntfy.sh Integration Behavior
|
||||
|
||||
When `settingsFetcher()` returns:
|
||||
|
||||
- `ntfyEnabled: true`
|
||||
- `ntfyTopic: "<topic>"`
|
||||
|
||||
…the manager starts (or switches) a live subscription to `{ntfyBaseUrl}/{topic}/json`.
|
||||
|
||||
If settings disable ntfy or clear the topic, the subscription is automatically stopped.
|
||||
|
||||
### Device Token Access
|
||||
|
||||
Use `manager.getDeviceToken()` after registration to retrieve the native device token for future server-side FCM/APNs integration work.
|
||||
|
||||
### Out of Scope
|
||||
|
||||
This package currently handles **receiving** push notifications and in-app routing events only.
|
||||
|
||||
Server-side FCM/APNs delivery infrastructure (token storage, provider credentials, push sending services) is intentionally out of scope for this feature.
|
||||
@@ -7,10 +7,13 @@
|
||||
"cap": "cap",
|
||||
"dev:ios": "tsx scripts/live-reload.ts --platform ios",
|
||||
"dev:android": "tsx scripts/live-reload.ts --platform android",
|
||||
"build:mobile": "pnpm --filter @fusion/dashboard build && npx cap sync"
|
||||
"build:mobile": "pnpm --filter @fusion/dashboard build && npx cap sync",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/core": "^7.0.0"
|
||||
"@capacitor/core": "^7.0.0",
|
||||
"@capacitor/push-notifications": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^7.0.0",
|
||||
@@ -18,6 +21,7 @@
|
||||
"@capacitor/ios": "^7.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
533
packages/mobile/src/__tests__/push-notifications.test.ts
Normal file
533
packages/mobile/src/__tests__/push-notifications.test.ts
Normal file
@@ -0,0 +1,533 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockState = vi.hoisted(() => {
|
||||
const listeners = new Map<string, (...args: any[]) => void>();
|
||||
const removeFns: Array<ReturnType<typeof vi.fn>> = [];
|
||||
|
||||
const addListener = vi.fn(async (eventName: string, callback: (...args: any[]) => void) => {
|
||||
listeners.set(eventName, callback);
|
||||
const remove = vi.fn(async () => {});
|
||||
removeFns.push(remove);
|
||||
return { remove };
|
||||
});
|
||||
|
||||
return {
|
||||
listeners,
|
||||
removeFns,
|
||||
addListener,
|
||||
requestPermissions: vi.fn(),
|
||||
register: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
isNativePlatform: vi.fn(() => false),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@capacitor/push-notifications", () => ({
|
||||
PushNotifications: {
|
||||
requestPermissions: mockState.requestPermissions,
|
||||
register: mockState.register,
|
||||
addListener: mockState.addListener,
|
||||
removeAllListeners: mockState.removeAllListeners,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@capacitor/core", () => ({
|
||||
Capacitor: {
|
||||
isNativePlatform: mockState.isNativePlatform,
|
||||
},
|
||||
}));
|
||||
|
||||
import { PushNotificationManager } from "../plugins/push-notifications.js";
|
||||
|
||||
const flushPromises = async (): Promise<void> => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
const invokeListener = (eventName: string, payload: unknown): void => {
|
||||
const listener = mockState.listeners.get(eventName);
|
||||
expect(listener, `Expected listener for ${eventName}`).toBeTypeOf("function");
|
||||
listener?.(payload);
|
||||
};
|
||||
|
||||
const createLineStream = (lines: string[]): ReadableStream<Uint8Array> => {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const line of lines) {
|
||||
controller.enqueue(encoder.encode(`${line}\n`));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe("PushNotificationManager — native push notifications", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.listeners.clear();
|
||||
mockState.removeFns.length = 0;
|
||||
mockState.isNativePlatform.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("requestPermission returns true and registers when permission is granted", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
mockState.requestPermissions.mockResolvedValue({ receive: "granted" });
|
||||
|
||||
const manager = new PushNotificationManager();
|
||||
const result = await manager.requestPermission();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockState.register).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("requestPermission returns false and does not register when denied", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
mockState.requestPermissions.mockResolvedValue({ receive: "denied" });
|
||||
|
||||
const manager = new PushNotificationManager();
|
||||
const result = await manager.requestPermission();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockState.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requestPermission emits permission:changed granted=true on success", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
mockState.requestPermissions.mockResolvedValue({ receive: "granted" });
|
||||
|
||||
const manager = new PushNotificationManager();
|
||||
const permissionChanged = vi.fn();
|
||||
manager.on("permission:changed", permissionChanged);
|
||||
|
||||
await manager.requestPermission();
|
||||
|
||||
expect(permissionChanged).toHaveBeenCalledWith({ granted: true });
|
||||
});
|
||||
|
||||
it("requestPermission emits permission:changed granted=false on denial", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
mockState.requestPermissions.mockResolvedValue({ receive: "prompt" });
|
||||
|
||||
const manager = new PushNotificationManager();
|
||||
const permissionChanged = vi.fn();
|
||||
manager.on("permission:changed", permissionChanged);
|
||||
|
||||
await manager.requestPermission();
|
||||
|
||||
expect(permissionChanged).toHaveBeenCalledWith({ granted: false });
|
||||
});
|
||||
|
||||
it("registration listener stores token and emits token:registered", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
const manager = new PushNotificationManager();
|
||||
const onToken = vi.fn();
|
||||
manager.on("token:registered", onToken);
|
||||
|
||||
await manager.initListeners();
|
||||
invokeListener("registration", { value: "device-token-123" });
|
||||
|
||||
expect(onToken).toHaveBeenCalledWith({ token: "device-token-123" });
|
||||
expect(manager.getDeviceToken()).toBe("device-token-123");
|
||||
});
|
||||
|
||||
it("registrationError listener emits permission:changed granted=false", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
const manager = new PushNotificationManager();
|
||||
const permissionChanged = vi.fn();
|
||||
manager.on("permission:changed", permissionChanged);
|
||||
|
||||
await manager.initListeners();
|
||||
invokeListener("registrationError", { error: "bad token" });
|
||||
|
||||
expect(permissionChanged).toHaveBeenCalledWith({ granted: false });
|
||||
});
|
||||
|
||||
it("pushNotificationReceived emits notification:received with parsed payload", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
const manager = new PushNotificationManager();
|
||||
const received = vi.fn();
|
||||
manager.on("notification:received", received);
|
||||
|
||||
await manager.initListeners();
|
||||
invokeListener("pushNotificationReceived", {
|
||||
title: "Task ready",
|
||||
body: "FN-321 is ready",
|
||||
data: { taskId: "FN-321", source: "native" },
|
||||
});
|
||||
|
||||
expect(received).toHaveBeenCalledWith({
|
||||
title: "Task ready",
|
||||
body: "FN-321 is ready",
|
||||
taskId: "FN-321",
|
||||
data: { taskId: "FN-321", source: "native" },
|
||||
});
|
||||
});
|
||||
|
||||
it("pushNotificationActionPerformed emits notification:tapped with taskId from data", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
const manager = new PushNotificationManager();
|
||||
const tapped = vi.fn();
|
||||
manager.on("notification:tapped", tapped);
|
||||
|
||||
await manager.initListeners();
|
||||
invokeListener("pushNotificationActionPerformed", {
|
||||
notification: {
|
||||
data: { taskId: "KB-404" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(tapped).toHaveBeenCalledWith({
|
||||
taskId: "KB-404",
|
||||
data: { taskId: "KB-404" },
|
||||
});
|
||||
});
|
||||
|
||||
it("pushNotificationActionPerformed emits taskId undefined when payload has no taskId", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
const manager = new PushNotificationManager();
|
||||
const tapped = vi.fn();
|
||||
manager.on("notification:tapped", tapped);
|
||||
|
||||
await manager.initListeners();
|
||||
invokeListener("pushNotificationActionPerformed", {
|
||||
notification: {
|
||||
title: "General alert",
|
||||
body: "No task reference",
|
||||
data: { category: "system" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(tapped).toHaveBeenCalledWith({
|
||||
taskId: undefined,
|
||||
data: { category: "system" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PushNotificationManager — non-native platform behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.listeners.clear();
|
||||
mockState.removeFns.length = 0;
|
||||
mockState.isNativePlatform.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("requestPermission returns false without invoking Capacitor APIs", async () => {
|
||||
const manager = new PushNotificationManager();
|
||||
|
||||
const result = await manager.requestPermission();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockState.requestPermissions).not.toHaveBeenCalled();
|
||||
expect(mockState.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("initListeners does not register listeners when not on native platform", async () => {
|
||||
const manager = new PushNotificationManager();
|
||||
|
||||
await manager.initListeners();
|
||||
|
||||
expect(mockState.addListener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PushNotificationManager — ntfy subscription", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.listeners.clear();
|
||||
mockState.removeFns.length = 0;
|
||||
mockState.isNativePlatform.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("startNtfySubscription fetches {ntfyBaseUrl}/{topic}/json", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(createLineStream([]), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const manager = new PushNotificationManager({ ntfyBaseUrl: "https://ntfy.sh" });
|
||||
await manager.startNtfySubscription("my-topic");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/my-topic/json",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("parses ntfy JSON lines and emits ntfy:message for event=message", async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(
|
||||
createLineStream([
|
||||
JSON.stringify({
|
||||
id: "msg-1",
|
||||
event: "message",
|
||||
title: "Task FN-200 completed",
|
||||
message: "Ready for review",
|
||||
priority: 4,
|
||||
}),
|
||||
]),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const manager = new PushNotificationManager();
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
manager.on("ntfy:message", (payload) => messages.push(payload));
|
||||
|
||||
await manager.startNtfySubscription("fusion-topic");
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
id: "msg-1",
|
||||
title: "Task FN-200 completed",
|
||||
message: "Ready for review",
|
||||
priority: "high",
|
||||
clickUrl: undefined,
|
||||
event: "message",
|
||||
taskId: "FN-200",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips non-message ntfy events", async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(
|
||||
createLineStream([
|
||||
JSON.stringify({ event: "open", id: "sys-1" }),
|
||||
JSON.stringify({ event: "keepalive", id: "sys-2" }),
|
||||
]),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const manager = new PushNotificationManager();
|
||||
const onMessage = vi.fn();
|
||||
manager.on("ntfy:message", onMessage);
|
||||
|
||||
await manager.startNtfySubscription("fusion-topic");
|
||||
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("extracts taskId from ntfy click URL query parameter", async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(
|
||||
createLineStream([
|
||||
JSON.stringify({
|
||||
id: "msg-2",
|
||||
event: "message",
|
||||
title: "Task update",
|
||||
message: "Tap to open",
|
||||
click: "https://fusion.local/?task=KB-888",
|
||||
priority: 3,
|
||||
}),
|
||||
]),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const manager = new PushNotificationManager();
|
||||
const onMessage = vi.fn();
|
||||
manager.on("ntfy:message", onMessage);
|
||||
|
||||
await manager.startNtfySubscription("fusion-topic");
|
||||
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
taskId: "KB-888",
|
||||
clickUrl: "https://fusion.local/?task=KB-888",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts taskId from title/message regex fallback when click URL is absent", async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(
|
||||
createLineStream([
|
||||
JSON.stringify({
|
||||
id: "msg-3",
|
||||
event: "message",
|
||||
title: "Task KB-123 moved",
|
||||
message: "Status changed",
|
||||
priority: 3,
|
||||
}),
|
||||
]),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const manager = new PushNotificationManager();
|
||||
const onMessage = vi.fn();
|
||||
manager.on("ntfy:message", onMessage);
|
||||
|
||||
await manager.startNtfySubscription("fusion-topic");
|
||||
|
||||
expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({ taskId: "KB-123" }));
|
||||
});
|
||||
|
||||
it("stopNtfySubscription aborts active fetch request", async () => {
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
||||
return new Promise<Response>(() => {
|
||||
expect(init?.signal).toBeDefined();
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const manager = new PushNotificationManager();
|
||||
void manager.startNtfySubscription("fusion-topic");
|
||||
await flushPromises();
|
||||
|
||||
const signal = fetchMock.mock.calls[0]?.[1]?.signal as AbortSignal;
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
manager.stopNtfySubscription();
|
||||
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("handles fetch errors gracefully without throwing", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error("network down");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const manager = new PushNotificationManager();
|
||||
|
||||
await expect(manager.startNtfySubscription("fusion-topic")).resolves.toBeUndefined();
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PushNotificationManager — settings poll and lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
mockState.listeners.clear();
|
||||
mockState.removeFns.length = 0;
|
||||
mockState.isNativePlatform.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("starts ntfy subscription when settings enable ntfy", async () => {
|
||||
const settingsFetcher = vi.fn().mockResolvedValue({ ntfyEnabled: true, ntfyTopic: "alpha" });
|
||||
const manager = new PushNotificationManager({ settingsFetcher, ntfyPollIntervalMs: 1_000 });
|
||||
const startSpy = vi
|
||||
.spyOn(manager, "startNtfySubscription")
|
||||
.mockImplementation(async (topic: string) => {
|
||||
(manager as any).ntfyCurrentTopic = topic;
|
||||
});
|
||||
|
||||
manager.startSettingsPoll();
|
||||
await flushPromises();
|
||||
|
||||
expect(startSpy).toHaveBeenCalledWith("alpha");
|
||||
});
|
||||
|
||||
it("stops ntfy subscription when settings disable ntfy", async () => {
|
||||
const settingsFetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ntfyEnabled: true, ntfyTopic: "alpha" })
|
||||
.mockResolvedValueOnce({ ntfyEnabled: false, ntfyTopic: "alpha" });
|
||||
|
||||
const manager = new PushNotificationManager({ settingsFetcher, ntfyPollIntervalMs: 1_000 });
|
||||
vi.spyOn(manager, "startNtfySubscription").mockImplementation(async (topic: string) => {
|
||||
(manager as any).ntfyCurrentTopic = topic;
|
||||
});
|
||||
const stopSpy = vi.spyOn(manager, "stopNtfySubscription");
|
||||
|
||||
manager.startSettingsPoll();
|
||||
await flushPromises();
|
||||
|
||||
vi.advanceTimersByTime(1_000);
|
||||
await flushPromises();
|
||||
|
||||
expect(stopSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restarts ntfy subscription when topic changes", async () => {
|
||||
const settingsFetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ntfyEnabled: true, ntfyTopic: "alpha" })
|
||||
.mockResolvedValueOnce({ ntfyEnabled: true, ntfyTopic: "beta" });
|
||||
|
||||
const manager = new PushNotificationManager({ settingsFetcher, ntfyPollIntervalMs: 1_000 });
|
||||
const startSpy = vi
|
||||
.spyOn(manager, "startNtfySubscription")
|
||||
.mockImplementation(async (topic: string) => {
|
||||
(manager as any).ntfyCurrentTopic = topic;
|
||||
});
|
||||
|
||||
manager.startSettingsPoll();
|
||||
await flushPromises();
|
||||
|
||||
vi.advanceTimersByTime(1_000);
|
||||
await flushPromises();
|
||||
|
||||
expect(startSpy).toHaveBeenNthCalledWith(1, "alpha");
|
||||
expect(startSpy).toHaveBeenNthCalledWith(2, "beta");
|
||||
});
|
||||
|
||||
it("start() calls initListeners, requestPermission, and startSettingsPoll", async () => {
|
||||
const manager = new PushNotificationManager({
|
||||
settingsFetcher: vi.fn().mockResolvedValue({ ntfyEnabled: false }),
|
||||
});
|
||||
const initSpy = vi.spyOn(manager, "initListeners").mockResolvedValue();
|
||||
const permissionSpy = vi.spyOn(manager, "requestPermission").mockResolvedValue(false);
|
||||
const settingsSpy = vi.spyOn(manager, "startSettingsPoll").mockImplementation(() => {});
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(initSpy).toHaveBeenCalledTimes(1);
|
||||
expect(permissionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(settingsSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("destroy() removes listeners, stops subscription, clears interval, and removes event listeners", async () => {
|
||||
mockState.isNativePlatform.mockReturnValue(true);
|
||||
const manager = new PushNotificationManager({
|
||||
settingsFetcher: vi.fn().mockResolvedValue({ ntfyEnabled: false }),
|
||||
ntfyPollIntervalMs: 1_000,
|
||||
});
|
||||
|
||||
await manager.initListeners();
|
||||
manager.startSettingsPoll();
|
||||
await flushPromises();
|
||||
|
||||
(manager as any).ntfyAbortController = new AbortController();
|
||||
(manager as any).ntfyCurrentTopic = "alpha";
|
||||
|
||||
const stopSpy = vi.spyOn(manager, "stopNtfySubscription");
|
||||
const tappedListener = vi.fn();
|
||||
manager.on("notification:tapped", tappedListener);
|
||||
|
||||
const removeFns = [...mockState.removeFns];
|
||||
await manager.destroy();
|
||||
|
||||
for (const remove of removeFns) {
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
|
||||
expect(stopSpy).toHaveBeenCalledTimes(1);
|
||||
expect((manager as any).settingsInterval).toBeNull();
|
||||
expect((manager as any).ntfyAbortController).toBeNull();
|
||||
expect((manager as any).ntfyCurrentTopic).toBeUndefined();
|
||||
|
||||
manager.emit("notification:tapped", { taskId: "FN-1117" });
|
||||
expect(tappedListener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
44
packages/mobile/src/index.ts
Normal file
44
packages/mobile/src/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
PushNotificationManager,
|
||||
type PushNotificationManagerOptions,
|
||||
} from "./plugins/push-notifications.js";
|
||||
|
||||
export { PushNotificationManager } from "./plugins/push-notifications.js";
|
||||
export type {
|
||||
PushNotificationEventMap,
|
||||
PushNotificationManagerOptions,
|
||||
} from "./plugins/push-notifications.js";
|
||||
export type { MobilePluginManager, PluginEventMap } from "./types.js";
|
||||
|
||||
export interface InitializePluginsOptions {
|
||||
pushNotifications?:
|
||||
| boolean
|
||||
| PushNotificationManager
|
||||
| PushNotificationManagerOptions;
|
||||
}
|
||||
|
||||
export interface InitializePluginsResult {
|
||||
pushNotifications?: PushNotificationManager;
|
||||
}
|
||||
|
||||
export async function initializePlugins(
|
||||
options: InitializePluginsOptions = {},
|
||||
): Promise<InitializePluginsResult> {
|
||||
const result: InitializePluginsResult = {};
|
||||
const pushOptions = options.pushNotifications;
|
||||
|
||||
if (!pushOptions) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const pushNotifications =
|
||||
pushOptions instanceof PushNotificationManager
|
||||
? pushOptions
|
||||
: new PushNotificationManager(
|
||||
typeof pushOptions === "object" ? pushOptions : undefined,
|
||||
);
|
||||
|
||||
await pushNotifications.start();
|
||||
result.pushNotifications = pushNotifications;
|
||||
return result;
|
||||
}
|
||||
439
packages/mobile/src/plugins/push-notifications.ts
Normal file
439
packages/mobile/src/plugins/push-notifications.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { PluginEventMap } from "../types.js";
|
||||
|
||||
export interface PushNotificationEventMap extends PluginEventMap {
|
||||
"notification:received": {
|
||||
title: string;
|
||||
body: string;
|
||||
taskId?: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
"notification:tapped": { taskId?: string; data?: Record<string, unknown> };
|
||||
"permission:changed": { granted: boolean };
|
||||
"token:registered": { token: string };
|
||||
"ntfy:message": {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
priority: string;
|
||||
clickUrl?: string;
|
||||
event?: string;
|
||||
taskId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PushNotificationManagerOptions {
|
||||
/** ntfy.sh base URL. Default: https://ntfy.sh */
|
||||
ntfyBaseUrl?: string;
|
||||
/** How to fetch settings for ntfy configuration. Called periodically. */
|
||||
settingsFetcher?: () => Promise<{ ntfyEnabled?: boolean; ntfyTopic?: string }>;
|
||||
/** Polling interval in ms for ntfy.sh settings refresh. Default: 30000 */
|
||||
ntfyPollIntervalMs?: number;
|
||||
}
|
||||
|
||||
type PushNotificationsModule = typeof import("@capacitor/push-notifications");
|
||||
|
||||
export class PushNotificationManager extends EventEmitter {
|
||||
private deviceToken: string | undefined;
|
||||
private ntfyBaseUrl: string;
|
||||
private settingsFetcher?: PushNotificationManagerOptions["settingsFetcher"];
|
||||
private ntfyPollIntervalMs: number;
|
||||
private ntfyAbortController: AbortController | null = null;
|
||||
private ntfyCurrentTopic: string | undefined;
|
||||
private settingsInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private listenerHandles: Array<{ remove: () => Promise<void> }> = [];
|
||||
private pushNotifications: PushNotificationsModule["PushNotifications"] | null = null;
|
||||
|
||||
constructor(options: PushNotificationManagerOptions = {}) {
|
||||
super();
|
||||
this.ntfyBaseUrl = options.ntfyBaseUrl ?? "https://ntfy.sh";
|
||||
this.settingsFetcher = options.settingsFetcher;
|
||||
this.ntfyPollIntervalMs = options.ntfyPollIntervalMs ?? 30_000;
|
||||
}
|
||||
|
||||
override on<K extends keyof PushNotificationEventMap>(
|
||||
eventName: K,
|
||||
listener: (payload: PushNotificationEventMap[K]) => void,
|
||||
): this;
|
||||
override on(eventName: string | symbol, listener: (...args: any[]) => void): this;
|
||||
override on(eventName: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.on(eventName, listener);
|
||||
}
|
||||
|
||||
override off<K extends keyof PushNotificationEventMap>(
|
||||
eventName: K,
|
||||
listener: (payload: PushNotificationEventMap[K]) => void,
|
||||
): this;
|
||||
override off(eventName: string | symbol, listener: (...args: any[]) => void): this;
|
||||
override off(eventName: string | symbol, listener: (...args: any[]) => void): this {
|
||||
return super.off(eventName, listener);
|
||||
}
|
||||
|
||||
emit<K extends keyof PushNotificationEventMap>(
|
||||
eventName: K,
|
||||
payload: PushNotificationEventMap[K],
|
||||
): boolean;
|
||||
emit(eventName: string | symbol, payload?: unknown): boolean;
|
||||
emit(eventName: string | symbol, payload?: unknown): boolean {
|
||||
return super.emit(eventName, payload);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.initListeners();
|
||||
void this.requestPermission();
|
||||
this.startSettingsPoll();
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
for (const handle of this.listenerHandles) {
|
||||
try {
|
||||
await handle.remove();
|
||||
} catch (error) {
|
||||
console.warn("Failed to remove push notification listener", error);
|
||||
}
|
||||
}
|
||||
|
||||
this.listenerHandles = [];
|
||||
this.stopNtfySubscription();
|
||||
|
||||
if (this.settingsInterval) {
|
||||
clearInterval(this.settingsInterval);
|
||||
this.settingsInterval = null;
|
||||
}
|
||||
|
||||
this.removeAllListeners();
|
||||
this.deviceToken = undefined;
|
||||
this.pushNotifications = null;
|
||||
}
|
||||
|
||||
async requestPermission(): Promise<boolean> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const pushNotifications = await this.loadPushNotifications();
|
||||
if (!pushNotifications) {
|
||||
this.emit("permission:changed", { granted: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await pushNotifications.requestPermissions();
|
||||
const granted = result.receive === "granted";
|
||||
|
||||
if (!granted) {
|
||||
this.emit("permission:changed", { granted: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
await pushNotifications.register();
|
||||
this.emit("permission:changed", { granted: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("Failed to request push notification permission", error);
|
||||
this.emit("permission:changed", { granted: false });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getDeviceToken(): string | undefined {
|
||||
return this.deviceToken;
|
||||
}
|
||||
|
||||
async initListeners(): Promise<void> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pushNotifications = await this.loadPushNotifications();
|
||||
if (!pushNotifications) {
|
||||
return;
|
||||
}
|
||||
|
||||
const registrationHandle = await pushNotifications.addListener("registration", (token) => {
|
||||
const value = typeof token?.value === "string" ? token.value : "";
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deviceToken = value;
|
||||
this.emit("token:registered", { token: value });
|
||||
});
|
||||
this.listenerHandles.push(registrationHandle);
|
||||
|
||||
const registrationErrorHandle = await pushNotifications.addListener("registrationError", (error) => {
|
||||
console.warn("Push registration error", error);
|
||||
this.emit("permission:changed", { granted: false });
|
||||
});
|
||||
this.listenerHandles.push(registrationErrorHandle);
|
||||
|
||||
const receivedHandle = await pushNotifications.addListener("pushNotificationReceived", (notification) => {
|
||||
const data = this.normalizeData(notification.data);
|
||||
const title = notification.title ?? "";
|
||||
const body = notification.body ?? "";
|
||||
const taskId = this.extractTaskId({
|
||||
data,
|
||||
title,
|
||||
message: body,
|
||||
});
|
||||
|
||||
this.emit("notification:received", {
|
||||
title,
|
||||
body,
|
||||
taskId,
|
||||
data,
|
||||
});
|
||||
});
|
||||
this.listenerHandles.push(receivedHandle);
|
||||
|
||||
const actionPerformedHandle = await pushNotifications.addListener("pushNotificationActionPerformed", (actionResult) => {
|
||||
const notification = actionResult.notification;
|
||||
const data = this.normalizeData(notification?.data);
|
||||
const taskId = this.extractTaskId({
|
||||
data,
|
||||
title: notification?.title,
|
||||
message: notification?.body,
|
||||
});
|
||||
|
||||
this.emit("notification:tapped", {
|
||||
taskId,
|
||||
data,
|
||||
});
|
||||
});
|
||||
this.listenerHandles.push(actionPerformedHandle);
|
||||
}
|
||||
|
||||
private normalizeData(data: unknown): Record<string, unknown> | undefined {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private extractTaskId(params: {
|
||||
data?: Record<string, unknown>;
|
||||
clickUrl?: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
}): string | undefined {
|
||||
const directTaskId = params.data?.taskId;
|
||||
if (typeof directTaskId === "string" && directTaskId.trim().length > 0) {
|
||||
return directTaskId;
|
||||
}
|
||||
|
||||
if (params.clickUrl) {
|
||||
try {
|
||||
const url = new URL(params.clickUrl);
|
||||
const taskFromQuery = url.searchParams.get("task");
|
||||
if (taskFromQuery && taskFromQuery.trim().length > 0) {
|
||||
return taskFromQuery;
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed click URLs
|
||||
}
|
||||
}
|
||||
|
||||
const combinedText = `${params.title ?? ""} ${params.message ?? ""}`;
|
||||
const taskMatch = combinedText.match(/(FN|KB)-\d{3,}/i);
|
||||
if (taskMatch?.[0]) {
|
||||
return taskMatch[0].toUpperCase();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async startNtfySubscription(topic: string): Promise<void> {
|
||||
const normalizedTopic = topic.trim();
|
||||
if (!normalizedTopic) {
|
||||
this.stopNtfySubscription();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ntfyCurrentTopic === normalizedTopic && this.ntfyAbortController) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ntfyCurrentTopic && this.ntfyCurrentTopic !== normalizedTopic) {
|
||||
this.stopNtfySubscription();
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
this.ntfyAbortController = abortController;
|
||||
this.ntfyCurrentTopic = normalizedTopic;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.ntfyBaseUrl}/${normalizedTopic}/json`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
console.warn(`Failed to start ntfy subscription for topic ${normalizedTopic}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
this.handleNtfyMessage(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
this.handleNtfyMessage(buffer);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
console.warn(`ntfy subscription error for topic ${normalizedTopic}`, error);
|
||||
} finally {
|
||||
if (this.ntfyAbortController === abortController) {
|
||||
this.ntfyAbortController = null;
|
||||
this.ntfyCurrentTopic = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stopNtfySubscription(): void {
|
||||
if (this.ntfyAbortController) {
|
||||
this.ntfyAbortController.abort();
|
||||
}
|
||||
|
||||
this.ntfyAbortController = null;
|
||||
this.ntfyCurrentTopic = undefined;
|
||||
}
|
||||
|
||||
startSettingsPoll(): void {
|
||||
if (!this.settingsFetcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.settingsInterval) {
|
||||
clearInterval(this.settingsInterval);
|
||||
this.settingsInterval = null;
|
||||
}
|
||||
|
||||
const checkSettings = async () => {
|
||||
if (!this.settingsFetcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await this.settingsFetcher();
|
||||
const ntfyEnabled = settings.ntfyEnabled === true;
|
||||
const ntfyTopic = settings.ntfyTopic?.trim();
|
||||
|
||||
if (ntfyEnabled && ntfyTopic) {
|
||||
if (this.ntfyCurrentTopic !== ntfyTopic) {
|
||||
void this.startNtfySubscription(ntfyTopic);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopNtfySubscription();
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch ntfy settings", error);
|
||||
}
|
||||
};
|
||||
|
||||
void checkSettings();
|
||||
this.settingsInterval = setInterval(() => {
|
||||
void checkSettings();
|
||||
}, this.ntfyPollIntervalMs);
|
||||
}
|
||||
|
||||
private handleNtfyMessage(rawLine: string): void {
|
||||
let parsed: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
const json = JSON.parse(rawLine);
|
||||
if (!json || typeof json !== "object" || Array.isArray(json)) {
|
||||
return;
|
||||
}
|
||||
parsed = json as Record<string, unknown>;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.event !== "message") {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = typeof parsed.title === "string" ? parsed.title : "";
|
||||
const message = typeof parsed.message === "string" ? parsed.message : "";
|
||||
const clickUrl = typeof parsed.click === "string" ? parsed.click : undefined;
|
||||
const taskId = this.extractTaskId({
|
||||
clickUrl,
|
||||
title,
|
||||
message,
|
||||
});
|
||||
|
||||
const priorityNumber = typeof parsed.priority === "number" ? parsed.priority : 3;
|
||||
const priority = this.mapNtfyPriority(priorityNumber);
|
||||
const id = typeof parsed.id === "string" ? parsed.id : "";
|
||||
|
||||
this.emit("ntfy:message", {
|
||||
id,
|
||||
title,
|
||||
message,
|
||||
priority,
|
||||
clickUrl,
|
||||
event: "message",
|
||||
taskId,
|
||||
});
|
||||
}
|
||||
|
||||
private mapNtfyPriority(priority: number): string {
|
||||
switch (priority) {
|
||||
case 1:
|
||||
return "low";
|
||||
case 4:
|
||||
return "high";
|
||||
case 5:
|
||||
return "urgent";
|
||||
case 3:
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPushNotifications(): Promise<PushNotificationsModule["PushNotifications"] | null> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.pushNotifications) {
|
||||
return this.pushNotifications;
|
||||
}
|
||||
|
||||
try {
|
||||
const mod: PushNotificationsModule = await import("@capacitor/push-notifications");
|
||||
this.pushNotifications = mod.PushNotifications;
|
||||
return this.pushNotifications;
|
||||
} catch (error) {
|
||||
console.warn("Failed to load @capacitor/push-notifications", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
packages/mobile/src/types.ts
Normal file
8
packages/mobile/src/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface PluginEventMap {
|
||||
[event: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MobilePluginManager {
|
||||
start(): Promise<void>;
|
||||
destroy(): void | Promise<void>;
|
||||
}
|
||||
9
packages/mobile/tsconfig.json
Normal file
9
packages/mobile/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/__tests__/**"]
|
||||
}
|
||||
11
packages/mobile/vitest.config.ts
Normal file
11
packages/mobile/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const maxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "16", 10);
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
maxWorkers,
|
||||
fileParallelism: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user