@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,59 @@
1
+ /**
2
+ * long-press.js — fires a callback after a pointer is held still for THRESHOLD_MS.
3
+ *
4
+ * Cancels on movement beyond MOVE_TOLERANCE_PX or on pointer-up before threshold.
5
+ * Returns a cleanup function that removes all listeners.
6
+ */
7
+
8
+ const THRESHOLD_MS = 500
9
+ const MOVE_TOLERANCE_PX = 6
10
+
11
+ /**
12
+ * @param {Element} el
13
+ * @param {(e: TouchEvent|MouseEvent, el: Element) => void} onLongPress
14
+ * @returns {() => void} cleanup
15
+ */
16
+ export function addLongPress(el, onLongPress) {
17
+ let timer = null
18
+ let startX = 0
19
+ let startY = 0
20
+
21
+ function start(e) {
22
+ const pt = e.touches?.[0] ?? e
23
+ startX = pt.clientX
24
+ startY = pt.clientY
25
+ timer = setTimeout(() => {
26
+ timer = null
27
+ onLongPress(e, el)
28
+ }, THRESHOLD_MS)
29
+ }
30
+
31
+ function cancel() {
32
+ if (timer) { clearTimeout(timer); timer = null }
33
+ }
34
+
35
+ function move(e) {
36
+ const pt = e.touches?.[0] ?? e
37
+ const dx = Math.abs(pt.clientX - startX)
38
+ const dy = Math.abs(pt.clientY - startY)
39
+ if (dx > MOVE_TOLERANCE_PX || dy > MOVE_TOLERANCE_PX) cancel()
40
+ }
41
+
42
+ el.addEventListener('touchstart', start, { passive: true })
43
+ el.addEventListener('touchmove', move, { passive: true })
44
+ el.addEventListener('touchend', cancel)
45
+ el.addEventListener('touchcancel', cancel)
46
+ el.addEventListener('mousedown', start)
47
+ el.addEventListener('mousemove', move)
48
+ el.addEventListener('mouseup', cancel)
49
+
50
+ return () => {
51
+ el.removeEventListener('touchstart', start)
52
+ el.removeEventListener('touchmove', move)
53
+ el.removeEventListener('touchend', cancel)
54
+ el.removeEventListener('touchcancel', cancel)
55
+ el.removeEventListener('mousedown', start)
56
+ el.removeEventListener('mousemove', move)
57
+ el.removeEventListener('mouseup', cancel)
58
+ }
59
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * modal.js — singleton centered modal for desktop.
3
+ *
4
+ * Dismisses on backdrop click or Escape key.
5
+ */
6
+
7
+ let backdropEl = null
8
+ let modalEl = null
9
+
10
+ function ensureDOM() {
11
+ if (backdropEl) return
12
+
13
+ backdropEl = document.createElement('div')
14
+ backdropEl.className = 'modal-backdrop'
15
+ backdropEl.innerHTML = `
16
+ <div class="modal" role="dialog" aria-modal="true">
17
+ <div class="modal-header">
18
+ <span class="modal-title"></span>
19
+ <button class="modal-close" aria-label="Close">&times;</button>
20
+ </div>
21
+ <div class="modal-body"></div>
22
+ </div>
23
+ `
24
+ document.body.appendChild(backdropEl)
25
+ modalEl = backdropEl.querySelector('.modal')
26
+
27
+ backdropEl.addEventListener('click', e => {
28
+ if (e.target === backdropEl) dismiss()
29
+ })
30
+ backdropEl.querySelector('.modal-close').addEventListener('click', dismiss)
31
+ document.addEventListener('keydown', e => {
32
+ if (e.key === 'Escape') dismiss()
33
+ })
34
+ }
35
+
36
+ /**
37
+ * @param {{ title: string, build: (body: HTMLElement) => void }} opts
38
+ */
39
+ export function showModal({ title, build }) {
40
+ ensureDOM()
41
+ modalEl.querySelector('.modal-title').textContent = title
42
+ const body = modalEl.querySelector('.modal-body')
43
+ body.innerHTML = ''
44
+ build(body)
45
+ requestAnimationFrame(() => backdropEl.classList.add('open'))
46
+ }
47
+
48
+ export function dismiss() {
49
+ backdropEl?.classList.remove('open')
50
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * router.js — client-side SPA navigation for /channels/* links.
3
+ *
4
+ * Intercepts link clicks, fetches the new page, morphs only .chat-panel
5
+ * (data-* attributes + .messages content), then dispatches chatpanel:navigated
6
+ * so the existing call.js island can re-initialise its chat state without
7
+ * tearing down WebRTC connections.
8
+ */
9
+ import { patchSettings } from './settings-sync.js'
10
+
11
+ const BASE_PATH = window.__BASE_PATH__ ?? ''
12
+
13
+ let inFlight = false
14
+
15
+ export function initRouter() {
16
+ document.addEventListener('click', handleClick)
17
+ window.addEventListener('popstate', () => navigateTo(location.href, false))
18
+ }
19
+
20
+ function isChannelUrl(href) {
21
+ try {
22
+ const url = new URL(href, location.href)
23
+ return url.origin === location.origin && url.pathname.startsWith(`${BASE_PATH}/channels/`)
24
+ } catch { return false }
25
+ }
26
+
27
+ async function handleClick(e) {
28
+ if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
29
+ const link = e.target.closest('a[href]')
30
+ if (!link || !isChannelUrl(link.href)) return
31
+ e.preventDefault()
32
+ history.pushState({}, '', link.href)
33
+ await navigateTo(link.href, true)
34
+ }
35
+
36
+ export async function navigateTo(url, scroll) {
37
+ if (inFlight) return
38
+ inFlight = true
39
+ try {
40
+ const res = await fetch(url, { headers: { Accept: 'text/html' } })
41
+ if (!res.ok) { location.href = url; return }
42
+ const html = await res.text()
43
+
44
+ const next = new DOMParser().parseFromString(html, 'text/html')
45
+ const nextPanel = next.querySelector('.chat-panel')
46
+ const currPanel = document.querySelector('.chat-panel')
47
+ if (!nextPanel || !currPanel) { location.href = url; return }
48
+
49
+ // 1. Swap data-* attributes so the island can read the new channel's identity
50
+ for (const { name } of [...currPanel.attributes]) {
51
+ if (name.startsWith('data-')) currPanel.removeAttribute(name)
52
+ }
53
+ for (const { name, value } of [...nextPanel.attributes]) {
54
+ if (name.startsWith('data-')) currPanel.setAttribute(name, value)
55
+ }
56
+
57
+ // 2. Replace .messages content (seed articles + <template> from new page)
58
+ const currMessages = currPanel.querySelector('#messages')
59
+ const nextMessages = nextPanel.querySelector('#messages')
60
+ if (currMessages && nextMessages) {
61
+ currMessages.innerHTML = nextMessages.innerHTML
62
+ }
63
+
64
+ // 3. Notify the existing island — it will leave the old channel and join the new one
65
+ const d = currPanel.dataset
66
+ document.dispatchEvent(new CustomEvent('chatpanel:navigated', {
67
+ detail: {
68
+ channelId: d.id,
69
+ name: d.name,
70
+ topic: d.topic ?? '',
71
+ kind: d.kind ?? 'text',
72
+ seedSeq: parseInt(d.seedSeq ?? '0', 10),
73
+ seedFirstSeq: parseInt(d.seedFirstSeq ?? '0', 10),
74
+ seedHasMore: d.seedHasMore === 'true',
75
+ }
76
+ }))
77
+
78
+ // 4. Persist the new channel so PWA restores here on next launch
79
+ if (d.id) patchSettings({ last_channel_id: d.id, mobile_chat_open: true })
80
+
81
+ if (scroll && currMessages) currMessages.scrollTop = currMessages.scrollHeight
82
+ } catch {
83
+ location.href = url
84
+ } finally {
85
+ inFlight = false
86
+ }
87
+ }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * RtcPeerManager — WebRTC peer connection lifecycle.
3
+ *
4
+ * Manages RTCPeerConnection instances, negotiation serialisation,
5
+ * transceiver slot management, ICE candidate queuing, and inbound
6
+ * stream resolution. Completely decoupled from WebSocket message
7
+ * types, UI, and call state signals — all I/O is via callbacks.
8
+ *
9
+ * Patterns ported from v1 RtcCallService (significant debugging invested):
10
+ * - negotiationInFlight / negotiationQueued per-peer serialisation
11
+ * - Pre-allocated transceiver slots (1 audio + 2 video: cam + screen)
12
+ * - replaceTrack() + direction toggle rather than addTrack() for renegotiation
13
+ * - waitForStableSignaling() before every offer
14
+ * - ICE candidate queue (pendingIce) until remote description is set
15
+ * - New joiner is offerer toward all existing peers; existing peers are answerers
16
+ *
17
+ * @param {object} options
18
+ * @param {RTCIceServer[]} options.iceServers
19
+ * @param {() => { audio, video, screen }} options.getLocalStreams — reads current local MediaStreams from caller
20
+ * @param {object} options.handlers
21
+ * @param {(peerId, sdp) => void} options.handlers.onOffer — send rtc.offer via WS
22
+ * @param {(peerId, sdp) => void} options.handlers.onAnswer — send rtc.answer via WS
23
+ * @param {(peerId, candidate) => void} options.handlers.onIceCandidate — send rtc.ice via WS
24
+ * @param {(peerId, tileId, stream, label) => void} options.handlers.onTrack — render a remote video tile
25
+ * @param {(peerId, stream) => void} options.handlers.onAudio — ensure a remote audio element
26
+ * @param {(peerId) => void} options.handlers.onPeerClosed — remove tiles + audio for this peer
27
+ */
28
+ export class RtcPeerManager {
29
+ #peerActors = new Map() // peerId → { pc }
30
+ #displayNames = new Map() // peerId → display_name string
31
+ #remoteStreams = new Map() // peerId → Map<key, MediaStream>
32
+ #pendingIce = new Map() // peerId → RTCIceCandidate[]
33
+ #inFlight = new Set() // peerIds currently negotiating
34
+ #queued = new Set() // peerIds with a queued renegotiation
35
+
36
+ #iceServers
37
+ #getLocalStreams
38
+ #handlers
39
+
40
+ constructor({ iceServers, getLocalStreams, handlers }) {
41
+ this.#iceServers = iceServers ?? [{ urls: 'stun:stun.l.google.com:19302' }]
42
+ this.#getLocalStreams = getLocalStreams
43
+ this.#handlers = handlers
44
+ }
45
+
46
+ setIceServers(servers) {
47
+ this.#iceServers = servers
48
+ }
49
+
50
+ setDisplayName(peerId, name) {
51
+ if (name) this.#displayNames.set(peerId, name)
52
+ }
53
+
54
+ /** Iterate connected peer IDs — used by caller to renegotiate after local stream changes. */
55
+ peerIds() {
56
+ return this.#peerActors.keys()
57
+ }
58
+
59
+ /**
60
+ * Ensure a peer actor exists, creating a new RTCPeerConnection if needed.
61
+ * Safe to call for both offerer and answerer paths — does NOT pre-add
62
+ * transceiver slots (that only happens in the offerer negotiation path).
63
+ */
64
+ ensurePeer(peerId) {
65
+ if (this.#peerActors.has(peerId)) return this.#peerActors.get(peerId)
66
+
67
+ const pc = new RTCPeerConnection({ iceServers: this.#iceServers })
68
+
69
+ pc.onicecandidate = ({ candidate }) => {
70
+ if (candidate) this.#handlers.onIceCandidate(peerId, candidate)
71
+ }
72
+
73
+ pc.ontrack = (event) => {
74
+ // If our transceiver is sendonly the remote peer is recvonly — they are
75
+ // not sending into this slot (e.g. iPhone has no screen share).
76
+ // Skip to avoid rendering an empty video element.
77
+ if (event.transceiver?.direction === 'sendonly') return
78
+
79
+ const stream = this.#getOrCreateInboundStream(event, peerId)
80
+ if (!stream) return
81
+
82
+ // Audio-only stream → just ensure an <audio> element exists
83
+ if (stream.getVideoTracks().length === 0) {
84
+ this.#handlers.onAudio(peerId, stream)
85
+ return
86
+ }
87
+
88
+ // Video stream → render tile + ensure audio (video stream may carry audio track)
89
+ const tileId = `${peerId}-${event.transceiver?.mid ?? 'cam'}`
90
+ const label = this.#displayNames.get(peerId) ?? peerId
91
+ this.#handlers.onTrack(peerId, tileId, stream, label)
92
+ this.#handlers.onAudio(peerId, stream)
93
+ }
94
+
95
+ pc.onconnectionstatechange = () => {
96
+ if (['failed', 'closed'].includes(pc.connectionState)) {
97
+ this.closePeer(peerId)
98
+ }
99
+ }
100
+
101
+ const actor = { pc }
102
+ this.#peerActors.set(peerId, actor)
103
+ return actor
104
+ }
105
+
106
+ /** Close and remove a peer, then call onPeerClosed so caller can clean up UI. */
107
+ closePeer(peerId) {
108
+ const actor = this.#peerActors.get(peerId)
109
+ if (actor) { actor.pc.close(); this.#peerActors.delete(peerId) }
110
+ this.#remoteStreams.delete(peerId)
111
+ this.#pendingIce.delete(peerId)
112
+ this.#inFlight.delete(peerId)
113
+ this.#queued.delete(peerId)
114
+ this.#displayNames.delete(peerId)
115
+ this.#handlers.onPeerClosed(peerId)
116
+ }
117
+
118
+ /**
119
+ * Queue a negotiation for peerId, serialising concurrent attempts.
120
+ * Safe to call multiple times — excess calls queue behind the in-flight one.
121
+ */
122
+ async negotiate(peerId) {
123
+ if (this.#inFlight.has(peerId)) {
124
+ this.#queued.add(peerId)
125
+ return
126
+ }
127
+ this.#inFlight.add(peerId)
128
+ try {
129
+ do {
130
+ this.#queued.delete(peerId)
131
+ await this.#negotiateOnce(peerId)
132
+ } while (this.#queued.has(peerId))
133
+ } finally {
134
+ this.#inFlight.delete(peerId)
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Handle an incoming offer from a remote peer: set remote description,
140
+ * drain queued ICE, attach local tracks, create and send an answer.
141
+ */
142
+ async handleRemoteOffer(peerId, callId, sdp) {
143
+ const actor = this.ensurePeer(peerId)
144
+ const pc = actor.pc
145
+
146
+ await pc.setRemoteDescription({ type: 'offer', sdp })
147
+ await this.#drainIce(peerId, pc)
148
+ await this.#attachLocalTracks(pc)
149
+
150
+ const answer = await pc.createAnswer()
151
+ await pc.setLocalDescription(answer)
152
+ this.#handlers.onAnswer(peerId, answer.sdp)
153
+ }
154
+
155
+ /**
156
+ * Handle an incoming answer from a remote peer: set remote description
157
+ * and drain any queued ICE candidates.
158
+ */
159
+ async handleRemoteAnswer(peerId, sdp) {
160
+ const actor = this.#peerActors.get(peerId)
161
+ if (!actor) return
162
+ await actor.pc.setRemoteDescription({ type: 'answer', sdp })
163
+ await this.#drainIce(peerId, actor.pc)
164
+ }
165
+
166
+ /**
167
+ * Handle an incoming ICE candidate. Queues it if the remote description
168
+ * has not been set yet (race between offer/answer and ICE trickle).
169
+ */
170
+ async handleIceCandidate(peerId, candidate) {
171
+ if (!candidate) return
172
+ const actor = this.#peerActors.get(peerId)
173
+ if (!actor || !actor.pc.remoteDescription) {
174
+ if (!this.#pendingIce.has(peerId)) this.#pendingIce.set(peerId, [])
175
+ this.#pendingIce.get(peerId).push(candidate)
176
+ return
177
+ }
178
+ await actor.pc.addIceCandidate(candidate)
179
+ }
180
+
181
+ /**
182
+ * Replace a single track slot in all active peer connections without
183
+ * triggering a full renegotiation (used for device switching).
184
+ * @param {'audio'|'camera'|'screen'} slotName
185
+ * @param {MediaStreamTrack|null} track
186
+ */
187
+ async replaceTrack(slotName, track) {
188
+ for (const { pc } of this.#peerActors.values()) {
189
+ const slots = this.#getTransceiverSlots(pc)
190
+ const transceiver = slots[slotName]
191
+ if (transceiver?.sender) await transceiver.sender.replaceTrack(track)
192
+ }
193
+ }
194
+
195
+ /** Close all peer connections and clear all state. */
196
+ teardown() {
197
+ for (const [, actor] of this.#peerActors) actor.pc.close()
198
+ this.#peerActors.clear()
199
+ this.#displayNames.clear()
200
+ this.#remoteStreams.clear()
201
+ this.#pendingIce.clear()
202
+ this.#inFlight.clear()
203
+ this.#queued.clear()
204
+ }
205
+
206
+ // ── Private ──────────────────────────────────────────────────────────────────
207
+
208
+ async #negotiateOnce(peerId) {
209
+ const actor = this.#peerActors.get(peerId)
210
+ if (!actor) return
211
+ const pc = actor.pc
212
+
213
+ const stable = await this.#waitForStable(pc)
214
+ if (!stable) return // timed out — don't attempt offer in bad state
215
+
216
+ this.#ensureTransceiverSlots(pc)
217
+ await this.#attachLocalTracks(pc)
218
+
219
+ const offer = await pc.createOffer()
220
+ await pc.setLocalDescription(offer)
221
+ this.#handlers.onOffer(peerId, offer.sdp)
222
+ }
223
+
224
+ async #waitForStable(pc, timeoutMs = 3000) {
225
+ if (!pc || pc.signalingState === 'stable') return true
226
+ return new Promise(resolve => {
227
+ const timer = setTimeout(() => { cleanup(); resolve(false) }, timeoutMs)
228
+ const handler = () => {
229
+ if (pc.signalingState !== 'stable') return
230
+ cleanup(); resolve(true)
231
+ }
232
+ const cleanup = () => { clearTimeout(timer); pc.removeEventListener('signalingstatechange', handler) }
233
+ pc.addEventListener('signalingstatechange', handler)
234
+ })
235
+ }
236
+
237
+ // ── Transceiver slot management ────────────────────────────────────────────
238
+ // Pre-allocate 1 audio + 2 video transceivers (camera slot + screen slot).
239
+ // Using replaceTrack() + direction toggle avoids creating new m= sections
240
+ // on every track change, which prevents SDP renegotiation races.
241
+
242
+ #ensureTransceiverSlots(pc) {
243
+ // Ensure at least 1 audio transceiver
244
+ while (pc.getTransceivers().filter(t => {
245
+ const k = t.receiver?.track?.kind ?? t.sender?.track?.kind
246
+ return k === 'audio'
247
+ }).length < 1) {
248
+ pc.addTransceiver('audio', { direction: 'recvonly' })
249
+ }
250
+ // Ensure at least 2 video transceivers (camera + screen)
251
+ const videoCount = pc.getTransceivers().filter(t => {
252
+ const k = t.receiver?.track?.kind ?? t.sender?.track?.kind
253
+ return k === 'video'
254
+ }).length
255
+ let added = videoCount
256
+ while (added < 2) {
257
+ pc.addTransceiver('video', { direction: 'recvonly' })
258
+ added++
259
+ }
260
+ }
261
+
262
+ #getTransceiverSlots(pc) {
263
+ const all = pc.getTransceivers()
264
+ const audioSlot = all.find(t => (t.receiver?.track?.kind ?? t.sender?.track?.kind) === 'audio') ??
265
+ all.find(t => !t.receiver?.track && !t.sender?.track) ?? null
266
+ const videoSlots = all.filter(t => (t.receiver?.track?.kind ?? t.sender?.track?.kind) === 'video')
267
+ const byMid = all.filter(t => {
268
+ const k = t.receiver?.track?.kind ?? t.sender?.track?.kind
269
+ return k === 'video' || (!t.receiver?.track && !t.sender?.track)
270
+ })
271
+ return {
272
+ audio: audioSlot,
273
+ camera: videoSlots[0] ?? byMid[0] ?? null,
274
+ screen: videoSlots[1] ?? byMid[1] ?? null,
275
+ }
276
+ }
277
+
278
+ async #attachLocalTracks(pc) {
279
+ this.#ensureTransceiverSlots(pc)
280
+ const slots = this.#getTransceiverSlots(pc)
281
+ const { audio, video, screen } = this.#getLocalStreams()
282
+ await Promise.all([
283
+ this.#setTransceiverTrack(slots.audio, audio?.getAudioTracks()[0] ?? null),
284
+ this.#setTransceiverTrack(slots.camera, video?.getVideoTracks()[0] ?? null),
285
+ this.#setTransceiverTrack(slots.screen, screen?.getVideoTracks()[0] ?? null),
286
+ ])
287
+ }
288
+
289
+ async #setTransceiverTrack(transceiver, track) {
290
+ if (!transceiver?.sender) return
291
+ if (transceiver.sender.track?.id === track?.id) return
292
+ if (track) {
293
+ if (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive') {
294
+ transceiver.direction = 'sendrecv'
295
+ }
296
+ await transceiver.sender.replaceTrack(track)
297
+ } else {
298
+ await transceiver.sender.replaceTrack(null)
299
+ if (transceiver.direction === 'sendrecv' || transceiver.direction === 'sendonly') {
300
+ transceiver.direction = 'recvonly'
301
+ }
302
+ }
303
+ }
304
+
305
+ // ── Inbound stream resolution (v1 RtcInboundStream pattern) ────────────────
306
+ // Track events don't always carry an associated stream in all browsers.
307
+ // Index by transceiver mid first, then stream id, synthesise if needed.
308
+
309
+ #getOrCreateInboundStream(event, peerId) {
310
+ if (!this.#remoteStreams.has(peerId)) this.#remoteStreams.set(peerId, new Map())
311
+ const peerStreams = this.#remoteStreams.get(peerId)
312
+
313
+ const signaledStream = event.streams?.[0]
314
+ if (signaledStream) {
315
+ peerStreams.set(`stream:${signaledStream.id}`, signaledStream)
316
+ const mid = event.transceiver?.mid
317
+ if (mid != null) peerStreams.set(`mid:${mid}`, signaledStream)
318
+ return signaledStream
319
+ }
320
+
321
+ if (!event.track) return null
322
+ const mid = event.transceiver?.mid
323
+ const key = mid != null ? `mid:${mid}` : `track:${event.track.kind}:${event.track.id}`
324
+
325
+ let stream = peerStreams.get(key) ??
326
+ [...peerStreams.values()].find(s => s.getTracks().some(t => t.id === event.track.id)) ??
327
+ null
328
+ if (!stream) { stream = new MediaStream(); peerStreams.set(key, stream) }
329
+
330
+ // Replace any stale track of the same kind
331
+ stream.getTracks()
332
+ .filter(t => t.kind === event.track.kind && t.id !== event.track.id)
333
+ .forEach(t => { try { stream.removeTrack(t) } catch { /* ignore */ } })
334
+
335
+ if (!stream.getTracks().some(t => t.id === event.track.id)) stream.addTrack(event.track)
336
+ return stream
337
+ }
338
+
339
+ async #drainIce(peerId, pc) {
340
+ const pending = this.#pendingIce.get(peerId) ?? []
341
+ for (const c of pending) await pc.addIceCandidate(c)
342
+ this.#pendingIce.delete(peerId)
343
+ }
344
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * settings-sync.js — client-side settings: localStorage + background server sync.
3
+ *
4
+ * This module owns all localStorage reads/writes and server sync. Islands import
5
+ * from here — they never touch localStorage or the API directly.
6
+ *
7
+ * Storage shape: { settings: { last_channel_id, mobile_chat_open }, updated_at: number }
8
+ */
9
+
10
+ const STORAGE_KEY = 'devchitchat_settings'
11
+ const BASE_PATH = window.__BASE_PATH__ ?? ''
12
+
13
+ function readLocal() {
14
+ try {
15
+ return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
16
+ } catch {
17
+ return {}
18
+ }
19
+ }
20
+
21
+ function writeLocal(data) {
22
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
23
+ }
24
+
25
+ // Returns current settings object (instant, synchronous)
26
+ export function getSettings() {
27
+ return readLocal().settings ?? {}
28
+ }
29
+
30
+ // Writes one or more keys, updates local timestamp, queues server sync
31
+ export function patchSettings(patch) {
32
+ const local = readLocal()
33
+ const updated_at = Math.floor(Date.now() / 1000)
34
+ const settings = { ...(local.settings ?? {}), ...patch }
35
+ writeLocal({ settings, updated_at })
36
+ syncToServer(settings, updated_at) // fire-and-forget
37
+ }
38
+
39
+ // Push local state to server (fire-and-forget)
40
+ async function syncToServer(settings, updated_at) {
41
+ try {
42
+ await fetch(`${BASE_PATH}/api/user/settings`, {
43
+ method: 'PUT',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({ settings, updated_at }),
46
+ })
47
+ } catch {
48
+ // Network failure — local state is still correct, server syncs on next load
49
+ }
50
+ }
51
+
52
+ // Pull from server and reconcile. Call once on page load.
53
+ // Returns remote settings if the server had newer data, null otherwise.
54
+ export async function syncFromServer() {
55
+ try {
56
+ const res = await fetch(`${BASE_PATH}/api/user/settings`)
57
+ if (!res.ok) return null
58
+
59
+ const remote = await res.json() // { settings, updated_at }
60
+ const local = readLocal()
61
+ const localUpdatedAt = local.updated_at ?? 0
62
+
63
+ if (remote.updated_at > localUpdatedAt) {
64
+ // Server is newer — overwrite local
65
+ writeLocal({ settings: remote.settings, updated_at: remote.updated_at })
66
+ return remote.settings
67
+ } else if (localUpdatedAt > remote.updated_at) {
68
+ // Local is newer — push to server
69
+ syncToServer(local.settings, localUpdatedAt)
70
+ }
71
+ // Equal timestamps — no action needed
72
+ } catch {
73
+ // Network failure — proceed with local state
74
+ }
75
+ return null
76
+ }