Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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.

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
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;
}
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.",
},
];
121 changes: 121 additions & 0 deletions src/lib/push/send-push.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import "server-only";

import { cert, getApps, initializeApp } from "firebase-admin/app";
import { getMessaging } from "firebase-admin/messaging";

type PushPayload = {
title: string;
message: string;
type: string;
redirect: string;
groupId?: string | null;
createdBy?: string | null;
};

const FIREBASE_SERVICE_ACCOUNT_JSON = process.env.FIREBASE_SERVICE_ACCOUNT_JSON;
const FIREBASE_SERVICE_ACCOUNT_BASE64 =
process.env.FIREBASE_SERVICE_ACCOUNT_BASE64;

let warnedMissingConfig = false;

function parseServiceAccount(): {
projectId: string;
clientEmail: string;
privateKey: string;
} | null {
let raw = FIREBASE_SERVICE_ACCOUNT_JSON?.trim();

if (!raw && FIREBASE_SERVICE_ACCOUNT_BASE64) {
raw = Buffer.from(FIREBASE_SERVICE_ACCOUNT_BASE64, "base64").toString(
"utf8",
);
}

if (!raw) return null;

try {
const parsed = JSON.parse(raw) as {
project_id?: string;
client_email?: string;
private_key?: string;
};

if (!parsed.project_id || !parsed.client_email || !parsed.private_key) {
return null;
}

return {
projectId: parsed.project_id,
clientEmail: parsed.client_email,
privateKey: parsed.private_key,
};
} catch {
return null;
}
}

function getOrInitFirebaseMessaging() {
if (getApps().length > 0) {
return getMessaging();
}

const serviceAccount = parseServiceAccount();
if (!serviceAccount) {
if (!warnedMissingConfig) {
console.warn(
"Push notifications are disabled: missing Firebase service account configuration.",
);
warnedMissingConfig = true;
}
return null;
}

const app = initializeApp({
credential: cert(serviceAccount),
});
return getMessaging(app);
}

function topicForUser(userId: string) {
return `user_${userId}`;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
}

export async function sendPushToUsers(userIds: string[], payload: PushPayload) {
if (userIds.length === 0) return;

const messaging = getOrInitFirebaseMessaging();
if (!messaging) return;

const sendResults = await Promise.allSettled(
userIds.map((userId) =>
messaging.send({
topic: topicForUser(userId),
notification: {
title: payload.title,
body: payload.message,
},
data: {
type: payload.type,
redirect: payload.redirect,
title: payload.title,
message: payload.message,
groupId: payload.groupId ?? "",
createdBy: payload.createdBy ?? "",
},
apns: {
payload: {
aps: {
sound: "default",
},
},
},
}),
),
);

for (const result of sendResults) {
if (result.status === "rejected") {
console.error("Failed to send push notification:", result.reason);
}
}
}
14 changes: 14 additions & 0 deletions src/server/data/user/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type NotificationPrefs,
toNotificationPrefs,
} from "@/lib/notification/types";
import { sendPushToUsers } from "@/lib/push/send-push";
import { toIlikeContainsPattern } from "@/lib/sql/like-pattern";

export async function getUserIdExists(id: string) {
Expand Down Expand Up @@ -209,6 +210,7 @@ export async function createNewNotification(

const recipientRows = await db
.select({
userId: users.id,
memberId: users.memberId,
email: users.email,
})
Expand Down Expand Up @@ -274,6 +276,18 @@ export async function createNewNotification(
}
}

await sendPushToUsers(
allowedRecipients.map((recipient) => recipient.userId),
{
title,
message,
type,
redirect: link,
groupId,
createdBy,
},
);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

return notificationsCreated;
}

Expand Down
Loading