@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,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">×</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
|
+
}
|