Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions ios/src/ZotMeet/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

// TODO: if we're using Firebase, uncomment next string
//FirebaseApp.configure()
FirebaseApp.configure()

// [START set_messaging_delegate]
Messaging.messaging().delegate = self
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"dotenv": "^16.4.5",
"firebase-admin": "^13.10.0",
"googleapis": "^148.0.0",
"lucide-react": "^0.453.0",
"next": "16.1.1",
Expand Down
879 changes: 869 additions & 10 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions public/sw-register.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,10 @@
.catch((err) => {
console.warn("[sw] registration failed", err);
});

navigator.serviceWorker.addEventListener("message", (event) => {
if (event.data && event.data.type === "notification-click") {
window.location.assign(event.data.redirect || "/summary");
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
}
});
})();
61 changes: 61 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,64 @@ self.addEventListener("message", (event) => {
self.skipWaiting();
}
});

function getPushPayload(event) {
if (!event.data) {
return {
title: "ZotMeet",
message: "You have a new notification.",
redirect: "/summary",
};
}

try {
return event.data.json();
} catch (_err) {
return {
title: "ZotMeet",
message: event.data.text(),
redirect: "/summary",
};
}
}

self.addEventListener("push", (event) => {
const payload = getPushPayload(event);
const title = payload.title || "ZotMeet";
const redirect = payload.redirect || payload.url || "/summary";
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

event.waitUntil(
self.registration.showNotification(title, {
body: payload.message || payload.body || "You have a new notification.",
icon: "/icons/icon-192.png",
badge: "/icons/icon-96.png",
data: { redirect },
}),
);
});

