Add mobile push notifications

This commit is contained in:
MartinBraquet
2025-10-27 16:12:51 +01:00
parent 05f6f3c79b
commit f1676c52f0
10 changed files with 255 additions and 71 deletions

View File

@@ -68,6 +68,7 @@ import {getUser} from "api/get-user";
import {IS_LOCAL} from "common/envs/constants";
import {localSendTestEmail} from "api/test";
import path from "node:path";
import {saveSubscriptionMobile} from "api/save-subscription-mobile";
// const corsOptions: CorsOptions = {
// origin: ['*'], // Only allow requests from this domain
@@ -354,6 +355,7 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'get-messages-count': getMessagesCount,
'set-last-online-time': setLastOnlineTime,
'save-subscription': saveSubscription,
'save-subscription-mobile': saveSubscriptionMobile,
'create-bookmarked-search': createBookmarkedSearch,
'delete-bookmarked-search': deleteBookmarkedSearch,
}

View File

@@ -13,9 +13,12 @@ import {sendNewMessageEmail} from 'email/functions/helpers'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import webPush from 'web-push';
import {parseJsonContentToText} from "common/util/parse";
import {encryptMessage} from "shared/encryption";
import webPush from 'web-push'
import {parseJsonContentToText} from "common/util/parse"
import {encryptMessage} from "shared/encryption"
import * as admin from 'firebase-admin'
const fcm = admin.messaging()
dayjs.extend(utc)
dayjs.extend(timezone)
@@ -29,17 +32,17 @@ export const leaveChatContent = (userName: string) => ({
},
],
})
export const joinChatContent = (userName: string) => {
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{text: `${userName} joined the chat!`, type: 'text'}],
},
],
}
}
// export const joinChatContent = (userName: string) => {
// return {
// type: 'doc',
// content: [
// {
// type: 'paragraph',
// content: [{text: `${userName} joined the chat!`, type: 'text'}],
// },
// ],
// }
// }
export const insertPrivateMessage = async (
content: Json,
@@ -48,8 +51,8 @@ export const insertPrivateMessage = async (
visibility: ChatVisibility,
pg: SupabaseDirectClient
) => {
const plaintext = JSON.stringify(content);
const {ciphertext, iv, tag} = encryptMessage(plaintext);
const plaintext = JSON.stringify(content)
const {ciphertext, iv, tag} = encryptMessage(plaintext)
const lastMessage = await pg.one(
`insert into private_user_messages (ciphertext, iv, tag, channel_id, user_id, visibility)
values ($1, $2, $3, $4, $5, $6)
@@ -134,7 +137,7 @@ export const createPrivateUserMessageMain = async (
void notifyOtherUserInChannelIfInactive(channelId, creator, content, pg)
.catch((err) => {
console.error('notifyOtherUserInChannelIfInactive failed', err)
});
})
track(creator.id, 'send private message', {
channelId,
@@ -162,49 +165,24 @@ const notifyOtherUserInChannelIfInactive = async (
// We're only sending notifs for 1:1 channels
if (!otherUserIds || otherUserIds.length > 1) return
const otherUserId = first(otherUserIds)
if (!otherUserId) return
const receiverId = first(otherUserIds)?.user_id
if (!receiverId) return
// TODO: notification only for active user
const otherUser = await getUser(otherUserId.user_id)
console.debug('otherUser:', otherUser)
if (!otherUser) return
const receiver = await getUser(receiverId)
console.debug('receiver:', receiver)
if (!receiver) return
// Push notif
webPush.setVapidDetails(
'mailto:hello@compassmeet.com',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
// Push notifs
const textContent = parseJsonContentToText(content)
// Retrieve subscription from the database
const subscriptions = await getSubscriptionsFromDB(otherUser.id, pg);
for (const subscription of subscriptions) {
try {
const payload = JSON.stringify({
title: `${creator.name}`,
body: textContent,
url: `/messages/${channelId}`,
})
console.log('Sending notification to:', subscription.endpoint, payload);
await webPush.sendNotification(subscription, payload);
} catch (err: any) {
console.log('Failed to send notification', err);
if (err.statusCode === 410 || err.statusCode === 404) {
console.warn('Removing expired subscription', subscription.endpoint);
await pg.none(
`DELETE
FROM push_subscriptions
WHERE endpoint = $1
AND user_id = $2`,
[subscription.endpoint, otherUser.id]
);
} else {
console.error('Push failed', err);
}
}
const payload = {
title: `${creator.name}`,
body: textContent,
url: `/messages/${channelId}`,
}
await sendWebNotifications(pg, receiverId, JSON.stringify(payload))
await sendMobileNotifications(pg, receiverId, payload)
const startOfDay = dayjs()
.tz('America/Los_Angeles')
@@ -222,7 +200,7 @@ const notifyOtherUserInChannelIfInactive = async (
log('previous messages this day', previousMessagesThisDayBetweenTheseUsers)
if (previousMessagesThisDayBetweenTheseUsers.count > 1) return
await createNewMessageNotification(creator, otherUser, channelId)
await createNewMessageNotification(creator, receiver, channelId)
}
const createNewMessageNotification = async (
@@ -237,9 +215,38 @@ const createNewMessageNotification = async (
}
export async function getSubscriptionsFromDB(
async function sendWebNotifications(
pg: SupabaseDirectClient,
userId: string,
payload: string,
) {
webPush.setVapidDetails(
'mailto:hello@compassmeet.com',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
)
// Retrieve subscription from the database
const subscriptions = await getSubscriptionsFromDB(pg, userId)
for (const subscription of subscriptions) {
try {
console.log('Sending notification to:', subscription.endpoint, payload)
await webPush.sendNotification(subscription, payload)
} catch (err: any) {
console.log('Failed to send notification', err)
if (err.statusCode === 410 || err.statusCode === 404) {
console.warn('Removing expired subscription', subscription.endpoint)
await removeSubscription(pg, subscription.endpoint, userId)
} else {
console.error('Push failed', err)
}
}
}
}
export async function getSubscriptionsFromDB(
pg: SupabaseDirectClient,
userId: string,
pg: SupabaseDirectClient
) {
try {
const subscriptions = await pg.manyOrNone(`
@@ -247,14 +254,87 @@ export async function getSubscriptionsFromDB(
from push_subscriptions
where user_id = $1
`, [userId]
);
)
return subscriptions.map(sub => ({
endpoint: sub.endpoint,
keys: sub.keys,
}));
}))
} catch (err) {
console.error('Error fetching subscriptions', err);
return [];
console.error('Error fetching subscriptions', err)
return []
}
}
async function removeSubscription(
pg: SupabaseDirectClient,
endpoint: any,
userId: string,
) {
await pg.none(
`DELETE
FROM push_subscriptions
WHERE endpoint = $1
AND user_id = $2`,
[endpoint, userId]
)
}
async function sendMobileNotifications(
pg: SupabaseDirectClient,
userId: string,
payload: PushPayload,
) {
const subscriptions = await getMobileSubscriptionsFromDB(pg, userId)
for (const subscription of subscriptions) {
await sendPushToToken(subscription.token, payload)
}
}
interface PushPayload {
title: string
body: string
data?: Record<string, string>
}
export async function sendPushToToken(token: string, payload: PushPayload) {
const message = {
token,
notification: {
title: payload.title,
body: payload.body,
},
data: payload.data, // optional custom key-value pairs
}
try {
console.log('Sending notification to:', token, payload)
const response = await fcm.send(message)
console.log('Push sent successfully:', response)
return response
} catch (err) {
console.error('Error sending push:', err)
}
return
}
export async function getMobileSubscriptionsFromDB(
pg: SupabaseDirectClient,
userId: string,
) {
try {
const subscriptions = await pg.manyOrNone(`
select token
from push_subscriptions_mobile
where user_id = $1
`, [userId]
)
return subscriptions
} catch (err) {
console.error('Error fetching subscriptions', err)
return []
}
}

View File

@@ -0,0 +1,32 @@
import {APIError, APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = async (body, auth) => {
const {token} = body
if (!token) {
throw new APIError(400, `Invalid subscription object`)
}
const userId = auth?.uid
try {
const pg = createSupabaseDirectClient()
// Check if a subscription already exists
const exists = await pg.oneOrNone(
'select id from push_subscriptions_mobile where token = $1',
[token]
);
if (!exists) {
await pg.none(`insert into push_subscriptions_mobile(token, platform, user_id) values($1, $2, $3) `,
[token, 'android', userId]
);
}
return {success: true};
} catch (err) {
console.error('Error saving subscription', err);
throw new APIError(500, `Failed to save subscription`)
}
}

View File

@@ -0,0 +1,18 @@
create table push_subscriptions_mobile (
id serial primary key,
user_id text not null,
token text not null unique,
platform text not null, -- 'android' or 'ios'
created_at timestamptz default now(),
constraint push_subscriptions_mobile_user_id_fkey foreign KEY (user_id) references users (id) on delete CASCADE
);
-- Row Level Security
ALTER TABLE push_subscriptions_mobile ENABLE ROW LEVEL SECURITY;
-- Indexes
CREATE INDEX IF not exists user_id_idx ON push_subscriptions_mobile (user_id);
CREATE INDEX IF not exists platform_idx ON push_subscriptions_mobile (platform);
CREATE INDEX IF not exists platform_user_id_idx ON push_subscriptions_mobile (platform, user_id);

View File

@@ -699,6 +699,17 @@ export const API = (_apiTypeCheck = {
summary: 'Save a push/browser subscription for the user',
tag: 'Notifications',
},
'save-subscription-mobile': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
token: z.string(),
}),
summary: 'Save a mobile push subscription for the user',
tag: 'Notifications',
},
'create-bookmarked-search': {
method: 'POST',
authed: true,

View File

@@ -25,6 +25,7 @@
},
"dependencies": {
"@capacitor/core": "7.4.4",
"@capacitor/push-notifications": "7.0.3",
"@playwright/test": "^1.54.2",
"colorette": "^2.0.20",
"prismjs": "^1.30.0",

View File

@@ -0,0 +1,34 @@
import {PushNotifications} from '@capacitor/push-notifications'
import {useEffect} from "react"
import {api} from "web/lib/api"
import {useUser} from "web/hooks/use-user"
export default function AndroidPush() {
const user = useUser() // authenticated user
const isWeb = typeof window !== 'undefined' && 'serviceWorker' in navigator
useEffect(() => {
if (!user?.id || isWeb) return
console.log('AndroidPush', user)
PushNotifications.requestPermissions().then(result => {
if (result.receive !== 'granted') return
PushNotifications.register()
})
PushNotifications.addListener('registration', async token => {
console.log('Device token:', token.value)
try {
const {data} = await api('save-subscription-mobile', { token: token.value })
console.log('Mobile subscription saved:', data)
} catch (err) {
console.error('Failed saving android subscription', err)
}
})
PushNotifications.addListener('pushNotificationReceived', notif => {
console.log('Push received', notif)
})
}, [user?.id, isWeb])
return null
}

View File

@@ -5,13 +5,12 @@ import {useUser} from "web/hooks/use-user";
const vapidPublicKey = 'BF80q7LrDa4a5ksS2BZrX6PPvL__y0jCNvNqyUzvk8Y4ofTdrS0kRnKfGpClCQAHWmcPHIUmWq8jgQ4ROquSpJQ'
export default function PushSubscriber() {
export default function WebPush() {
const user = useUser(); // authenticated user
const isWeb = typeof window !== 'undefined' && 'serviceWorker' in navigator;
useEffect(() => {
console.log('PushSubscriber', user, 'serviceWorker' in navigator)
if (!user) return; // only subscribe logged-in users
if (!('serviceWorker' in navigator)) return;
if (!user?.id || !isWeb) return;
console.log('WebPush', user)
const registerPush = async () => {
navigator.serviceWorker
@@ -37,7 +36,7 @@ export default function PushSubscriber() {
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
})
// Send subscription to server
// Send subscription to backend
const {data} = await api('save-subscription', {subscription})
console.log('Subscription saved:', data)
})
@@ -48,7 +47,7 @@ export default function PushSubscriber() {
};
registerPush()
}, [user?.id])
}, [user?.id, isWeb])
return null; // component doesn't render anything
return null
}

View File

@@ -10,7 +10,8 @@ import '../styles/globals.css'
import {Major_Mono_Display} from 'next/font/google'
import clsx from 'clsx'
import {initTracking} from 'web/lib/service/analytics'
import PushSubscriber from "web/lib/service/notifications";
import WebPush from "web/lib/service/web-push";
import AndroidPush from "web/lib/service/android-push";
// See https://nextjs.org/docs/basic-features/font-optimization#google-fonts
// and if you add a font, you must add it to tailwind config as well for it to work.
@@ -111,7 +112,8 @@ function MyApp({Component, pageProps}: AppProps<PageProps>) {
)}
>
<AuthProvider serverUser={pageProps.auth}>
<PushSubscriber/>
<WebPush/>
<AndroidPush/>
<Component {...pageProps} />
</AuthProvider>
{/* Workaround for https://github.com/tailwindlabs/headlessui/discussions/666, to allow font CSS variable */}

View File

@@ -1146,6 +1146,11 @@
dependencies:
tslib "^2.1.0"
"@capacitor/push-notifications@7.0.3":
version "7.0.3"
resolved "https://registry.yarnpkg.com/@capacitor/push-notifications/-/push-notifications-7.0.3.tgz#b053f5c8db8cc8ceddda8dd9a1c744a72113e202"
integrity sha512-4qt5dRVBkHzq202NEoj3JC+S+zQCrZ1FJh7sjkICy/i1iEos+VaoB3bie8eWDQ2LTARktB4+k2xkdpu8pcVo/g==
"@corex/deepmerge@^2.6.148":
version "2.6.148"
resolved "https://registry.yarnpkg.com/@corex/deepmerge/-/deepmerge-2.6.148.tgz#8fa825d53ffd1cbcafce1b6a830eefd3dcc09dd5"