Skip to content

Commit f1676c5

Browse files
committed
Add mobile push notifications
1 parent 05f6f3c commit f1676c5

File tree

10 files changed

+254
-70
lines changed

10 files changed

+254
-70
lines changed

backend/api/src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {getUser} from "api/get-user";
6868
import {IS_LOCAL} from "common/envs/constants";
6969
import {localSendTestEmail} from "api/test";
7070
import path from "node:path";
71+
import {saveSubscriptionMobile} from "api/save-subscription-mobile";
7172

7273
// const corsOptions: CorsOptions = {
7374
// origin: ['*'], // Only allow requests from this domain
@@ -354,6 +355,7 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
354355
'get-messages-count': getMessagesCount,
355356
'set-last-online-time': setLastOnlineTime,
356357
'save-subscription': saveSubscription,
358+
'save-subscription-mobile': saveSubscriptionMobile,
357359
'create-bookmarked-search': createBookmarkedSearch,
358360
'delete-bookmarked-search': deleteBookmarkedSearch,
359361
}

backend/api/src/helpers/private-messages.ts

Lines changed: 140 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ import {sendNewMessageEmail} from 'email/functions/helpers'
1313
import dayjs from 'dayjs'
1414
import utc from 'dayjs/plugin/utc'
1515
import timezone from 'dayjs/plugin/timezone'
16-
import webPush from 'web-push';
17-
import {parseJsonContentToText} from "common/util/parse";
18-
import {encryptMessage} from "shared/encryption";
16+
import webPush from 'web-push'
17+
import {parseJsonContentToText} from "common/util/parse"
18+
import {encryptMessage} from "shared/encryption"
19+
import * as admin from 'firebase-admin'
20+
21+
const fcm = admin.messaging()
1922

2023
dayjs.extend(utc)
2124
dayjs.extend(timezone)
@@ -29,17 +32,17 @@ export const leaveChatContent = (userName: string) => ({
2932
},
3033
],
3134
})
32-
export const joinChatContent = (userName: string) => {
33-
return {
34-
type: 'doc',
35-
content: [
36-
{
37-
type: 'paragraph',
38-
content: [{text: `${userName} joined the chat!`, type: 'text'}],
39-
},
40-
],
41-
}
42-
}
35+
// export const joinChatContent = (userName: string) => {
36+
// return {
37+
// type: 'doc',
38+
// content: [
39+
// {
40+
// type: 'paragraph',
41+
// content: [{text: `${userName} joined the chat!`, type: 'text'}],
42+
// },
43+
// ],
44+
// }
45+
// }
4346

