Skip to content

Commit 7b512a9

Browse files
committed
fix(push): resolve iOS devices not receiving push notifications
iOS users were not seeing push notifications due to several issues across the pipeline: - Database: is_user_online() referenced a non-existent column (last_seen_at → online_at), which broke notification enqueueing for all platforms - Service worker: notifications of the same type silently replaced each other on iOS because the tag was generic ("mention", "reply"). Now each notification gets a unique tag so they all show up - Service worker: removed vibrate option (not supported on iOS) - iPadOS detection: iPads running iPadOS 13+ were misidentified as desktop, causing subscriptions to register under the wrong platform Also extracted shared platform-detection logic (iPad, iOS, Android) into a single utility (src/lib/platform.ts) so the hook and the push library use the same code instead of duplicating it
1 parent c487dba commit 7b512a9

File tree

5 files changed

+123
-83
lines changed

5 files changed

+123
-83
lines changed

packages/supabase/scripts/19-push-notifications-pgmq.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ as $$
173173
from public.users
174174
where id = p_user_id
175175
and status = 'ONLINE'
176-
and last_seen_at > now() - interval '2 minutes'
176+
and online_at > now() - interval '2 minutes'
177177
);
178178
$$;
179179

packages/webapp/public/service-worker.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,18 +152,22 @@ self.addEventListener("push", (event) => {
152152
// The sender_avatar comes from the push payload (set in 19-push-notifications.sql)
153153
const notificationIcon = data.sender_avatar || "/icons/android-chrome-192x192.png";
154154

155+
// Use notification_id as tag so each notification is unique on iOS.
156+
// iOS Safari does NOT support `renotify` — using a generic tag like "mention"
157+
// would cause each new mention to silently replace the previous one.
158+
const tag = data.notification_id || `${data.type}-${Date.now()}`;
159+
155160
const options = {
156161
body: body,
157162
icon: notificationIcon,
158163
badge: "/icons/favicon-32x32.png",
159-
tag: data.type || "default",
164+
tag: tag,
160165
renotify: true,
161166
requireInteraction: false,
162167
data: {
163168
url: data.action_url || "/",
164169
notification_id: data.notification_id,
165170
},
166-
vibrate: [200, 100, 200],
167171
// Include image preview if message has an attachment
168172
...(data.image_url && { image: data.image_url }),
169173
};

packages/webapp/src/hooks/usePlatformDetection.ts

Lines changed: 9 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,13 @@
44
* Detects device platform, PWA installation status, and push support.
55
* Used for PWA install prompts and platform-specific push notification flows.
66
*
7-
* iPadOS note:
8-
* ─────────────────────────────────────────────────────────────
9-
* iPadOS 13+ reports as desktop Safari (same UA as macOS Safari).
10-
* We use maxTouchPoints > 1 to distinguish iPad from Mac.
11-
* This is the industry-standard detection method used by Google, Apple docs, etc.
12-
* ─────────────────────────────────────────────────────────────
7+
* Core detection logic (isIPadDevice, isIOSDevice, getIOSVersion, etc.)
8+
* lives in `@/lib/platform.ts` — shared with push-notifications.ts.
139
*/
1410
import { useCallback, useEffect, useState } from 'react'
1511

12+
import { getDevicePlatform, getIOSVersion, isIOSDevice } from '../lib/platform'
13+
1614
export interface PlatformInfo {
1715
/** Device platform */
1816
platform: 'ios' | 'android' | 'desktop'
@@ -30,59 +28,6 @@ export interface PlatformInfo {
3028
iosSupportsWebPush: boolean
3129
}
3230

33-
/**
34-
* Detect iOS version from user agent.
35-
* For iPadOS (which reports as macOS), we can't get the exact version from UA,
36-
* but if we detect it as iPad via touch points, we know it's 13+ (the first iPadOS).
37-
*/
38-
function getIOSVersion(isIPad: boolean): number | null {
39-
if (typeof window === 'undefined') return null
40-
41-
const ua = navigator.userAgent
42-
43-
// iPhone/iPod — version is in UA
44-
const match = ua.match(/OS (\d+)_(\d+)/)
45-
if (match) {
46-
return parseFloat(`${match[1]}.${match[2]}`)
47-
}
48-
49-
// iPadOS 13+ — reports as macOS, extract version from Mac OS X version
50-
// iPadOS version tracks macOS version starting from iPadOS 13
51-
if (isIPad) {
52-
const macMatch = ua.match(/Mac OS X (\d+)[._](\d+)/)
53-
if (macMatch) {
54-
// macOS 10.15 = iPadOS 13, macOS 11 = iPadOS 14, etc.
55-
const major = parseInt(macMatch[1], 10)
56-
const minor = parseInt(macMatch[2], 10)
57-
if (major === 10 && minor >= 15) return 13 + (minor - 15) // 10.15 = 13, 10.16 = 14...
58-
if (major >= 11) return major + 2 // macOS 11 = iPadOS 14, 12 = 15, etc.
59-
}
60-
// If we can't parse, assume modern (iPadOS 13+) since only iPadOS 13+ spoofs desktop UA
61-
return 16.4
62-
}
63-
64-
return null
65-
}
66-
67-
/**
68-
* Detect if device is an iPad (including iPadOS 13+ which spoofs as Mac).
69-
* Uses the maxTouchPoints heuristic — Macs have 0, iPads have 5+.
70-
*/
71-
function isIPadDevice(): boolean {
72-
if (typeof window === 'undefined') return false
73-
74-
const ua = navigator.userAgent
75-
76-
// Classic iPad detection (pre-iPadOS 13)
77-
if (/iPad/.test(ua)) return true
78-
79-
// iPadOS 13+ detection: reports as Macintosh but has touch support
80-
// Real Macs have maxTouchPoints === 0 (or undefined)
81-
if (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1) return true
82-
83-
return false
84-
}
85-
8631
/**
8732
* Detect platform info from user agent and browser APIs
8833
*/
@@ -101,11 +46,9 @@ function detectPlatform(): PlatformInfo {
10146

10247
const ua = navigator.userAgent
10348

104-
// Platform detection (order matters: iPad check before general Mac check)
105-
const iPad = isIPadDevice()
106-
const isIOS = iPad || (/iPhone|iPod/.test(ua) && !(window as any).MSStream)
107-
const isAndroid = /Android/.test(ua)
108-
const platform: 'ios' | 'android' | 'desktop' = isIOS ? 'ios' : isAndroid ? 'android' : 'desktop'
49+
// Platform detection — uses shared lib (isIPadDevice + maxTouchPoints under the hood)
50+
const rawPlatform = getDevicePlatform() // 'ios' | 'android' | 'web'
51+
const platform: 'ios' | 'android' | 'desktop' = rawPlatform === 'web' ? 'desktop' : rawPlatform
10952

11053
// Browser detection
11154
let browser: 'safari' | 'chrome' | 'firefox' | 'edge' | 'other' = 'other'
@@ -127,7 +70,8 @@ function detectPlatform(): PlatformInfo {
12770
'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
12871

12972
// iOS version and web push support (iOS 16.4+)
130-
const iosVersion = isIOS ? getIOSVersion(iPad) : null
73+
const isIOS = isIOSDevice()
74+
const iosVersion = isIOS ? getIOSVersion() : null
13175
const iosSupportsWebPush = iosVersion !== null && iosVersion >= 16.4
13276

13377
return {
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* Platform detection utilities (framework-agnostic)
3+
*
4+
* Pure functions — no React, no side-effects, safe to call from hooks,
5+
* service-worker helpers, or any other context.
6+
*
7+
* iPadOS note:
8+
* iPadOS 13+ reports its user-agent as macOS Safari (no "iPad" in UA).
9+
* We use the maxTouchPoints heuristic to distinguish iPads from Macs.
10+
* This is the industry-standard method (Google Analytics, Apple docs, MDN).
11+
*/
12+
13+
// ── iPad detection ──────────────────────────────────────────────────────────
14+
15+
/**
16+
* Detect if the device is an iPad, including iPadOS 13+ which spoofs as Mac.
17+
* Macs have maxTouchPoints === 0; iPads have 5+.
18+
*/
19+
export function isIPadDevice(): boolean {
20+
if (typeof window === 'undefined') return false
21+
22+
const ua = navigator.userAgent
23+
24+
// Classic iPad (pre-iPadOS 13)
25+
if (/iPad/.test(ua)) return true
26+
27+
// iPadOS 13+: reports as Macintosh but has touch support
28+
if (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1) return true
29+
30+
return false
31+
}
32+
33+
// ── iOS detection ───────────────────────────────────────────────────────────
34+
35+
/**
36+
* Detect if the device is any iOS variant (iPhone, iPod, iPad/iPadOS).
37+
*/
38+
export function isIOSDevice(): boolean {
39+
if (typeof window === 'undefined') return false
40+
41+
const ua = navigator.userAgent
42+
43+
if (/iPhone|iPod/.test(ua) && !(window as any).MSStream) return true
44+
if (isIPadDevice()) return true
45+
46+
return false
47+
}
48+
49+
// ── iOS version ─────────────────────────────────────────────────────────────
50+
51+
/**
52+
* Parse iOS version from the user-agent string.
53+
*
54+
* For iPadOS 13+ (which reports as macOS) we map the macOS version back:
55+
* macOS 10.15 → iPadOS 13, macOS 11 → iPadOS 14, etc.
56+
*
57+
* Returns null on non-iOS devices.
58+
*/
59+
export function getIOSVersion(): number | null {
60+
if (typeof window === 'undefined') return null
61+
62+
const ua = navigator.userAgent
63+
64+
// iPhone / iPod — version is in the UA
65+
const match = ua.match(/OS (\d+)_(\d+)/)
66+
if (match) {
67+
return parseFloat(`${match[1]}.${match[2]}`)
68+
}
69+
70+
// iPadOS 13+ — derive from macOS version
71+
if (isIPadDevice()) {
72+
const macMatch = ua.match(/Mac OS X (\d+)[._](\d+)/)
73+
if (macMatch) {
74+
const major = parseInt(macMatch[1], 10)
75+
const minor = parseInt(macMatch[2], 10)
76+
if (major === 10 && minor >= 15) return 13 + (minor - 15)
77+
if (major >= 11) return major + 2
78+
}
79+
// Can't parse, but only iPadOS 13+ spoofs desktop UA — assume modern
80+
return 16.4
81+
}
82+
83+
return null
84+
}
85+
86+
// ── Platform category ───────────────────────────────────────────────────────
87+
88+
export type DevicePlatform = 'ios' | 'android' | 'web'
89+
90+
/**
91+
* Classify the device into ios / android / web.
92+
* "web" means desktop browser (Mac, Windows, Linux).
93+
*/
94+
export function getDevicePlatform(): DevicePlatform {
95+
if (typeof window === 'undefined') return 'web'
96+
97+
if (isIOSDevice()) return 'ios'
98+
99+
if (/Android/.test(navigator.userAgent)) return 'android'
100+
101+
return 'web'
102+
}

packages/webapp/src/lib/push-notifications.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
import { createClient } from '@utils/supabase/component'
99

10+
import { getDevicePlatform } from './platform'
11+
1012
// VAPID public key - set this in your environment
1113
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY
1214

@@ -85,23 +87,11 @@ function getDeviceId(): string {
8587
}
8688

8789
/**
88-
* Detect platform for push subscription
89-
* Returns 'ios' for iOS devices, 'android' for Android, 'web' for desktop
90+
* Detect platform for push subscription.
91+
* Delegates to the shared platform utility (src/lib/platform.ts).
9092
*/
9193
function getPlatform(): 'ios' | 'android' | 'web' {
92-
if (typeof window === 'undefined') return 'web'
93-
94-
const ua = navigator.userAgent
95-
96-
// iOS detection (iPhone, iPad, iPod)
97-
const isIOS = /iPad|iPhone|iPod/.test(ua) && !(window as any).MSStream
98-
if (isIOS) return 'ios'
99-
100-
// Android detection
101-
const isAndroid = /Android/.test(ua)
102-
if (isAndroid) return 'android'
103-
104-
return 'web'
94+
return getDevicePlatform()
10595
}
10696

10797
/**

0 commit comments

Comments
 (0)