mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-02-26 11:58:50 -05:00
Add mobile push notifications
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
32
backend/api/src/save-subscription-mobile.ts
Normal file
32
backend/api/src/save-subscription-mobile.ts
Normal 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`)
|
||||
}
|
||||
}
|
||||
18
backend/supabase/push_subscriptions_mobile.sql
Normal file
18
backend/supabase/push_subscriptions_mobile.sql
Normal 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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
34
web/lib/service/android-push.ts
Normal file
34
web/lib/service/android-push.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user