Update files
This commit is contained in:
@@ -38,6 +38,11 @@ import { useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
type WebNotificationClickDetail,
|
||||
} from "@/utils/os-notifications";
|
||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||
|
||||
polyfillCrypto();
|
||||
|
||||
@@ -47,7 +52,24 @@ function PushNotificationRouter() {
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") {
|
||||
return;
|
||||
const target = globalThis as unknown as EventTarget;
|
||||
const openFromWebClick = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
||||
const route = buildNotificationRoute(customEvent.detail?.data);
|
||||
event.preventDefault();
|
||||
router.push(route as any);
|
||||
};
|
||||
|
||||
target.addEventListener(
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
openFromWebClick as EventListener
|
||||
);
|
||||
return () => {
|
||||
target.removeEventListener(
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
openFromWebClick as EventListener
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
@@ -71,14 +93,7 @@ function PushNotificationRouter() {
|
||||
const data = response.notification.request.content.data as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const agentId = typeof data?.agentId === "string" ? data.agentId : null;
|
||||
|
||||
if (agentId) {
|
||||
// Legacy route resolves agent -> host once sessions reconnect.
|
||||
router.push(`/agent/${agentId}` as any);
|
||||
} else {
|
||||
router.push("/agents" as any);
|
||||
}
|
||||
router.push(buildNotificationRoute(data) as any);
|
||||
};
|
||||
|
||||
const subscription =
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildNotificationRoute,
|
||||
resolveNotificationTarget,
|
||||
} from "./notification-routing";
|
||||
|
||||
describe("resolveNotificationTarget", () => {
|
||||
it("extracts non-empty server and agent ids", () => {
|
||||
expect(
|
||||
resolveNotificationTarget({
|
||||
serverId: " server-123 ",
|
||||
agentId: " agent-456 ",
|
||||
})
|
||||
).toEqual({
|
||||
serverId: "server-123",
|
||||
agentId: "agent-456",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for missing/empty ids", () => {
|
||||
expect(resolveNotificationTarget({ serverId: "", agentId: " " })).toEqual({
|
||||
serverId: null,
|
||||
agentId: null,
|
||||
});
|
||||
expect(resolveNotificationTarget(undefined)).toEqual({
|
||||
serverId: null,
|
||||
agentId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNotificationRoute", () => {
|
||||
it("routes directly to server-scoped agent path when both ids are present", () => {
|
||||
expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe(
|
||||
"/agent/srv-1/agent-1"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to legacy agent route when serverId is absent", () => {
|
||||
expect(buildNotificationRoute({ agentId: "agent-legacy" })).toBe("/agent/agent-legacy");
|
||||
});
|
||||
|
||||
it("falls back to agents list when no agent id is present", () => {
|
||||
expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/agents");
|
||||
});
|
||||
|
||||
it("encodes path segments", () => {
|
||||
expect(
|
||||
buildNotificationRoute({
|
||||
serverId: "srv/with/slash",
|
||||
agentId: "agent with space",
|
||||
})
|
||||
).toBe("/agent/srv%2Fwith%2Fslash/agent%20with%20space");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
type NotificationData = Record<string, unknown> | null | undefined;
|
||||
|
||||
function readNonEmptyString(
|
||||
data: NotificationData,
|
||||
key: string
|
||||
): string | null {
|
||||
const value = data?.[key];
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function resolveNotificationTarget(data: NotificationData): {
|
||||
serverId: string | null;
|
||||
agentId: string | null;
|
||||
} {
|
||||
return {
|
||||
serverId: readNonEmptyString(data, "serverId"),
|
||||
agentId: readNonEmptyString(data, "agentId"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNotificationRoute(data: NotificationData): string {
|
||||
const { serverId, agentId } = resolveNotificationTarget(data);
|
||||
if (serverId && agentId) {
|
||||
return `/agent/${encodeURIComponent(serverId)}/${encodeURIComponent(agentId)}`;
|
||||
}
|
||||
if (agentId) {
|
||||
return `/agent/${encodeURIComponent(agentId)}`;
|
||||
}
|
||||
return "/agents";
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type MockNotificationOptions = {
|
||||
body?: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type MockNotificationInstance = {
|
||||
title: string;
|
||||
options?: MockNotificationOptions;
|
||||
onclick: ((event: Event) => void) | null;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type GlobalSnapshot = {
|
||||
Notification: unknown;
|
||||
CustomEvent: unknown;
|
||||
dispatchEvent: unknown;
|
||||
focus: unknown;
|
||||
location: unknown;
|
||||
};
|
||||
|
||||
const originalGlobals: GlobalSnapshot = {
|
||||
Notification: (globalThis as { Notification?: unknown }).Notification,
|
||||
CustomEvent: (globalThis as { CustomEvent?: unknown }).CustomEvent,
|
||||
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
|
||||
focus: (globalThis as { focus?: unknown }).focus,
|
||||
location: (globalThis as { location?: unknown }).location,
|
||||
};
|
||||
|
||||
async function loadModuleForPlatform(platform: "web" | "ios" | "android") {
|
||||
vi.resetModules();
|
||||
vi.doMock("react-native", () => ({ Platform: { OS: platform } }));
|
||||
return import("./os-notifications");
|
||||
}
|
||||
|
||||
function restoreGlobals(): void {
|
||||
(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification;
|
||||
(globalThis as { CustomEvent?: unknown }).CustomEvent = originalGlobals.CustomEvent;
|
||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
|
||||
(globalThis as { focus?: unknown }).focus = originalGlobals.focus;
|
||||
(globalThis as { location?: unknown }).location = originalGlobals.location;
|
||||
}
|
||||
|
||||
describe("sendOsNotification", () => {
|
||||
beforeEach(() => {
|
||||
class MockCustomEvent<T = unknown> {
|
||||
type: string;
|
||||
detail: T;
|
||||
cancelable: boolean;
|
||||
defaultPrevented = false;
|
||||
|
||||
constructor(type: string, init?: { detail?: T; cancelable?: boolean }) {
|
||||
this.type = type;
|
||||
this.detail = (init?.detail ?? null) as T;
|
||||
this.cancelable = init?.cancelable ?? false;
|
||||
}
|
||||
|
||||
preventDefault(): void {
|
||||
if (this.cancelable) {
|
||||
this.defaultPrevented = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(globalThis as { CustomEvent?: unknown }).CustomEvent = MockCustomEvent;
|
||||
(globalThis as { focus?: unknown }).focus = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("react-native");
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
restoreGlobals();
|
||||
});
|
||||
|
||||
it("dispatches a click event that the app can handle", async () => {
|
||||
const created: MockNotificationInstance[] = [];
|
||||
|
||||
class MockNotification implements MockNotificationInstance {
|
||||
static permission = "granted";
|
||||
static requestPermission = vi.fn(async () => "granted");
|
||||
onclick: ((event: Event) => void) | null = null;
|
||||
close = vi.fn();
|
||||
|
||||
constructor(public title: string, public options?: MockNotificationOptions) {
|
||||
created.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
const dispatchEvent = vi.fn((event: unknown) => {
|
||||
void event;
|
||||
return false;
|
||||
});
|
||||
const assign = vi.fn();
|
||||
|
||||
(globalThis as { Notification?: unknown }).Notification = MockNotification;
|
||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = dispatchEvent;
|
||||
(globalThis as { location?: unknown }).location = { assign };
|
||||
|
||||
const { sendOsNotification, WEB_NOTIFICATION_CLICK_EVENT } =
|
||||
await loadModuleForPlatform("web");
|
||||
|
||||
const sent = await sendOsNotification({
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
data: { serverId: "srv-1", agentId: "agent-1" },
|
||||
});
|
||||
|
||||
expect(sent).toBe(true);
|
||||
expect(created).toHaveLength(1);
|
||||
|
||||
const clicked = created[0];
|
||||
expect(clicked.onclick).toBeTypeOf("function");
|
||||
clicked.onclick?.({} as Event);
|
||||
|
||||
expect(dispatchEvent).toHaveBeenCalledTimes(1);
|
||||
const event = dispatchEvent.mock.calls[0]?.[0] as unknown as {
|
||||
type?: string;
|
||||
detail?: { data?: Record<string, unknown> };
|
||||
};
|
||||
expect(event?.type).toBe(WEB_NOTIFICATION_CLICK_EVENT);
|
||||
expect(event?.detail).toEqual({
|
||||
data: { serverId: "srv-1", agentId: "agent-1" },
|
||||
});
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to route navigation when no listener handles the click", async () => {
|
||||
const created: MockNotificationInstance[] = [];
|
||||
|
||||
class MockNotification implements MockNotificationInstance {
|
||||
static permission = "granted";
|
||||
static requestPermission = vi.fn(async () => "granted");
|
||||
onclick: ((event: Event) => void) | null = null;
|
||||
close = vi.fn();
|
||||
|
||||
constructor(public title: string, public options?: MockNotificationOptions) {
|
||||
created.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
const dispatchEvent = vi.fn((event: unknown) => {
|
||||
void event;
|
||||
return true;
|
||||
});
|
||||
const assign = vi.fn();
|
||||
|
||||
(globalThis as { Notification?: unknown }).Notification = MockNotification;
|
||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = dispatchEvent;
|
||||
(globalThis as { location?: unknown }).location = { assign };
|
||||
|
||||
const { sendOsNotification } = await loadModuleForPlatform("web");
|
||||
|
||||
await sendOsNotification({
|
||||
title: "Agent finished",
|
||||
data: { serverId: "srv with space", agentId: "agent/1" },
|
||||
});
|
||||
|
||||
const clicked = created[0];
|
||||
expect(clicked.onclick).toBeTypeOf("function");
|
||||
clicked.onclick?.({} as Event);
|
||||
|
||||
expect(assign).toHaveBeenCalledWith("/agent/srv%20with%20space/agent%2F1");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Platform } from "react-native";
|
||||
import { buildNotificationRoute } from "./notification-routing";
|
||||
|
||||
type OsNotificationPayload = {
|
||||
title: string;
|
||||
@@ -6,6 +7,18 @@ type OsNotificationPayload = {
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type WebNotificationClickDetail = {
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type WebNotificationInstance = {
|
||||
onclick?: ((event: Event) => void) | null;
|
||||
addEventListener?: (type: string, listener: (event: Event) => void) => void;
|
||||
close?: () => void;
|
||||
};
|
||||
|
||||
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
|
||||
|
||||
let permissionRequest: Promise<boolean> | null = null;
|
||||
|
||||
function getWebNotificationConstructor(): {
|
||||
@@ -41,6 +54,71 @@ async function ensureNotificationPermission(): Promise<boolean> {
|
||||
return result;
|
||||
}
|
||||
|
||||
function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
|
||||
const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent;
|
||||
const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent })
|
||||
.CustomEvent;
|
||||
|
||||
if (typeof dispatch !== "function" || !CustomEventConstructor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const event = new CustomEventConstructor<WebNotificationClickDetail>(
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
{
|
||||
detail,
|
||||
cancelable: true,
|
||||
}
|
||||
);
|
||||
return dispatch(event) === false;
|
||||
}
|
||||
|
||||
function fallbackNavigateToNotificationTarget(
|
||||
data: Record<string, unknown> | undefined
|
||||
): void {
|
||||
const route = buildNotificationRoute(data);
|
||||
const location = (globalThis as { location?: { assign?: (url: string) => void; href?: string } })
|
||||
.location;
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
if (typeof location.assign === "function") {
|
||||
location.assign(route);
|
||||
return;
|
||||
}
|
||||
if (typeof location.href === "string") {
|
||||
location.href = route;
|
||||
}
|
||||
}
|
||||
|
||||
function attachWebClickHandler(
|
||||
notification: WebNotificationInstance,
|
||||
data: Record<string, unknown> | undefined
|
||||
): void {
|
||||
const onClick = () => {
|
||||
const focus = (globalThis as { focus?: () => void }).focus;
|
||||
if (typeof focus === "function") {
|
||||
focus();
|
||||
}
|
||||
|
||||
const handledByApp = dispatchWebNotificationClick({ data });
|
||||
if (!handledByApp) {
|
||||
fallbackNavigateToNotificationTarget(data);
|
||||
}
|
||||
|
||||
if (typeof notification.close === "function") {
|
||||
notification.close();
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof notification.addEventListener === "function") {
|
||||
notification.addEventListener("click", onClick);
|
||||
return;
|
||||
}
|
||||
|
||||
notification.onclick = onClick;
|
||||
}
|
||||
|
||||
export async function sendOsNotification(
|
||||
payload: OsNotificationPayload
|
||||
): Promise<boolean> {
|
||||
@@ -57,9 +135,10 @@ export async function sendOsNotification(
|
||||
if (!granted) {
|
||||
return false;
|
||||
}
|
||||
new NotificationConstructor(payload.title, {
|
||||
const notification = new NotificationConstructor(payload.title, {
|
||||
body: payload.body,
|
||||
data: payload.data,
|
||||
});
|
||||
}) as WebNotificationInstance;
|
||||
attachWebClickHandler(notification, payload.data);
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user