self.addEventListener("notificationclick", (event) => {
event.notification.close();

const redirect = event.notification.data?.redirect || "/summary";
const targetUrl = new URL(redirect, self.location.origin).href;

event.waitUntil(
(async () => {
const clientList = await clients.matchAll({
type: "window",
includeUncontrolled: true,
});

for (const client of clientList) {
if (new URL(client.url).origin === self.location.origin) {
await client.focus();
client.postMessage({ type: "notification-click", redirect });
return;
}
}

await clients.openWindow(targetUrl);
})(),
);
});
2 changes: 2 additions & 0 deletions src/components/nav/mui-app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { Box, useMediaQuery, useTheme } from "@mui/material";
import { usePathname } from "next/navigation";
import { NativeIosPushBridge } from "@/components/push/native-ios-push-bridge";
import type { NotificationItem, UserProfile } from "@/lib/auth/user";
import { MuiBottomNav } from "./mui-bottom-nav";
import { MuiTopNav } from "./mui-top-nav";
Expand Down Expand Up @@ -37,6 +38,7 @@ export function MuiAppShell({
minHeight: "100vh",
}}
>
{user ? <NativeIosPushBridge userId={user.id} /> : null}
{!isMobile && <MuiTopNav user={user} notifications={notifications} />}
<Box
sx={{
Expand Down
2 changes: 2 additions & 0 deletions src/components/profile/notifications-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Stack from "@mui/material/Stack";
import Switch from "@mui/material/Switch";
import Typography from "@mui/material/Typography";
import { useState, useTransition } from "react";
import { WebPushPermissionButton } from "@/components/push/web-push-permission-button";
import { useSnackbar } from "@/components/ui/snackbar-provider";
import {
NOTIFICATION_PREF_OPTIONS,
Expand Down Expand Up @@ -56,6 +57,7 @@ export function NotificationsPanel({
<Typography variant="h5">Notifications</Typography>

<Stack spacing={0}>
<WebPushPermissionButton />
{NOTIFICATION_PREF_OPTIONS.map((option) => (
<Box
key={option.key}
Expand Down
92 changes: 92 additions & 0 deletions src/components/push/native-ios-push-bridge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"use client";

import { useEffect } from "react";
import { isNativeIosApp } from "@/lib/platform";

declare global {
interface Window {
webkit?: {
messageHandlers?: Record<
string,
{
postMessage: (message: string) => void;
}
>;
};
}
}

function getBridgeHandler(name: string) {
return window.webkit?.messageHandlers?.[name];
}

function getUserTopic(userId: string) {
return `user_${userId}`;
}

function postMessage(name: string, payload?: unknown) {
const handler = getBridgeHandler(name);
if (!handler) return;
handler.postMessage(payload ? JSON.stringify(payload) : "");
}

type NativeIosPushBridgeProps = {
userId: string;
};

export function NativeIosPushBridge({ userId }: NativeIosPushBridgeProps) {
useEffect(() => {
if (!isNativeIosApp()) return;
if (!window.webkit?.messageHandlers) return;

const topic = getUserTopic(userId);
const subscribe = () => postMessage("push-subscribe", { topic });

const handlePermissionState = (event: Event) => {
const detail = (event as CustomEvent<string>).detail;
if (
detail === "authorized" ||
detail === "ephemeral" ||
detail === "provisional"
) {
subscribe();
postMessage("push-token");
return;
}

if (detail === "notDetermined") {
postMessage("push-permission-request");
}
};

const handlePermissionRequestResult = (event: Event) => {
const detail = (event as CustomEvent<string>).detail;
if (detail === "granted") {
subscribe();
postMessage("push-token");
}
};

window.addEventListener("push-permission-state", handlePermissionState);
window.addEventListener(
"push-permission-request",
handlePermissionRequestResult,
);

postMessage("push-permission-state");

return () => {
window.removeEventListener(
"push-permission-state",
handlePermissionState,
);
window.removeEventListener(
"push-permission-request",
handlePermissionRequestResult,
);
postMessage("push-subscribe", { topic, unsubscribe: true });
};
}, [userId]);

return null;
}
119 changes: 119 additions & 0 deletions src/components/push/web-push-permission-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"use client";

import NotificationsActiveOutlinedIcon from "@mui/icons-material/NotificationsActiveOutlined";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useEffect, useState } from "react";
import { useSnackbar } from "@/components/ui/snackbar-provider";
import { isNativeIosApp } from "@/lib/platform";

type PermissionState = NotificationPermission | "unsupported";

function browserSupportsNotifications() {
return (
typeof window !== "undefined" &&
"Notification" in window &&
"serviceWorker" in navigator
);
}

function isStandaloneWebApp() {
if (typeof window === "undefined") return false;

return (
window.matchMedia("(display-mode: standalone)").matches ||
("standalone" in navigator &&
(navigator as Navigator & { standalone?: boolean }).standalone === true)
);
}

function getInitialPermission(): PermissionState {
if (!browserSupportsNotifications()) return "unsupported";
return Notification.permission;
}

export function WebPushPermissionButton() {
const [permission, setPermission] =
useState<PermissionState>(getInitialPermission);
const [isStandalone, setIsStandalone] = useState(false);
const [isNative, setIsNative] = useState(false);
const [isRequesting, setIsRequesting] = useState(false);
const { showSuccess, showError } = useSnackbar();

useEffect(() => {
setPermission(getInitialPermission());
setIsStandalone(isStandaloneWebApp());
setIsNative(isNativeIosApp());
}, []);

if (isNative || permission === "unsupported") return null;

const handleEnable = async () => {
if (!browserSupportsNotifications()) return;

setIsRequesting(true);
try {
await navigator.serviceWorker.ready;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
const nextPermission = await Notification.requestPermission();
setPermission(nextPermission);

if (nextPermission === "granted") {
showSuccess("Notification permission enabled");
} else {
showError("Notification permission was not enabled");
}
} catch {
showError("Failed to request notification permission");
} finally {
setIsRequesting(false);
}
};

const statusText =
permission === "granted"
? "Browser notification permission is enabled on this device."
: permission === "denied"
? "Browser notification permission is blocked in system settings."
: isStandalone
? "Enable browser notification permission for this home-screen app."
: "Install ZotMeet to your home screen to enable iOS browser notifications.";

return (
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
alignItems={{ xs: "flex-start", sm: "center" }}
justifyContent="space-between"
sx={{
py: 2,
borderBottom: 1,
borderColor: "divider",
}}
>
<Stack spacing={0.75}>
<Typography variant="body1" fontWeight={500}>
Device Notifications
</Typography>
<Typography variant="body2" color="text.secondary">
{statusText}
</Typography>
</Stack>
<Button
variant="outlined"
startIcon={<NotificationsActiveOutlinedIcon />}
onClick={handleEnable}
disabled={permission !== "default" || !isStandalone || isRequesting}
sx={{ flexShrink: 0 }}
>
{permission === "granted"
? "Enabled"
: permission === "denied"
? "Blocked"
: isRequesting
? "Enabling..."
: "Enable"}
</Button>
</Stack>
);
}
7 changes: 4 additions & 3 deletions src/lib/notification/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,17 +62,18 @@ export const NOTIFICATION_PREF_OPTIONS: {
key: "meetingInvites",
label: "Meeting Invites",
description:
"Receive in-app and email notifications when you're invited to a meeting.",
"Receive in-app, email, and push notifications when you're invited to a meeting.",
},
{
key: "groupInvites",
label: "Group Invites",
description:
"Receive in-app and email notifications when you're invited to a group.",
"Receive in-app, email, and push notifications when you're invited to a group.",
},
{
key: "nudges",
label: "Nudges",
description: "Receive in-app and email reminders to add your availability.",
description:
"Receive in-app, email, and push reminders to add your availability.",
},
];
Loading
Loading