@devchitchat/chat 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +313 -0
  2. package/index.js +148 -0
  3. package/migrate/001-drop-channel-invites.js +3 -0
  4. package/migrate/002-invite-initial-roles.js +5 -0
  5. package/migrate/003-dm-channels.js +35 -0
  6. package/migrate/004-notifications.js +15 -0
  7. package/migrate/005-uploads.js +21 -0
  8. package/migrate/006-mention-priority.js +3 -0
  9. package/migrate/007-push-subscriptions.js +14 -0
  10. package/migrate/008-messages-channel-seq-index.js +3 -0
  11. package/migrate/009-message-reactions.js +15 -0
  12. package/migrate/010-edit-messages.js +7 -0
  13. package/package.json +51 -0
  14. package/pages/_error.html +12 -0
  15. package/pages/_layout.html +31 -0
  16. package/pages/_layout.js +13 -0
  17. package/pages/admin/_layout.html +52 -0
  18. package/pages/admin/_layout.js +8 -0
  19. package/pages/admin/bots/[userId].js +88 -0
  20. package/pages/admin/bots/[userId].phtml +89 -0
  21. package/pages/admin/bots/index.js +41 -0
  22. package/pages/admin/bots/index.phtml +58 -0
  23. package/pages/admin/index.js +8 -0
  24. package/pages/admin/invites/index.js +72 -0
  25. package/pages/admin/invites/index.phtml +88 -0
  26. package/pages/admin/users/[userId].js +60 -0
  27. package/pages/admin/users/[userId].phtml +57 -0
  28. package/pages/admin/users/index.js +20 -0
  29. package/pages/admin/users/index.phtml +37 -0
  30. package/pages/api/uploads/index.js +66 -0
  31. package/pages/api/user/settings.js +26 -0
  32. package/pages/auth/signout.js +14 -0
  33. package/pages/channels/[channelId].js +99 -0
  34. package/pages/channels/[channelId].phtml +173 -0
  35. package/pages/index.js +33 -0
  36. package/pages/invite/[token].js +10 -0
  37. package/pages/login/index.js +57 -0
  38. package/pages/login/index.phtml +29 -0
  39. package/pages/public/client/action-sheet.js +77 -0
  40. package/pages/public/client/app.js +38 -0
  41. package/pages/public/client/auth-tabs.js +13 -0
  42. package/pages/public/client/emoji-data.js +197 -0
  43. package/pages/public/client/islands/call.js +1770 -0
  44. package/pages/public/client/islands/sidebar.js +1197 -0
  45. package/pages/public/client/long-press.js +59 -0
  46. package/pages/public/client/modal.js +50 -0
  47. package/pages/public/client/router.js +87 -0
  48. package/pages/public/client/rtc-peer-manager.js +344 -0
  49. package/pages/public/client/settings-sync.js +76 -0
  50. package/pages/public/client/shared/messages.js +147 -0
  51. package/pages/public/client/swipe-nav.js +98 -0
  52. package/pages/public/client/theme.js +27 -0
  53. package/pages/public/client/ws.js +71 -0
  54. package/pages/public/favicon.ico +0 -0
  55. package/pages/public/favicon.png +0 -0
  56. package/pages/public/icon.png +0 -0
  57. package/pages/public/manifest.json +11 -0
  58. package/pages/public/sw.js +38 -0
  59. package/pages/public/themes/base.css +1786 -0
  60. package/pages/public/themes/dark.css +22 -0
  61. package/pages/public/themes/forest.css +22 -0
  62. package/pages/public/themes/light.css +23 -0
  63. package/pages/public/themes/ocean.css +22 -0
  64. package/pages/public/themes/rose.css +22 -0
  65. package/pages/registration/index.js +35 -0
  66. package/pages/registration/index.phtml +38 -0
  67. package/pages/uploads/[uploadId]/[filename].js +45 -0
  68. package/src/adapters/InMemoryAuthRepository.js +74 -0
  69. package/src/adapters/InMemoryChannelRepository.js +138 -0
  70. package/src/adapters/InMemoryDeliveryRepository.js +52 -0
  71. package/src/adapters/InMemoryFileStore.js +53 -0
  72. package/src/adapters/InMemoryHubRepository.js +85 -0
  73. package/src/adapters/InMemoryMessageRepository.js +35 -0
  74. package/src/adapters/InMemoryReactionRepository.js +45 -0
  75. package/src/adapters/InMemorySearchRepository.js +37 -0
  76. package/src/adapters/InMemorySignalingRepository.js +35 -0
  77. package/src/adapters/InMemoryUploadRepository.js +36 -0
  78. package/src/adapters/InMemoryUserSettingsRepository.js +15 -0
  79. package/src/adapters/LocalFileStore.js +40 -0
  80. package/src/adapters/SqliteAuthRepository.js +184 -0
  81. package/src/adapters/SqliteChannelRepository.js +149 -0
  82. package/src/adapters/SqliteDeliveryRepository.js +53 -0
  83. package/src/adapters/SqliteHubRepository.js +99 -0
  84. package/src/adapters/SqliteMessageRepository.js +90 -0
  85. package/src/adapters/SqlitePushRepository.js +39 -0
  86. package/src/adapters/SqliteReactionRepository.js +50 -0
  87. package/src/adapters/SqliteSearchRepository.js +42 -0
  88. package/src/adapters/SqliteSignalingRepository.js +50 -0
  89. package/src/adapters/SqliteUploadRepository.js +34 -0
  90. package/src/adapters/SqliteUserSettingsRepository.js +23 -0
  91. package/src/adminAuth.js +25 -0
  92. package/src/config.js +11 -0
  93. package/src/context.js +77 -0
  94. package/src/core/dm.js +10 -0
  95. package/src/core/mentions.js +27 -0
  96. package/src/core/messages.js +21 -0
  97. package/src/core/reactions.js +6 -0
  98. package/src/core/roles.js +5 -0
  99. package/src/core/uploads.js +107 -0
  100. package/src/db/initDb.js +225 -0
  101. package/src/db/openDb.js +18 -0
  102. package/src/db/runMigrations.js +45 -0
  103. package/src/db/transaction.js +11 -0
  104. package/src/ports/IFileStore.js +34 -0
  105. package/src/services/AuthService.js +208 -0
  106. package/src/services/BotService.js +148 -0
  107. package/src/services/ChannelService.js +176 -0
  108. package/src/services/DeliveryService.js +28 -0
  109. package/src/services/HubService.js +133 -0
  110. package/src/services/MessageService.js +122 -0
  111. package/src/services/NotificationService.js +45 -0
  112. package/src/services/PresenceService.js +55 -0
  113. package/src/services/ReactionService.js +57 -0
  114. package/src/services/SearchService.js +21 -0
  115. package/src/services/SignalingService.js +177 -0
  116. package/src/services/UploadService.js +111 -0
  117. package/src/services/UserSettingsService.js +30 -0
  118. package/src/services/WebPushService.js +217 -0
  119. package/src/util/crypto.js +21 -0
  120. package/src/util/errors.js +14 -0
  121. package/src/util/ids.js +3 -0
  122. package/src/util/logger.js +21 -0
  123. package/src/ws/ChatServer.js +478 -0
  124. package/src/ws/handlers/authHandlers.js +152 -0
  125. package/src/ws/handlers/channelHandlers.js +166 -0
  126. package/src/ws/handlers/hubHandlers.js +82 -0
  127. package/src/ws/handlers/messageHandlers.js +88 -0
  128. package/src/ws/handlers/pushHandlers.js +25 -0
  129. package/src/ws/handlers/reactionHandlers.js +27 -0
  130. package/src/ws/handlers/rtcHandlers.js +126 -0
  131. package/styles.css +22 -0
