Files
paseo/packages/app/src/utils/os-notifications.ts
T
Mohamed Boudra 0f7fa55a0f Centralize platform gating and fix iPad/tablet support
Add `packages/app/src/constants/platform.ts` with canonical gates
(isWeb, isNative, getIsElectron) and refactor all ~100 ad-hoc
Platform.OS checks across 69 files to use shared imports.

Fix sidebar hover crash on native (onPointerEnter → onHoverIn),
fix tooltip to use breakpoint instead of platform detection, make
sidebar action buttons always visible on native where hover doesn't
work, and document the platform gating decision matrix in CLAUDE.md.
2026-04-13 13:24:00 +07:00

184 lines
5.2 KiB
TypeScript

import { Asset } from "expo-asset";
import { getDesktopHost } from "@/desktop/host";
import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing";
import { isNative } from "@/constants/platform";
type OsNotificationPayload = {
title: string;
body?: string;
data?: Record<string, unknown>;
};
export type WebNotificationClickDetail = {
data?: Record<string, unknown>;
};
type WebNotificationInstance = {
onclick?: ((event: Event) => void) | null;
};
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
let permissionRequest: Promise<boolean> | null = null;
let notificationIconUrl: string | null | undefined;
function getDesktopNotificationSender():
| ((payload: {
title: string;
body?: string;
data?: Record<string, unknown>;
}) => Promise<boolean>)
| null {
const sendNotification = getDesktopHost()?.notification?.sendNotification;
return typeof sendNotification === "function"
? (sendNotification as (payload: {
title: string;
body?: string;
data?: Record<string, unknown>;
}) => Promise<boolean>)
: null;
}
function getWebNotificationConstructor(): {
permission: string;
requestPermission?: () => Promise<string>;
new (
title: string,
options?: {
body?: string;
data?: Record<string, unknown>;
icon?: string;
},
): unknown;
} | null {
const NotificationConstructor = (globalThis as { Notification?: any }).Notification;
return NotificationConstructor ?? null;
}
async function ensureNotificationPermission(): Promise<boolean> {
const NotificationConstructor = getWebNotificationConstructor();
if (!NotificationConstructor) {
return false;
}
if (NotificationConstructor.permission === "granted") {
return true;
}
if (NotificationConstructor.permission === "denied") {
return false;
}
if (permissionRequest) {
return permissionRequest;
}
permissionRequest = Promise.resolve(
NotificationConstructor.requestPermission
? NotificationConstructor.requestPermission()
: "denied",
).then((permission) => permission === "granted");
const result = await permissionRequest;
permissionRequest = null;
return result;
}
export async function ensureOsNotificationPermission(): Promise<boolean> {
if (isNative) {
return false;
}
return await ensureNotificationPermission();
}
function hasNotificationClickTarget(data: Record<string, unknown> | undefined): boolean {
const target = resolveNotificationTarget(data);
return target.serverId !== null || target.agentId !== null || target.workspaceId !== null;
}
function getWebNotificationIconUrl(): string | undefined {
if (notificationIconUrl !== undefined) {
return notificationIconUrl ?? undefined;
}
try {
const asset = Asset.fromModule(require("../../assets/images/notification-icon.png"));
notificationIconUrl = asset.uri ?? null;
} catch {
notificationIconUrl = null;
}
return notificationIconUrl ?? undefined;
}
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 {
notification.onclick = () => {
const handledByApp = dispatchWebNotificationClick({ data });
if (!handledByApp) {
fallbackNavigateToNotificationTarget(data);
}
};
}
export async function sendOsNotification(payload: OsNotificationPayload): Promise<boolean> {
// Mobile/native notifications should be remote push only.
if (isNative) {
return false;
}
const desktopNotificationSender = getDesktopNotificationSender();
if (desktopNotificationSender) {
return await desktopNotificationSender(payload);
}
const NotificationConstructor = getWebNotificationConstructor();
if (NotificationConstructor) {
const granted = await ensureNotificationPermission();
if (granted) {
const notification = new NotificationConstructor(payload.title, {
body: payload.body,
data: payload.data,
icon: getWebNotificationIconUrl(),
}) as WebNotificationInstance;
if (hasNotificationClickTarget(payload.data)) {
attachWebClickHandler(notification, payload.data);
}
return true;
}
}
return false;
}