Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
3 changes: 0 additions & 3 deletions ios/src/ZotMeet/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,6 @@ extension ViewController: WKScriptMessageHandler {
if message.name == "print" {
printView(webView: ZotMeet.webView)
}
if message.name == "push-subscribe" {
handleSubscribeTouch(message: message)
}
if message.name == "push-permission-request" {
handlePushPermission()
}
Expand Down
1 change: 0 additions & 1 deletion ios/src/ZotMeet/WebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ func createWebView(container: UIView, WKSMH: WKScriptMessageHandler, WKND: WKNav
let userContentController = WKUserContentController()

userContentController.add(WKSMH, name: "print")
userContentController.add(WKSMH, name: "push-subscribe")
userContentController.add(WKSMH, name: "push-permission-request")
userContentController.add(WKSMH, name: "push-permission-state")
userContentController.add(WKSMH, name: "push-token")
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.

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

function getSameOriginRedirect(value) {
if (typeof value !== "string") return "/summary";

try {
const url = new URL(value, window.location.origin);
if (url.origin !== window.location.origin) return "/summary";
return url.pathname + url.search + url.hash;
} catch (_err) {
return "/summary";
}
}

navigator.serviceWorker.addEventListener("message", (event) => {
if (event.data && event.data.type === "notification-click") {
window.location.assign(getSameOriginRedirect(event.data.redirect));
}
});
})();
73 changes: 73 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,76 @@ 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",
};
}
}

function getSameOriginRedirect(value) {
if (typeof value !== "string") return "/summary";

try {
const url = new URL(value, self.location.origin);
if (url.origin !== self.location.origin) return "/summary";
return `${url.pathname}${url.search}${url.hash}`;
} catch (_err) {
return "/summary";
}
}

self.addEventListener("push", (event) => {
const payload = getPushPayload(event);
const title = payload.title || "ZotMeet";
const redirect = getSameOriginRedirect(payload.redirect || payload.url);

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 = getSameOriginRedirect(event.notification.data?.redirect);
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);
})(),
);
});
86 changes: 86 additions & 0 deletions src/app/api/push-subscriptions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/db";
import { pushSubscriptions } from "@/db/schema";
import { getCurrentSession } from "@/lib/auth";

type PushSubscriptionPayload = {
endpoint?: unknown;
keys?: {
p256dh?: unknown;
auth?: unknown;
};
};

function isValidSubscriptionPayload(
payload: PushSubscriptionPayload,
): payload is {
endpoint: string;
keys: { p256dh: string; auth: string };
} {
return (
typeof payload.endpoint === "string" &&
typeof payload.keys?.p256dh === "string" &&
typeof payload.keys.auth === "string"
);
}

export async function POST(request: Request) {
const { user } = await getCurrentSession();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const payload = (await request.json()) as PushSubscriptionPayload;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
if (!isValidSubscriptionPayload(payload)) {
return NextResponse.json(
{ error: "Invalid push subscription" },
{ status: 400 },
);
}

await db
.insert(pushSubscriptions)
.values({
userId: user.id,
endpoint: payload.endpoint,
p256dh: payload.keys.p256dh,
auth: payload.keys.auth,
userAgent: request.headers.get("user-agent"),
})
.onConflictDoUpdate({
target: pushSubscriptions.endpoint,
set: {
userId: user.id,
p256dh: payload.keys.p256dh,
auth: payload.keys.auth,
userAgent: request.headers.get("user-agent"),
updatedAt: new Date(),
},
});

return NextResponse.json({ success: true });
}

export async function DELETE(request: Request) {
const { user } = await getCurrentSession();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const payload = (await request.json()) as { endpoint?: unknown };
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
if (typeof payload.endpoint !== "string") {
return NextResponse.json({ error: "Invalid endpoint" }, { status: 400 });
}

await db
.delete(pushSubscriptions)
.where(
and(
eq(pushSubscriptions.endpoint, payload.endpoint),
eq(pushSubscriptions.userId, user.id),
),
);

return NextResponse.json({ success: true });
}
78 changes: 78 additions & 0 deletions src/app/api/push-tokens/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/db";
import { nativePushTokens } from "@/db/schema";
import { getCurrentSession } from "@/lib/auth";

type PushTokenPayload = {
token?: unknown;
platform?: unknown;
};

function getValidToken(payload: PushTokenPayload) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
if (typeof payload.token !== "string") return null;

const token = payload.token.trim();
if (!token || token === "ERROR GET TOKEN") return null;

return token;
}

export async function POST(request: Request) {
const { user } = await getCurrentSession();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const payload = (await request.json()) as PushTokenPayload;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
const token = getValidToken(payload);
if (!token) {
return NextResponse.json({ error: "Invalid push token" }, { status: 400 });
}

const platform = payload.platform === "ios" ? "ios" : "unknown";

await db
.insert(nativePushTokens)
.values({
userId: user.id,
token,
platform,
userAgent: request.headers.get("user-agent"),
})
.onConflictDoUpdate({
target: nativePushTokens.token,
set: {
userId: user.id,
platform,
userAgent: request.headers.get("user-agent"),
updatedAt: new Date(),
},
});

return NextResponse.json({ success: true });
}

export async function DELETE(request: Request) {
const { user } = await getCurrentSession();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const payload = (await request.json()) as PushTokenPayload;
const token = getValidToken(payload);
if (!token) {
return NextResponse.json({ error: "Invalid push token" }, { status: 400 });
}

await db
.delete(nativePushTokens)
.where(
and(
eq(nativePushTokens.token, token),
eq(nativePushTokens.userId, user.id),
),
);

return NextResponse.json({ success: true });
}
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
Loading
Loading