4447
export const insertPrivateMessage = async (
4548
content: Json,
@@ -48,8 +51,8 @@ export const insertPrivateMessage = async (
4851
visibility: ChatVisibility,
4952
pg: SupabaseDirectClient
5053
) => {
51-
const plaintext = JSON.stringify(content);
52-
const {ciphertext, iv, tag} = encryptMessage(plaintext);
54+
const plaintext = JSON.stringify(content)
55+
const {ciphertext, iv, tag} = encryptMessage(plaintext)
5356
const lastMessage = await pg.one(
5457
`insert into private_user_messages (ciphertext, iv, tag, channel_id, user_id, visibility)
5558
values ($1, $2, $3, $4, $5, $6)
@@ -134,7 +137,7 @@ export const createPrivateUserMessageMain = async (
134137
void notifyOtherUserInChannelIfInactive(channelId, creator, content, pg)
135138
.catch((err) => {
136139
console.error('notifyOtherUserInChannelIfInactive failed', err)
137-
});
140+
})
138141

139142
track(creator.id, 'send private message', {
140143
channelId,
@@ -162,49 +165,24 @@ const notifyOtherUserInChannelIfInactive = async (
162165
// We're only sending notifs for 1:1 channels
163166
if (!otherUserIds || otherUserIds.length > 1) return
164167

165-
const otherUserId = first(otherUserIds)
166-
if (!otherUserId) return
168+
const receiverId = first(otherUserIds)?.user_id
169+
if (!receiverId) return
167170

168171
// TODO: notification only for active user
169172

170-
const otherUser = await getUser(otherUserId.user_id)
171-
console.debug('otherUser:', otherUser)
172-
if (!otherUser) return
173+
const receiver = await getUser(receiverId)
174+
console.debug('receiver:', receiver)
175+
if (!receiver) return
173176

174-
// Push notif
175-
webPush.setVapidDetails(
176-
177-
process.env.VAPID_PUBLIC_KEY!,
178-
process.env.VAPID_PRIVATE_KEY!
179-
);
177+
// Push notifs
180178
const textContent = parseJsonContentToText(content)
181-
// Retrieve subscription from the database
182-
const subscriptions = await getSubscriptionsFromDB(otherUser.id, pg);
183-
for (const subscription of subscriptions) {
184-
try {
185-
const payload = JSON.stringify({
186-
title: `${creator.name}`,
187-
body: textContent,
188-
url: `/messages/${channelId}`,
189-
})
190-
console.log('Sending notification to:', subscription.endpoint, payload);
191-
await webPush.sendNotification(subscription, payload);
192-
} catch (err: any) {
193-
console.log('Failed to send notification', err);
194-
if (err.statusCode === 410 || err.statusCode === 404) {
195-
console.warn('Removing expired subscription', subscription.endpoint);
196-
await pg.none(
197-
`DELETE
198-
FROM push_subscriptions
199-
WHERE endpoint = $1
200-
AND user_id = $2`,
201-
[subscription.endpoint, otherUser.id]
202-
);
203-
} else {
204-
console.error('Push failed', err);
205-
}
206-
}
179+
const payload = {
180+
title: `${creator.name}`,
181+
body: textContent,
182+
url: `/messages/${channelId}`,
207183
}
184+
await sendWebNotifications(pg, receiverId, JSON.stringify(payload))
185+
await sendMobileNotifications(pg, receiverId, payload)
208186

209187
const startOfDay = dayjs()
210188
.tz('America/Los_Angeles')
@@ -222,7 +200,7 @@ const notifyOtherUserInChannelIfInactive = async (
222200
log('previous messages this day', previousMessagesThisDayBetweenTheseUsers)
223201
if (previousMessagesThisDayBetweenTheseUsers.count > 1) return
224202

225-
await createNewMessageNotification(creator, otherUser, channelId)
203+
await createNewMessageNotification(creator, receiver, channelId)
226204
}
227205

228206
const createNewMessageNotification = async (
@@ -237,24 +215,126 @@ const createNewMessageNotification = async (
237215
}
238216

239217

218+
async function sendWebNotifications(
219+
pg: SupabaseDirectClient,
220+
userId: string,
221+
payload: string,
222+
) {
223+
webPush.setVapidDetails(
224+
225+
process.env.VAPID_PUBLIC_KEY!,
226+
process.env.VAPID_PRIVATE_KEY!
227+
)
228+
// Retrieve subscription from the database
229+
const subscriptions = await getSubscriptionsFromDB(pg, userId)
230+
for (const subscription of subscriptions) {
231+
try {
232+
console.log('Sending notification to:', subscription.endpoint, payload)
233+
await webPush.sendNotification(subscription, payload)
234+
} catch (err: any) {
235+
console.log('Failed to send notification', err)
236+
if (err.statusCode === 410 || err.statusCode === 404) {
237+
console.warn('Removing expired subscription', subscription.endpoint)
238+
await removeSubscription(pg, subscription.endpoint, userId)
239+
} else {
240+
console.error('Push failed', err)
241+
}
242+
}
243+
}
244+
}
245+
246+
240247
export async function getSubscriptionsFromDB(
248+
pg: SupabaseDirectClient,
241249
userId: string,
242-
pg: SupabaseDirectClient
243250
) {
244251
try {
245252
const subscriptions = await pg.manyOrNone(`
246253
select endpoint, keys
247254
from push_subscriptions
248255
where user_id = $1
249256
`, [userId]
250-
);
257+
)
251258

252259
return subscriptions.map(sub => ({
253260
endpoint: sub.endpoint,
254261
keys: sub.keys,
255-
}));
262+
}))
263+
} catch (err) {
264+
console.error('Error fetching subscriptions', err)
265+
return []
266+
}
267+
}
268+
269+
async function removeSubscription(
270+
pg: SupabaseDirectClient,
271+
endpoint: any,
272+
userId: string,
273+
) {
274+
await pg.none(
275+
`DELETE
276+
FROM push_subscriptions
277+
WHERE endpoint = $1
278+
AND user_id = $2`,
279+
[endpoint, userId]
280+
)
281+
}
282+
283+
284+
async function sendMobileNotifications(
285+
pg: SupabaseDirectClient,
286+
userId: string,
287+
payload: PushPayload,
288+
) {
289+
const subscriptions = await getMobileSubscriptionsFromDB(pg, userId)
290+
for (const subscription of subscriptions) {
291+
await sendPushToToken(subscription.token, payload)
292+
}
293+
}
294+
295+
interface PushPayload {
296+
title: string
297+
body: string
298+
data?: Record<string, string>
299+
}
300+
301+
export async function sendPushToToken(token: string, payload: PushPayload) {
302+
const message = {
303+
token,
304+
notification: {
305+
title: payload.title,
306+
body: payload.body,
307+
},
308+
data: payload.data, // optional custom key-value pairs
309+
}
310+
311+
try {
312+
console.log('Sending notification to:', token, payload)
313+
const response = await fcm.send(message)
314+
console.log('Push sent successfully:', response)
315+
return response
316+
} catch (err) {
317+
console.error('Error sending push:', err)
318+
}
319+
return
320+
}
321+
322+
323+
export async function getMobileSubscriptionsFromDB(
324+
pg: SupabaseDirectClient,
325+
userId: string,
326+
) {
327+
try {
328+
const subscriptions = await pg.manyOrNone(`
329+
select token
330+
from push_subscriptions_mobile
331+
where user_id = $1
332+
`, [userId]
333+
)
334+
335+
return subscriptions
256336
} catch (err) {
257-
console.error('Error fetching subscriptions', err);
258-
return [];
337+
console.error('Error fetching subscriptions', err)
338+
return []
259339
}
260340
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {APIError, APIHandler} from './helpers/endpoint'
2+
import {createSupabaseDirectClient} from 'shared/supabase/init'
3+
4+
export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = async (body, auth) => {
5+
const {token} = body
6+
7+
if (!token) {
8+
throw new APIError(400, `Invalid subscription object`)
9+
}
10+
11+
const userId = auth?.uid
12+
13+
try {
14+
const pg = createSupabaseDirectClient()
15+
// Check if a subscription already exists
16+
const exists = await pg.oneOrNone(
17+
'select id from push_subscriptions_mobile where token = $1',
18+
[token]
19+
);
20+
21+
if (!exists) {
22+
await pg.none(`insert into push_subscriptions_mobile(token, platform, user_id) values($1, $2, $3) `,
23+
[token, 'android', userId]
24+
);
25+
}
26+
27+
return {success: true};
28+
} catch (err) {
29+
console.error('Error saving subscription', err);
30+
throw new APIError(500, `Failed to save subscription`)
31+
}
32+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
create table push_subscriptions_mobile (
2+
id serial primary key,
3+
user_id text not null,
4+
token text not null unique,
5+
platform text not null, -- 'android' or 'ios'
6+
created_at timestamptz default now(),
7+
constraint push_subscriptions_mobile_user_id_fkey foreign KEY (user_id) references users (id) on delete CASCADE
8+
);
9+
10+
-- Row Level Security
11+
ALTER TABLE push_subscriptions_mobile ENABLE ROW LEVEL SECURITY;
12+
13+
-- Indexes
14+
CREATE INDEX IF not exists user_id_idx ON push_subscriptions_mobile (user_id);
15+
16+
CREATE INDEX IF not exists platform_idx ON push_subscriptions_mobile (platform);
17+
18+
CREATE INDEX IF not exists platform_user_id_idx ON push_subscriptions_mobile (platform, user_id);

common/src/api/schema.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,17 @@ export const API = (_apiTypeCheck = {
699699
summary: 'Save a push/browser subscription for the user',
700700
tag: 'Notifications',
701701
},
702+
'save-subscription-mobile': {
703+
method: 'POST',
704+
authed: true,
705+
rateLimited: true,
706+
returns: {} as any,
707+
props: z.object({
708+
token: z.string(),
709+
}),
710+
summary: 'Save a mobile push subscription for the user',
711+
tag: 'Notifications',
712+
},
702713
'create-bookmarked-search': {
703714
method: 'POST',
704715
authed: true,

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
},
2626
"dependencies": {
2727
"@capacitor/core": "7.4.4",
28+
"@capacitor/push-notifications": "7.0.3",
2829
"@playwright/test": "^1.54.2",
2930
"colorette": "^2.0.20",
3031
"prismjs": "^1.30.0",

web/lib/service/android-push.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {PushNotifications} from '@capacitor/push-notifications'
2+
import {useEffect} from "react"
3+
import {api} from "web/lib/api"
4+
import {useUser} from "web/hooks/use-user"
5+
6+
export default function AndroidPush() {
7+
const user = useUser() // authenticated user
8+
const isWeb = typeof window !== 'undefined' && 'serviceWorker' in navigator
9+
useEffect(() => {
10+
if (!user?.id || isWeb) return
11+
console.log('AndroidPush', user)
12+
13+
PushNotifications.requestPermissions().then(result => {
14+
if (result.receive !== 'granted') return
15+
PushNotifications.register()
16+
})
17+
18+
PushNotifications.addListener('registration', async token => {
19+
console.log('Device token:', token.value)
20+
try {
21+
const {data} = await api('save-subscription-mobile', { token: token.value })
22+
console.log('Mobile subscription saved:', data)
23+
} catch (err) {
24+
console.error('Failed saving android subscription', err)
25+
}
26+
})
27+
28+
PushNotifications.addListener('pushNotificationReceived', notif => {
29+
console.log('Push received', notif)
30+
})
31+
}, [user?.id, isWeb])
32+
33+
return null
34+
}

0 commit comments

Comments
 (0)