@@ -0,0 +1,147 @@
1
+ /**
2
+ * shared/messages.js — shared message rendering utilities.
3
+ *
4
+ * Used by: islands/chat.js, islands/call.js
5
+ */
6
+
7
+ export function escHtml(str) {
8
+ return String(str ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
9
+ }
10
+
11
+ export function utcDateKey(tsMs) {
12
+ const d = new Date(tsMs)
13
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`
14
+ }
15
+
16
+ export function formatDateLabel(dateKey) {
17
+ const [y, mo, d] = dateKey.split('-').map(Number)
18
+ const date = new Date(Date.UTC(y, mo - 1, d))
19
+ const todayKey = utcDateKey(Date.now())
20
+ const yestKey = utcDateKey(Date.now() - 86_400_000)
21
+ if (dateKey === todayKey) return 'Today'
22
+ if (dateKey === yestKey) return 'Yesterday'
23
+ return date.toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' })
24
+ }
25
+
26
+ export function makeDateSeparator(dateKey) {
27
+ const el = document.createElement('div')
28
+ el.className = 'date-separator'
29
+ el.dataset.date = dateKey
30
+ el.innerHTML = `<span class="date-separator-label">${formatDateLabel(dateKey)}</span>`
31
+ return el
32
+ }
33
+
34
+ /**
35
+ * Escape HTML then wrap @handles in <span class="mention"> (or mention-self for current user).
36
+ * @param {string} text
37
+ * @param {{ userHandle?: string }} [opts]
38
+ */
39
+ // Combined regex (operates on raw text before HTML-escaping):
40
+ // group 1 (+ inner 2, 3) — markdown link: [text](url)
41
+ // group 4 — bare https?:// URL (excludes []() so it can't swallow a markdown link)
42
+ // group 5 — @mention
43
+ const INLINE_RE = /(\[([^\]]*)\]\((https?:\/\/[^)]+)\))|(https?:\/\/[^\s<>"'[\]()]+)|(@[a-zA-Z0-9_.-]+)/g
44
+
45
+ export function renderText(text, { userHandle } = {}) {
46
+ let result = ''
47
+ let lastIndex = 0
48
+ INLINE_RE.lastIndex = 0
49
+ let m
50
+ while ((m = INLINE_RE.exec(text)) !== null) {
51
+ result += escHtml(text.slice(lastIndex, m.index))
52
+ const [full, , mdText, mdUrl, bareUrl, mention] = m
53
+ if (mdUrl) {
54
+ // [link text](https://url)
55
+ result += `<a href="${escHtml(mdUrl)}" target="_blank" rel="noopener noreferrer">${escHtml(mdText)}</a>`
56
+ } else if (bareUrl) {
57
+ const trimmed = bareUrl.replace(/[.,!?;:)]+$/, '')
58
+ const trailing = bareUrl.slice(trimmed.length)
59
+ result += `<a href="${escHtml(trimmed)}" target="_blank" rel="noopener noreferrer">${escHtml(trimmed)}</a>${escHtml(trailing)}`
60
+ } else if (mention) {
61
+ const handle = mention.slice(1)
62
+ const isSelf = userHandle && handle.toLowerCase() === userHandle.toLowerCase()
63
+ result += `<span class="mention${isSelf ? ' mention-self' : ''}">${escHtml(mention)}</span>`
64
+ }
65
+ lastIndex = m.index + full.length
66
+ }
67
+ result += escHtml(text.slice(lastIndex))
68
+ return result
69
+ }
70
+
71
+ /**
72
+ * Apply inline rendering (URLs, @mentions) to text nodes inside an element,
73
+ * leaving existing HTML structure (e.g. server-rendered <a> tags) intact.
74
+ * Used by hydrateSeedMessages so markdown-rendered links are preserved.
75
+ * @param {Element} el
76
+ * @param {{ userHandle?: string }} [opts]
77
+ */
78
+ export function applyInlineRenderingToTextNodes(el, { userHandle } = {}) {
79
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
80
+ const nodes = []
81
+ let n
82
+ while ((n = walker.nextNode())) {
83
+ // Skip text nodes already inside an <a> — they are already linked.
84
+ if (!n.parentElement?.closest('a')) nodes.push(n)
85
+ }
86
+ for (const textNode of nodes) {
87
+ const raw = textNode.textContent
88
+ if (!raw.trim()) continue
89
+ const rendered = renderText(raw, { userHandle })
90
+ if (rendered === escHtml(raw)) continue // nothing changed
91
+ const span = document.createElement('span')
92
+ span.innerHTML = rendered
93
+ textNode.replaceWith(...span.childNodes)
94
+ }
95
+ }
96
+
97
+ export function formatBytes(bytes) {
98
+ if (bytes < 1024) return `${bytes} B`
99
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`
100
+ return `${(bytes / 1048576).toFixed(1)} MB`
101
+ }
102
+
103
+ export function renderAttachment(a) {
104
+ const name = escHtml(a.filename ?? a.original_name ?? 'file')
105
+ const url = escHtml(a.url)
106
+ const mime = a.mime_type ?? ''
107
+ if (mime.startsWith('image/')) {
108
+ return `<a class="attachment-image-link" href="${url}" target="_blank" rel="noopener noreferrer">
109
+ <img class="attachment-image" src="${url}" alt="${name}" loading="lazy">
110
+ </a>`
111
+ }
112
+ const size = a.size_bytes ? ` (${formatBytes(a.size_bytes)})` : ''
113
+ return `<a class="attachment-file" href="${url}" target="_blank" rel="noopener noreferrer" download>
114
+ <span class="attachment-file-icon">📎</span>
115
+ <span class="attachment-file-name">${name}</span>
116
+ <span class="attachment-file-size">${size}</span>
117
+ </a>`
118
+ }
119
+
120
+ /**
121
+ * Build a <article class="message"> element.
122
+ * @param {{ msg_id, seq, user_id, user_display_name, ts, text, attachments }} msg
123
+ * @param {{ userId?: string, userHandle?: string }} [ctx] — caller's identity, used for self-styling and @mention highlighting
124
+ */
125
+ export function makeMessageEl({ msg_id, seq, user_id, user_display_name, ts, text, rendered_text, edited_at, attachments }, { userId, userHandle } = {}) {
126
+ const article = document.createElement('article')
127
+ article.className = 'message'
128
+ article.dataset.seq = seq
129
+ article.dataset.msgId = msg_id
130
+ article.dataset.userId = user_id
131
+ article.dataset.rawText = text ?? ''
132
+ article.dataset.editedAt = edited_at ?? ''
133
+ const time = new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
134
+ const isSelf = userId != null && user_id === userId
135
+ const attachmentHtml = (attachments ?? []).map(a => renderAttachment(a)).join('')
136
+ const editedHtml = edited_at ? '<span class="message-edited">(edited)</span>' : ''
137
+ const actionsHtml = isSelf ? '<button class="btn-msg-actions btn-icon" type="button" title="Message actions">…</button>' : ''
138
+ const textHtml = rendered_text ?? (text ? renderText(text, { userHandle }) : '')
139
+ article.innerHTML = `
140
+ <span class="message-handle${isSelf ? '' : ' dm-trigger'}" data-user-id="${escHtml(user_id)}" title="${isSelf ? '' : 'Send a direct message'}">${escHtml(user_display_name ?? user_id)}</span>
141
+ <time class="message-time" datetime="${ts}">${time}${editedHtml}</time>
142
+ ${textHtml ? `<p class="message-text">${textHtml}</p>` : ''}
143
+ ${attachmentHtml}
144
+ ${actionsHtml}
145
+ `
146
+ return article
147
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * swipe-nav.js — horizontal swipe to switch between sidebar and message panel.
3
+ *
4
+ * Swipe right on the message panel → sidebar slides in, message panel slides out.
5
+ * Swipe left on the sidebar → message panel slides in, sidebar slides out.
6
+ *
7
+ * Direction is locked after LOCK_PX of movement so vertical scrolling inside
8
+ * either panel is never interrupted.
9
+ */
10
+
11
+ const SWIPE_PX = 50 // minimum horizontal distance to commit a swipe
12
+ const LOCK_PX = 20 // travel before we decide horizontal vs vertical
13
+
14
+ function attachSwipe(el, { onLeft, onRight }) {
15
+ let startX, startY, dir
16
+ let suppressSwipe = false
17
+
18
+ // Set suppressSwipe = true if selection changes during a touch gesture.
19
+ // This catches long-press → extend-selection-by-dragging in one gesture.
20
+ function onSelectionChange() {
21
+ suppressSwipe = true
22
+ }
23
+
24
+ el.addEventListener('touchstart', e => {
25
+ startX = e.touches[0].clientX
26
+ startY = e.touches[0].clientY
27
+ dir = null
28
+ // Case 1: a Range selection already exists — user is likely dragging a handle.
29
+ // (Vertical scroll within .messages is guarded by touch-action: pan-y on the element
30
+ // and direction locking via LOCK_PX, so we no longer suppress swipes from there.)
31
+ suppressSwipe = window.getSelection()?.type === 'Range'
32
+ // Case 3: selection might be created during this touch (long-press → drag).
33
+ if (!suppressSwipe) {
34
+ document.addEventListener('selectionchange', onSelectionChange)
35
+ }
36
+ el.style.transition = 'none'
37
+ }, { passive: true })
38
+
39
+ el.addEventListener('touchmove', e => {
40
+ if (suppressSwipe) return
41
+
42
+ const dx = e.touches[0].clientX - startX
43
+ const dy = e.touches[0].clientY - startY
44
+
45
+ // Lock direction once we know which way the user is moving
46
+ if (!dir) {
47
+ if (Math.abs(dx) < LOCK_PX && Math.abs(dy) < LOCK_PX) return
48
+ dir = Math.abs(dx) > Math.abs(dy) ? 'h' : 'v'
49
+ }
50
+
51
+ if (dir !== 'h') return
52
+
53
+ // Only follow the finger in the valid direction for this panel
54
+ const valid = (dx > 0 && onRight) || (dx < 0 && onLeft)
55
+ if (!valid) return
56
+
57
+ e.preventDefault()
58
+ el.style.transform = `translateX(${dx}px)`
59
+ }, { passive: false })
60
+
61
+ el.addEventListener('touchend', e => {
62
+ document.removeEventListener('selectionchange', onSelectionChange)
63
+
64
+ // Reset inline styles — CSS transition takes over from here
65
+ el.style.transition = ''
66
+ el.style.transform = ''
67
+
68
+ if (suppressSwipe || dir !== 'h') { dir = null; return }
69
+ const dx = e.changedTouches[0].clientX - startX
70
+ dir = null
71
+
72
+ if (dx >= SWIPE_PX && onRight) onRight()
73
+ else if (dx <= -SWIPE_PX && onLeft) onLeft()
74
+ }, { passive: true })
75
+ }
76
+
77
+ export function initSwipeNav() {
78
+ const mainContent = document.querySelector('.main-content')
79
+ const sidebar = document.querySelector('.sidebar')
80
+ if (!mainContent || !sidebar) return
81
+
82
+ import('./settings-sync.js').then(({ patchSettings }) => {
83
+ const showSidebar = () => {
84
+ document.body.classList.add('sidebar-open')
85
+ patchSettings({ mobile_chat_open: false })
86
+ }
87
+ const showMessages = () => {
88
+ document.body.classList.remove('sidebar-open')
89
+ patchSettings({ mobile_chat_open: true })
90
+ }
91
+
92
+ // Message panel: swipe right → show sidebar
93
+ attachSwipe(mainContent, { onRight: showSidebar })
94
+
95
+ // Sidebar: swipe left → show message panel
96
+ attachSwipe(sidebar, { onLeft: showMessages })
97
+ })
98
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * theme.js — applies the saved theme on load and wires the <select> picker.
3
+ *
4
+ * Each theme is a separate CSS file in /themes/<name>.css.
5
+ * The <html data-theme> attribute is set so themes can also use attribute selectors.
6
+ */
7
+ const THEMES = ['dark', 'light', 'ocean', 'forest', 'rose']
8
+ const STORAGE_KEY = 'devchitchat_theme'
9
+ const BASE_PATH = window.__BASE_PATH__ ?? ''
10
+ const stylesheet = document.getElementById('theme-stylesheet')
11
+ const picker = document.getElementById('theme-picker')
12
+
13
+ function applyTheme(name) {
14
+ const theme = THEMES.includes(name) ? name : 'dark'
15
+ document.documentElement.dataset.theme = theme
16
+ if (stylesheet) stylesheet.href = `${BASE_PATH}/themes/${theme}.css`
17
+ if (picker) picker.value = theme
18
+ localStorage.setItem(STORAGE_KEY, theme)
19
+ }
20
+
21
+ // Restore saved theme immediately (before paint)
22
+ applyTheme(localStorage.getItem(STORAGE_KEY) ?? 'dark')
23
+
24
+ // Wire picker
25
+ if (picker) {
26
+ picker.addEventListener('change', (e) => applyTheme(e.target.value))
27
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * WsClient — WebSocket wrapper with auto-reconnect (exponential backoff).
3
+ *
4
+ * Usage:
5
+ * const ws = new WsClient('/ws')
6
+ * ws.on('msg.event', (body) => { ... })
7
+ * ws.send({ t: 'hello', ... })
8
+ */
9
+ export class WsClient extends EventTarget {
10
+ #url
11
+ #ws = null
12
+ #reconnectDelay = 1000
13
+ #maxDelay = 10000
14
+ #msgId = 0
15
+ #pending = [] // queued while disconnected
16
+
17
+ constructor(path = '/ws') {
18
+ super()
19
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
20
+ this.#url = `${proto}//${location.host}${path}`
21
+ this.#connect()
22
+ }
23
+
24
+ #connect() {
25
+ this.#ws = new WebSocket(this.#url)
26
+
27
+ this.#ws.onopen = () => {
28
+ this.#reconnectDelay = 1000
29
+ this.dispatchEvent(new Event('open'))
30
+ for (const msg of this.#pending) this.#ws.send(JSON.stringify(msg))
31
+ this.#pending = []
32
+ }
33
+
34
+ this.#ws.onmessage = ({ data }) => {
35
+ let msg
36
+ try { msg = JSON.parse(data) } catch { return }
37
+ this.dispatchEvent(Object.assign(new Event(msg.t), { msg }))
38
+ this.dispatchEvent(Object.assign(new Event('*'), { msg }))
39
+ }
40
+
41
+ this.#ws.onclose = () => {
42
+ this.dispatchEvent(new Event('close'))
43
+ setTimeout(() => this.#connect(), this.#reconnectDelay)
44
+ this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, this.#maxDelay)
45
+ }
46
+
47
+ this.#ws.onerror = () => { /* onclose fires after */ }
48
+ }
49
+
50
+ send(payload) {
51
+ const msg = { v: 1, id: `c_${++this.#msgId}`, ts: Date.now(), ...payload }
52
+ if (this.#ws?.readyState === WebSocket.OPEN) {
53
+ this.#ws.send(JSON.stringify(msg))
54
+ } else {
55
+ this.#pending.push(msg)
56
+ }
57
+ return msg.id
58
+ }
59
+
60
+ on(type, handler) {
61
+ this.addEventListener(type, (e) => handler(e.msg?.body ?? e.msg, e.msg))
62
+ return this
63
+ }
64
+
65
+ once(type, handler) {
66
+ this.addEventListener(type, (e) => handler(e.msg?.body ?? e.msg, e.msg), { once: true })
67
+ return this
68
+ }
69
+
70
+ get ready() { return this.#ws?.readyState === WebSocket.OPEN }
71
+ }
Binary file
Binary file
Binary file
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "devchitchat",
3
+ "short_name": "devchitchat",
4
+ "start_url": "/",
5
+ "display": "standalone",
6
+ "background_color": "#1a1b1e",
7
+ "theme_color": "#141517",
8
+ "icons": [
9
+ { "src": "/icon.png", "sizes": "300x300", "type": "image/png", "purpose": "any maskable" }
10
+ ]
11
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Service worker for Web Push notifications.
3
+ * Served at BASE_PATH/sw.js via a custom route in index.js.
4
+ * Registered with scope BASE_PATH/ so it covers all app pages.
5
+ */
6
+
7
+ self.addEventListener('push', event => {
8
+ let data = {}
9
+ try { data = event.data?.json() ?? {} } catch { /* ignore malformed payloads */ }
10
+
11
+ // Use the registered scope as the default notification URL so it works at any BASE_PATH
12
+ const defaultUrl = self.registration.scope
13
+
14
+ event.waitUntil(
15
+ self.registration.showNotification(data.title ?? 'chat', {
16
+ body: data.body ?? '',
17
+ icon: './favicon.png',
18
+ badge: './favicon.png',
19
+ tag: data.channel_id ?? 'chat', // collapse multiple from the same channel
20
+ data: { url: data.url ?? defaultUrl },
21
+ })
22
+ )
23
+ })
24
+
25
+ self.addEventListener('notificationclick', event => {
26
+ event.notification.close()
27
+ const url = event.notification.data?.url ?? self.registration.scope
28
+ event.waitUntil(
29
+ clients.matchAll({ type: 'window', includeUncontrolled: true }).then(windowClients => {
30
+ // Focus an existing tab if one is open at the target URL
31
+ for (const client of windowClients) {
32
+ if (client.url === url && 'focus' in client) return client.focus()
33
+ }
34
+ // Otherwise open a new tab
35
+ if (clients.openWindow) return clients.openWindow(url)
36
+ })
37
+ )
38
+ })