@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.
- package/README.md +313 -0
- package/index.js +148 -0
- package/migrate/001-drop-channel-invites.js +3 -0
- package/migrate/002-invite-initial-roles.js +5 -0
- package/migrate/003-dm-channels.js +35 -0
- package/migrate/004-notifications.js +15 -0
- package/migrate/005-uploads.js +21 -0
- package/migrate/006-mention-priority.js +3 -0
- package/migrate/007-push-subscriptions.js +14 -0
- package/migrate/008-messages-channel-seq-index.js +3 -0
- package/migrate/009-message-reactions.js +15 -0
- package/migrate/010-edit-messages.js +7 -0
- package/package.json +51 -0
- package/pages/_error.html +12 -0
- package/pages/_layout.html +31 -0
- package/pages/_layout.js +13 -0
- package/pages/admin/_layout.html +52 -0
- package/pages/admin/_layout.js +8 -0
- package/pages/admin/bots/[userId].js +88 -0
- package/pages/admin/bots/[userId].phtml +89 -0
- package/pages/admin/bots/index.js +41 -0
- package/pages/admin/bots/index.phtml +58 -0
- package/pages/admin/index.js +8 -0
- package/pages/admin/invites/index.js +72 -0
- package/pages/admin/invites/index.phtml +88 -0
- package/pages/admin/users/[userId].js +60 -0
- package/pages/admin/users/[userId].phtml +57 -0
- package/pages/admin/users/index.js +20 -0
- package/pages/admin/users/index.phtml +37 -0
- package/pages/api/uploads/index.js +66 -0
- package/pages/api/user/settings.js +26 -0
- package/pages/auth/signout.js +14 -0
- package/pages/channels/[channelId].js +99 -0
- package/pages/channels/[channelId].phtml +173 -0
- package/pages/index.js +33 -0
- package/pages/invite/[token].js +10 -0
- package/pages/login/index.js +57 -0
- package/pages/login/index.phtml +29 -0
- package/pages/public/client/action-sheet.js +77 -0
- package/pages/public/client/app.js +38 -0
- package/pages/public/client/auth-tabs.js +13 -0
- package/pages/public/client/emoji-data.js +197 -0
- package/pages/public/client/islands/call.js +1770 -0
- package/pages/public/client/islands/sidebar.js +1197 -0
- package/pages/public/client/long-press.js +59 -0
- package/pages/public/client/modal.js +50 -0
- package/pages/public/client/router.js +87 -0
- package/pages/public/client/rtc-peer-manager.js +344 -0
- package/pages/public/client/settings-sync.js +76 -0
- package/pages/public/client/shared/messages.js +147 -0
- package/pages/public/client/swipe-nav.js +98 -0
- package/pages/public/client/theme.js +27 -0
- package/pages/public/client/ws.js +71 -0
- package/pages/public/favicon.ico +0 -0
- package/pages/public/favicon.png +0 -0
- package/pages/public/icon.png +0 -0
- package/pages/public/manifest.json +11 -0
- package/pages/public/sw.js +38 -0
- package/pages/public/themes/base.css +1786 -0
- package/pages/public/themes/dark.css +22 -0
- package/pages/public/themes/forest.css +22 -0
- package/pages/public/themes/light.css +23 -0
- package/pages/public/themes/ocean.css +22 -0
- package/pages/public/themes/rose.css +22 -0
- package/pages/registration/index.js +35 -0
- package/pages/registration/index.phtml +38 -0
- package/pages/uploads/[uploadId]/[filename].js +45 -0
- package/src/adapters/InMemoryAuthRepository.js +74 -0
- package/src/adapters/InMemoryChannelRepository.js +138 -0
- package/src/adapters/InMemoryDeliveryRepository.js +52 -0
- package/src/adapters/InMemoryFileStore.js +53 -0
- package/src/adapters/InMemoryHubRepository.js +85 -0
- package/src/adapters/InMemoryMessageRepository.js +35 -0
- package/src/adapters/InMemoryReactionRepository.js +45 -0
- package/src/adapters/InMemorySearchRepository.js +37 -0
- package/src/adapters/InMemorySignalingRepository.js +35 -0
- package/src/adapters/InMemoryUploadRepository.js +36 -0
- package/src/adapters/InMemoryUserSettingsRepository.js +15 -0
- package/src/adapters/LocalFileStore.js +40 -0
- package/src/adapters/SqliteAuthRepository.js +184 -0
- package/src/adapters/SqliteChannelRepository.js +149 -0
- package/src/adapters/SqliteDeliveryRepository.js +53 -0
- package/src/adapters/SqliteHubRepository.js +99 -0
- package/src/adapters/SqliteMessageRepository.js +90 -0
- package/src/adapters/SqlitePushRepository.js +39 -0
- package/src/adapters/SqliteReactionRepository.js +50 -0
- package/src/adapters/SqliteSearchRepository.js +42 -0
- package/src/adapters/SqliteSignalingRepository.js +50 -0
- package/src/adapters/SqliteUploadRepository.js +34 -0
- package/src/adapters/SqliteUserSettingsRepository.js +23 -0
- package/src/adminAuth.js +25 -0
- package/src/config.js +11 -0
- package/src/context.js +77 -0
- package/src/core/dm.js +10 -0
- package/src/core/mentions.js +27 -0
- package/src/core/messages.js +21 -0
- package/src/core/reactions.js +6 -0
- package/src/core/roles.js +5 -0
- package/src/core/uploads.js +107 -0
- package/src/db/initDb.js +225 -0
- package/src/db/openDb.js +18 -0
- package/src/db/runMigrations.js +45 -0
- package/src/db/transaction.js +11 -0
- package/src/ports/IFileStore.js +34 -0
- package/src/services/AuthService.js +208 -0
- package/src/services/BotService.js +148 -0
- package/src/services/ChannelService.js +176 -0
- package/src/services/DeliveryService.js +28 -0
- package/src/services/HubService.js +133 -0
- package/src/services/MessageService.js +122 -0
- package/src/services/NotificationService.js +45 -0
- package/src/services/PresenceService.js +55 -0
- package/src/services/ReactionService.js +57 -0
- package/src/services/SearchService.js +21 -0
- package/src/services/SignalingService.js +177 -0
- package/src/services/UploadService.js +111 -0
- package/src/services/UserSettingsService.js +30 -0
- package/src/services/WebPushService.js +217 -0
- package/src/util/crypto.js +21 -0
- package/src/util/errors.js +14 -0
- package/src/util/ids.js +3 -0
- package/src/util/logger.js +21 -0
- package/src/ws/ChatServer.js +478 -0
- package/src/ws/handlers/authHandlers.js +152 -0
- package/src/ws/handlers/channelHandlers.js +166 -0
- package/src/ws/handlers/hubHandlers.js +82 -0
- package/src/ws/handlers/messageHandlers.js +88 -0
- package/src/ws/handlers/pushHandlers.js +25 -0
- package/src/ws/handlers/reactionHandlers.js +27 -0
- package/src/ws/handlers/rtcHandlers.js +126 -0
- 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
+
})
|