xmpp/xmpp.view.ts

2215 lines
81 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

namespace $.$$ {
type Xmpp_contact = {
jid: string
name: string
show: string
status: string
}
type Xmpp_message = {
id: string
from: string
to: string
body: string
time: number
nick?: string // sender nick for MUC groupchat messages
mam_id?: string // archive result id for MAM pagination
media_uri?: string
media_mime?: string
media_name?: string
media_kind?: Media_type
media_size?: number
media_hash?: string
media_hash_algo?: string
read?: boolean
}
type Xmpp_room = {
jid: string
name: string
nick: string // my nickname in this room
}
type Media_type = 'image' | 'audio' | 'link' | null
// $mol_db schema for persisted messages.
type Xmpp_db_schema = {
Messages: {
Key: [ string, string ] // [account_bare_jid, msg_id]
Doc: {
account: string
peer: string
id: string
from: string
to: string
body: string
time: number
nick?: string
mam_id?: string
media_uri?: string
media_mime?: string
media_name?: string
media_kind?: Media_type
media_size?: number
media_hash?: string
media_hash_algo?: string
read?: boolean
}
Indexes: {}
}
}
function media_type(url: string): Media_type {
const s = url.trim()
if (!/^https?:\/\/\S+$/.test(s)) return null
const path = s.split('?')[0].toLowerCase()
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|avif)$/.test(path)) return 'image'
if (/\.(mp3|ogg|wav|webm|opus|m4a|aac|flac)$/.test(path)) return 'audio'
return 'link'
}
function media_type_from_mime(mime: string): Media_type {
const s = mime.trim().toLowerCase()
if (s.startsWith('image/')) return 'image'
if (s.startsWith('audio/')) return 'audio'
return null
}
// ─── XMPP over WebSocket (RFC 7590) ──────────────────────────────────────
class Xmpp_conn {
private _ws: WebSocket | null = null
private _count = 0
private _jid = ''
private _pass = ''
private _domain = ''
private _state: 'open' | 'auth' | 'reopen' | 'bind' | 'ready' = 'open'
private _slots = new Map<string, (put: string, get: string) => void>()
private _mam_iqs = new Map<string, string>() // iq_id → with_jid
private _avatar_meta_iqs = new Map<string, (m: { id: string; mime: string } | null) => void>()
private _avatar_data_iqs = new Map<string, (b64: string | null) => void>()
upload_service = ''
on_ready: ((jid: string) => void) | null = null
on_bookmarks: ((rooms: Array<{ jid: string; name: string; nick: string; autojoin: boolean }>) => void) | null = null
on_roster: ((contacts: Xmpp_contact[]) => void) | null = null
on_message: ((msg: Xmpp_message) => void) | null = null
on_groupchat_message: ((msg: Xmpp_message) => void) | null = null
on_mam_message: ((msg: Xmpp_message) => void) | null = null
on_mam_fin: ((with_jid: string) => void) | null = null
on_markable: ((msg_id: string, from: string, type: 'chat' | 'groupchat') => void) | null = null
on_marker: ((kind: 'received' | 'displayed' | 'acknowledged', msg_id: string, from: string) => void) | null = null
on_avatar_meta: ((from: string, info: { id: string; mime: string } | null) => void) | null = null
on_presence: ((jid: string, show: string, status: string) => void) | null = null
on_muc_presence: ((room_jid: string, nick: string, real_bare?: string) => void) | null = null
on_error: ((err: string) => void) | null = null
on_room_error: ((err: string) => void) | null = null
on_close: (() => void) | null = null
private _id() { return `x${ ++this._count }` }
private _esc(s: string) {
return s
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
private _send(xml: string) { this._ws?.send(xml) }
private _stream_open() {
this._send(`<open xmlns="urn:ietf:params:xml:ns:xmpp-framing" to="${ this._esc(this._domain) }" version="1.0"/>`)
}
connect(url: string, jid: string, password: string) {
this._jid = jid
this._pass = password
this._domain = jid.split('@')[1] || jid
this._state = 'open'
this.upload_service = `upload.${ this._domain }`
const ws = new WebSocket(url, 'xmpp')
this._ws = ws
ws.onopen = () => this._stream_open()
ws.onmessage = e => this._handle(e.data as string)
ws.onerror = () => this.on_error?.('WebSocket connection failed')
ws.onclose = () => this.on_close?.()
}
disconnect() {
this._send('<close xmlns="urn:ietf:params:xml:ns:xmpp-framing"/>')
this._ws?.close()
this._ws = null
}
send_message(to: string, body: string, id?: string): string {
const msg_id = id ?? this._id()
this._send(
`<message to="${ this._esc(to) }" type="chat" id="${ msg_id }">` +
`<body>${ this._esc(body) }</body>` +
`<markable xmlns="urn:xmpp:chat-markers:0"/>` +
`</message>`
)
return msg_id
}
static async hash_file(file: File, algorithm = 'SHA-256'): Promise<string> {
const buffer = await file.arrayBuffer()
const digest = await crypto.subtle.digest(algorithm, buffer)
const bytes = new Uint8Array(digest)
let binary = ''
const chunk = 0x8000
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
}
return btoa(binary)
}
send_media_sharing(to: string, body: string, file_name: string, size: number, mime: string, hash_algo: string, hash_value: string, get_url: string, type: 'chat' | 'groupchat' = 'chat', id?: string): string {
const msg_id = id ?? this._id()
const begin = body.indexOf(file_name)
const end = begin >= 0 ? begin + file_name.length : body.length
this._send(
`<message to="${ this._esc(to) }" type="${ this._esc(type) }" id="${ msg_id }">` +
`<body>${ this._esc(body) }</body>` +
`<reference xmlns="urn:xmpp:reference:0" type="data" begin="${ begin >= 0 ? begin : 0 }" end="${ end }">` +
`<media-sharing xmlns="urn:xmpp:sims:1">` +
`<file xmlns="urn:xmpp:jingle:apps:file-transfer:5">` +
`<media-type>${ this._esc(mime) }</media-type>` +
`<name>${ this._esc(file_name) }</name>` +
`<size>${ size }</size>` +
`<hash xmlns="urn:xmpp:hashes:2" algo="${ this._esc(hash_algo) }">${ this._esc(hash_value) }</hash>` +
`</file>` +
`<sources>` +
`<reference xmlns="urn:xmpp:reference:0" type="data" uri="${ this._esc(get_url) }"/>` +
`</sources>` +
`</media-sharing>` +
`</reference>` +
`<markable xmlns="urn:xmpp:chat-markers:0"/>` +
`</message>`
)
return msg_id
}
// XEP-0333: send a chat marker (received/displayed/acknowledged) for a previously-received message id.
send_marker(to: string, kind: 'received' | 'displayed' | 'acknowledged', id: string, type: 'chat' | 'groupchat' = 'chat') {
console.log('[xmpp] → send_marker', { to, kind, id, type })
this._send(
`<message to="${ this._esc(to) }" type="${ type }">` +
`<${ kind } xmlns="urn:xmpp:chat-markers:0" id="${ this._esc(id) }"/>` +
`</message>`
)
}
// ── XEP-0084 User Avatars (over PEP/XEP-0163) ─────────────────────────
publish_avatar(hash: string, b64: string, mime: string, bytes: number) {
// 1. publish raw bytes
const data_id = this._id()
this._send(
`<iq type="set" id="${ data_id }">` +
`<pubsub xmlns="http://jabber.org/protocol/pubsub">` +
`<publish node="urn:xmpp:avatar:data">` +
`<item id="${ this._esc(hash) }">` +
`<data xmlns="urn:xmpp:avatar:data">${ b64 }</data>` +
`</item></publish></pubsub></iq>`
)
// 2. publish metadata pointer
const meta_id = this._id()
this._send(
`<iq type="set" id="${ meta_id }">` +
`<pubsub xmlns="http://jabber.org/protocol/pubsub">` +
`<publish node="urn:xmpp:avatar:metadata">` +
`<item id="${ this._esc(hash) }">` +
`<metadata xmlns="urn:xmpp:avatar:metadata">` +
`<info bytes="${ bytes }" type="${ this._esc(mime) }" id="${ this._esc(hash) }"/>` +
`</metadata></item></publish></pubsub></iq>`
)
}
fetch_avatar_metadata(jid: string): Promise<{ id: string; mime: string } | null> {
return new Promise(resolve => {
const id = this._id()
this._avatar_meta_iqs.set(id, resolve)
setTimeout(() => { if (this._avatar_meta_iqs.delete(id)) resolve(null) }, 10_000)
this._send(
`<iq type="get" to="${ this._esc(jid) }" id="${ id }">` +
`<pubsub xmlns="http://jabber.org/protocol/pubsub">` +
`<items node="urn:xmpp:avatar:metadata"><max>1</max></items>` +
`</pubsub></iq>`
)
})
}
fetch_avatar_data(jid: string, hash: string): Promise<string | null> {
return new Promise(resolve => {
const id = this._id()
this._avatar_data_iqs.set(id, resolve)
setTimeout(() => { if (this._avatar_data_iqs.delete(id)) resolve(null) }, 15_000)
this._send(
`<iq type="get" to="${ this._esc(jid) }" id="${ id }">` +
`<pubsub xmlns="http://jabber.org/protocol/pubsub">` +
`<items node="urn:xmpp:avatar:data">` +
`<item id="${ this._esc(hash) }"/>` +
`</items></pubsub></iq>`
)
})
}
private _find(el: Element, ns: string, tag: string): Element | null {
return el.getElementsByTagNameNS(ns, tag)[0] || null;
}
private _text(el: Element, ns: string, tag: string): string {
const found = this._find(el, ns, tag);
return found?.textContent ?? '';
}
private _getBody(el: Element): string | null {
let body = this._find(el, 'jabber:client', 'body');
if (!body) body = this._find(el, '', 'body'); // fallback
return body?.textContent ?? null;
}
// ── XEP-0045: Multi-User Chat ─────────────────────────────────────────
join_room(room_jid: string, nick: string) {
const to = `${ this._esc(room_jid) }/${ this._esc(nick) }`
this._send(
`<presence to="${ to }">` +
`<x xmlns="http://jabber.org/protocol/muc"/>` +
`</presence>`
)
}
leave_room(room_jid: string, nick: string) {
this._send(`<presence to="${ this._esc(room_jid) }/${ this._esc(nick) }" type="unavailable"/>`)
}
send_groupchat(room_jid: string, body: string, id?: string): string {
const msg_id = id ?? this._id()
this._send(
`<message to="${ this._esc(room_jid) }" type="groupchat" id="${ msg_id }">` +
`<body>${ this._esc(body) }</body>` +
`<markable xmlns="urn:xmpp:chat-markers:0"/>` +
`</message>`
)
return msg_id
}
// XEP-0363: request an HTTP upload slot
request_slot(filename: string, size: number, mime: string): Promise<{ put: string; get: string }> {
return new Promise((resolve, reject) => {
const id = this._id()
this._slots.set(id, (put, get) => resolve({ put, get }))
setTimeout(() => { if (this._slots.delete(id)) reject(new Error('Upload slot timeout')) }, 15_000)
this._send(
`<iq to="${ this._esc(this.upload_service) }" type="get" id="${ id }">` +
`<request xmlns="urn:xmpp:http:upload:0"` +
` filename="${ this._esc(filename) }"` +
` size="${ size }"` +
` content-type="${ this._esc(mime) }"/>` +
`</iq>`
)
})
}
static async upload(put_url: string, file: File): Promise<void> {
const resp = await fetch(put_url, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } })
if (!resp.ok) throw new Error(`Upload failed: HTTP ${ resp.status }`)
}
// XEP-0313 for MUC: query sent TO the room, no "with" filter, optional end-timestamp to avoid duplicating join-history
request_mam_room(room_jid: string, max = 50, before_id?: string, before_time?: number): void {
if (!this._ws) return
room_jid = room_jid.split('/')[0]
const qid = this._id()
const id = this._id()
this._mam_iqs.set(id, room_jid)
const time_filter = before_time !== undefined
? `<x xmlns="jabber:x:data" type="submit">` +
`<field var="FORM_TYPE" type="hidden"><value>urn:xmpp:mam:2</value></field>` +
`<field var="end"><value>${ this._esc(new Date(before_time - 1).toISOString()) }</value></field>` +
`</x>`
: ''
const before = before_id ? `<before>${ this._esc(before_id) }</before>` : ``
this._send(
`<iq to="${ this._esc(room_jid) }" type="set" id="${ id }">` +
`<query xmlns="urn:xmpp:mam:2" queryid="${ qid }">` +
time_filter +
`<set xmlns="http://jabber.org/protocol/rsm">` +
`<max>${ max }</max>${ before }` +
`</set>` +
`</query></iq>`
)
}
// XEP-0313: request last `max` messages with a given peer; pass `before_id` for pagination
request_mam(with_jid: string, max = 50, before_id?: string): void {
if (!this._ws) return
with_jid = with_jid.split('/')[0]
const qid = this._id()
const id = this._id()
this._mam_iqs.set(id, with_jid)
const before = before_id ? `<before>${ this._esc(before_id) }</before>` : ``
this._send(
`<iq type="set" id="${ id }">` +
`<query xmlns="urn:xmpp:mam:2" queryid="${ qid }">` +
`<x xmlns="jabber:x:data" type="submit">` +
`<field var="FORM_TYPE" type="hidden"><value>urn:xmpp:mam:2</value></field>` +
`<field var="with"><value>${ this._esc(with_jid) }</value></field>` +
`</x>` +
`<set xmlns="http://jabber.org/protocol/rsm">` +
`<max>${ max }</max>${ before }` +
`</set>` +
`</query></iq>`
)
}
private _parse(data: string) {
return new DOMParser().parseFromString(data, 'text/xml')
}
private _parse_media_sharing(el: Element) {
const reference = el.getElementsByTagNameNS('urn:xmpp:reference:0', 'reference')[0]
if (!reference || reference.getAttribute('type') !== 'data') return null
const media = reference.getElementsByTagNameNS('urn:xmpp:sims:1', 'media-sharing')[0]
if (!media) return null
const file = media.getElementsByTagNameNS('urn:xmpp:jingle:apps:file-transfer:5', 'file')[0]
if (!file) return null
const name = file.getElementsByTagNameNS('urn:xmpp:jingle:apps:file-transfer:5', 'name')[0]?.textContent || undefined
const mime = file.getElementsByTagNameNS('urn:xmpp:jingle:apps:file-transfer:5', 'media-type')[0]?.textContent || undefined
const sizeText = file.getElementsByTagNameNS('urn:xmpp:jingle:apps:file-transfer:5', 'size')[0]?.textContent
const hashElem = file.getElementsByTagNameNS('urn:xmpp:hashes:2', 'hash')[0]
const sourceRef = Array.from(media.getElementsByTagNameNS('urn:xmpp:reference:0', 'reference'))
.find(r => r.getAttribute('type') === 'data')
const uri = sourceRef?.getAttribute('uri') || undefined
const size = sizeText ? Number(sizeText) : undefined
const hash = hashElem?.textContent || undefined
const hash_algo = hashElem?.getAttribute('algo') || undefined
const kind = mime ? media_type_from_mime(mime) : uri ? media_type(uri) : null
return { media_uri: uri, media_mime: mime, media_name: name, media_kind: kind, media_size: size, media_hash: hash, media_hash_algo: hash_algo }
}
private _handle(data: string) {
const doc = this._parse(data)
const root = doc.documentElement
if (!root) return
const tag = root.localName
const ns = root.namespaceURI || ''
if (tag === 'open') return
if (tag === 'features') { this._handle_features(root); return }
if (tag === 'success' && ns.includes('xmpp-sasl')) { this._state = 'reopen'; this._stream_open(); return }
if (tag === 'failure') { this.on_error?.('Authentication failed — check JID and password'); return }
if (tag === 'iq') { this._handle_iq(root); return }
if (tag === 'message') { this._handle_message(root); return }
if (tag === 'presence'){ this._handle_presence(root); return }
}
private _handle_features(el: Element) {
if (this._state === 'open') {
const mechs = el.querySelector('mechanisms')
if (!mechs) { this.on_error?.('No SASL mechanisms offered'); return }
const ok = Array.from(mechs.querySelectorAll('mechanism')).some(m => m.textContent?.trim() === 'PLAIN')
if (!ok) { this.on_error?.('PLAIN auth not available'); return }
const user = this._jid.split('@')[0]
const encoded = btoa(unescape(encodeURIComponent(`\0${ user }\0${ this._pass }`)))
this._send(`<auth xmlns="urn:ietf:params:xml:ns:xmpp-sasl" mechanism="PLAIN">${ encoded }</auth>`)
this._state = 'auth'
} else if (this._state === 'reopen') {
const id = this._id()
this._send(`<iq type="set" id="${ id }"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><resource>mol</resource></bind></iq>`)
this._state = 'bind'
}
}
private _handle_iq(el: Element) {
const id = el.getAttribute('id') || ''
if (this._avatar_meta_iqs.has(id) || this._avatar_data_iqs.has(id)) {
try { console.log('[xmpp] ← avatar iq', new XMLSerializer().serializeToString(el)) } catch {}
}
// bind result
const jid_el = el.querySelector('bind > jid')
if (jid_el?.textContent) {
this._jid = jid_el.textContent
this._state = 'ready'
// XEP-0280: enable message carbons so messages from other users always reach this resource
this._send(`<iq type="set" id="${ this._id() }"><enable xmlns="urn:xmpp:carbons:2"/></iq>`)
this._send(`<iq type="get" id="${ this._id() }"><query xmlns="jabber:iq:roster"/></iq>`)
this._send('<presence/>')
this._send(`<iq type="get" id="${ this._id() }"><query xmlns="jabber:iq:private"><storage xmlns="storage:bookmarks"/></query></iq>`)
this._send(`<iq to="${ this._esc(this._domain) }" type="get" id="${ this._id() }"><query xmlns="http://jabber.org/protocol/disco#items"/></iq>`)
this.on_ready?.(this._jid)
return
}
// roster
const roster_q = el.querySelector('query')
if (roster_q?.namespaceURI === 'jabber:iq:roster') {
const contacts: Xmpp_contact[] = Array.from(roster_q.querySelectorAll('item'))
.map(item => ({ jid: item.getAttribute('jid') || '', name: item.getAttribute('name') || item.getAttribute('jid') || '', show: '', status: '' }))
.filter(c => c.jid)
this.on_roster?.(contacts)
return
}
// XEP-0363 upload slot
const slot = el.querySelector('slot')
if (slot) {
const put = slot.querySelector('put')?.getAttribute('url') || ''
const get = slot.querySelector('get')?.getAttribute('url') || ''
this._slots.get(id)?.(put, get)
this._slots.delete(id)
return
}
// XEP-0048 bookmarks
const priv = el.querySelector('query')
if (priv?.namespaceURI === 'jabber:iq:private') {
const storage = priv.querySelector('storage')
if (storage?.namespaceURI === 'storage:bookmarks') {
const rooms = Array.from(storage.querySelectorAll('conference'))
.map(c => ({
jid: c.getAttribute('jid') || '',
name: c.getAttribute('name') || '',
nick: c.querySelector('nick')?.textContent || '',
autojoin: c.getAttribute('autojoin') === 'true' || c.getAttribute('autojoin') === '1',
}))
.filter(r => r.jid)
this.on_bookmarks?.(rooms)
}
return
}
// XEP-0084 avatar metadata IQ result
const avatar_meta_cb = this._avatar_meta_iqs.get(id)
if (avatar_meta_cb) {
this._avatar_meta_iqs.delete(id)
const ps = el.querySelector('pubsub')
const items = ps?.querySelector('items')
if (items?.getAttribute('node') === 'urn:xmpp:avatar:metadata') {
const info = items.querySelector('info')
if (info && info.getAttribute('id')) {
avatar_meta_cb({
id: info.getAttribute('id') || '',
mime: info.getAttribute('type') || 'image/png',
})
return
}
}
avatar_meta_cb(null)
return
}
// XEP-0084 avatar data IQ result
const avatar_data_cb = this._avatar_data_iqs.get(id)
if (avatar_data_cb) {
this._avatar_data_iqs.delete(id)
const ps = el.querySelector('pubsub')
const items = ps?.querySelector('items')
if (items?.getAttribute('node') === 'urn:xmpp:avatar:data') {
const data = items.querySelector('data')?.textContent
avatar_data_cb(data ? data.replace(/\s+/g, '') : null)
return
}
avatar_data_cb(null)
return
}
// XEP-0313 fin — MAM query complete
const fin = el.querySelector('fin')
if (fin?.namespaceURI === 'urn:xmpp:mam:2') {
const with_jid = this._mam_iqs.get(id)
if (with_jid) {
this._mam_iqs.delete(id)
this.on_mam_fin?.(with_jid)
}
return
}
// disco#items → query each item for disco#info
const di = el.querySelector('query[xmlns="http://jabber.org/protocol/disco#items"]')
if (di) {
Array.from(di.querySelectorAll('item')).forEach(item => {
const jid = item.getAttribute('jid')
if (jid) this._send(`<iq to="${ this._esc(jid) }" type="get" id="${ this._id() }"><query xmlns="http://jabber.org/protocol/disco#info"/></iq>`)
})
return
}
// disco#info → detect upload service
const dinfo = el.querySelector('query[xmlns="http://jabber.org/protocol/disco#info"]')
if (dinfo) {
const has = Array.from(dinfo.querySelectorAll('feature')).some(f => f.getAttribute('var') === 'urn:xmpp:http:upload:0')
if (has) this.upload_service = el.getAttribute('from') || this.upload_service
}
}
private _handle_message(el: Element) {
// XEP-0280 Message Carbons — messages from other users delivered to this resource
const carbon = this._find(el, 'urn:xmpp:carbons:2', 'received')
|| this._find(el, 'urn:xmpp:carbons:2', 'sent');
if (carbon) {
const fwd = this._find(carbon, 'urn:xmpp:forward:0', 'forwarded');
if (fwd) {
const inner = this._find(fwd, 'jabber:client', 'message')
|| this._find(fwd, '', 'message');
if (inner) this._handle_message(inner);
}
return;
}
// XEP-0313 MAM result (archive history)
const mam = this._find(el, 'urn:xmpp:mam:2', 'result');
if (mam) { this._handle_mam(mam); return; }
// XEP-0163 PEP push: avatar metadata updates arrive as <event> messages
const pep_event = this._find(el, 'http://jabber.org/protocol/pubsub#event', 'event')
if (pep_event) {
const items = pep_event.querySelector('items')
if (items?.getAttribute('node') === 'urn:xmpp:avatar:metadata') {
const from = (el.getAttribute('from') || '').split('/')[0]
const info = items.querySelector('info')
if (from) {
if (info && info.getAttribute('id')) {
this.on_avatar_meta?.(from, {
id: info.getAttribute('id') || '',
mime: info.getAttribute('type') || 'image/png',
})
} else {
this.on_avatar_meta?.(from, null)
}
}
}
return
}
// XEP-0333 chat markers — these messages may have no body
const cm_ns = 'urn:xmpp:chat-markers:0'
for (const kind of ['received', 'displayed', 'acknowledged'] as const) {
const marker = this._find(el, cm_ns, kind)
if (marker) {
const mid = marker.getAttribute('id')
const mfrom = (el.getAttribute('from') || '').split('/')[0]
console.log('[xmpp] ← marker', { kind, mid, mfrom })
if (mid && mfrom) this.on_marker?.(kind, mid, mfrom)
return
}
}
const type = el.getAttribute('type') || 'chat';
let body = this._getBody(el);
const media = this._parse_media_sharing(el)
if (!body) {
if (media?.media_name) body = `Shared file: ${ media.media_name }`
else if (media?.media_uri) body = media.media_uri
}
if (!body) return;
// XEP-0333: peer requested chat markers for this message
if (this._find(el, cm_ns, 'markable')) {
const mid = el.getAttribute('id')
const mfrom = el.getAttribute('from') || ''
console.log('[xmpp] ← markable', { mid, mfrom, type })
if (mid && mfrom) this.on_markable?.(mid, mfrom, type === 'groupchat' ? 'groupchat' : 'chat')
}
// delay
const delay = this._find(el, 'urn:xmpp:delay', 'delay');
const stamp = delay?.getAttribute('stamp');
const time = stamp ? new Date(stamp).getTime() : Date.now();
if (type === 'groupchat') {
const from_full = el.getAttribute('from') || '';
const slash = from_full.indexOf('/');
if (slash < 0) return;
const room_jid = from_full.slice(0, slash);
const nick = from_full.slice(slash + 1);
this.on_groupchat_message?.({
id: el.getAttribute('id') || this._id(),
from: room_jid,
to: (el.getAttribute('to') || '').split('/')[0],
body, time, nick,
...(media || {}),
});
return;
}
if (type !== 'chat' && type !== 'normal') return;
this.on_message?.({
id: el.getAttribute('id') || this._id(),
from: (el.getAttribute('from') || '').split('/')[0],
to: (el.getAttribute('to') || '').split('/')[0],
body, time,
...(media || {}),
});
}
private _handle_mam(result: Element) {
const fwd = result.querySelector('forwarded')
if (!fwd) return
const msg = fwd.querySelector('message')
if (!msg) return
let body = msg.querySelector('body')?.textContent
const media = this._parse_media_sharing(msg)
if (!body) {
if (media?.media_name) body = `Shared file: ${ media.media_name }`
else if (media?.media_uri) body = media.media_uri
}
if (!body) return
const type = msg.getAttribute('type') || 'chat'
if (type !== 'chat' && type !== 'normal' && type !== 'groupchat') return
const delay = fwd.querySelector('delay')
const stamp = delay?.getAttribute('stamp')
const time = stamp ? new Date(stamp).getTime() : Date.now()
const from_full = msg.getAttribute('from') || ''
const slash = from_full.indexOf('/')
const from = slash >= 0 ? from_full.slice(0, slash) : from_full
// Only groupchat carries a meaningful nick after the slash; for 1:1 the resource is just a device id.
const nick = type === 'groupchat' && slash >= 0 ? from_full.slice(slash + 1) : undefined
const stanza_id = msg.getAttribute('id') || result.getAttribute('id') || this._id()
const mam_id = result.getAttribute('id') || stanza_id
this.on_mam_message?.({
id: stanza_id,
from,
to: (msg.getAttribute('to') || '').split('/')[0],
body, time,
mam_id,
...(nick !== undefined ? { nick } : {}),
...(media || {}),
})
}
private _handle_presence(el: Element) {
const from_full = el.getAttribute('from') || ''
const type = el.getAttribute('type') || ''
// MUC error (e.g. join denied) — don't disconnect, just report
if (type === 'error') {
const err = el.querySelector('error')
const msg = err?.firstElementChild?.localName ?? 'unknown error'
this.on_room_error?.(`Room error: ${ msg }`)
return
}
// MUC room presence — from contains resource (nick)
const slash = from_full.indexOf('/')
if (slash >= 0) {
const room_jid = from_full.slice(0, slash)
const nick = from_full.slice(slash + 1)
// Extract real JID from MUC presence
const muc_x = el.querySelector('x[xmlns="http://jabber.org/protocol/muc#user"]')
const item = muc_x?.querySelector('item')
const real_jid_full = item?.getAttribute('jid') || ''
const real_bare = real_jid_full.split('/')[0]
this.on_muc_presence?.(room_jid, nick, real_bare || undefined)
return
}
const from = from_full || ''
if (!from) return
const show = type === 'unavailable' ? 'offline' : el.querySelector('show')?.textContent || 'online'
this.on_presence?.(from, show, el.querySelector('status')?.textContent || '')
}
}
// ─── $xmpp component ─────────────────────────────────────────────────────
export class $xmpp extends $.$xmpp {
private _conn: Xmpp_conn | null = null
private _msg_by_id = new Map<string, Xmpp_message>()
private _msgs: Xmpp_message[] = []
private _rec = new Map<string, { recorder: MediaRecorder; chunks: Blob[]; stream: MediaStream }>()
private _history_loaded = new Set<string>()
private _mam_oldest = new Map<string, string>() // jid → oldest MAM result id
private _mam_oldest_time = new Map<string, number>() // jid → oldest MAM result time
private _loading_more = new Set<string>()
private _fin_count = new Map<string, number>()
private _scroll_setup = new Set<string>() // scroll listener installed
private _rooms = new Map<string, Xmpp_room>()
private _room_occupants = new Map<string, Map<string, string>>() // room_jid → nick → real_bare_jid
private _oldest_time = new Map<string, number>() // room_jid → oldest msg timestamp
private _poll_timer: number | null = null
private _poll_count: number = 0
private _ver = 0
private _toast_container: HTMLDivElement | null = null
private _audio_ctx: AudioContext | null = null
// XEP-0333 chat markers
private _markers = new Map<string, 'received' | 'displayed' | 'acknowledged'>() // msg id → highest received marker
private _last_displayed_sent = new Map<string, number>() // peer/room jid → time of last msg we marked displayed
// XEP-0084 avatars
private _avatars = new Map<string, string>() // bare jid в†’ data: URI
private _avatar_loading = new Set<string>()
// Suppress notifications during the first 5s after a fresh connection
// — covers servers that replay offline messages without proper <delay>.
private _connect_at = 0
// IndexedDB for persistent message history (via $mol_db).
private _db: $mol_db_database<Xmpp_db_schema> | null = null
private _db_init: Promise<$mol_db_database<Xmpp_db_schema>> | null = null
private _loading_persisted = false
private _auto_connect_tried = false
@ $mol_mem
status(next?: 'disconnected' | 'connecting' | 'connected') { return next ?? 'disconnected' }
@ $mol_mem
my_jid(next?: string) { return next ?? '' }
// Persisted in localStorage so the user doesn't have to re-enter on reload.
server(next?: string) {
return (this.$.$mol_state_local.value('xmpp_server', next) as string | null) ?? ''
}
jid(next?: string) {
return (this.$.$mol_state_local.value('xmpp_jid', next) as string | null) ?? ''
}
password(next?: string) {
return (this.$.$mol_state_local.value('xmpp_password', next) as string | null) ?? ''
}
@ $mol_mem
error_text(next?: string) { return next ?? '' }
@ $mol_mem
contacts(next?: Xmpp_contact[]) { return next ?? [] }
// Version counter — incremented on every new message to trigger reactive re-renders.
// Necessary because _msgs is a plain (non-reactive) array mutated in WebSocket callbacks.
@ $mol_mem
messages_ver(next?: number) { return next ?? 0 }
// Bumped whenever a chat-marker arrives so msg_status() re-renders.
@ $mol_mem
marker_ver(next?: number) { return next ?? 0 }
// Bumped whenever an avatar is loaded/replaced so avatar_uri() re-renders.
@ $mol_mem
avatar_ver(next?: number) { return next ?? 0 }
@ $mol_mem_key
avatar_uri(jid: string) {
this.avatar_ver()
return this._avatars.get(jid) ?? ''
}
// Stable SVG fallback: colored circle with first letter, hue derived from a hash of the seed.
private _default_avatar(seed: string, label?: string): string {
if (!seed) return ''
const text = ((label || seed.split('@')[0] || seed).trim().slice(0, 1) || '?').toUpperCase()
const safe = text.replace(/[<>&"']/g, '')
let h = 0
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0
const hue = Math.abs(h) % 360
const bg = `hsl(${ hue },55%,50%)`
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64'>` +
`<rect width='64' height='64' fill='${ bg }'/>` +
`<text x='50%' y='54%' font-family='system-ui,sans-serif' font-size='34' font-weight='600' fill='white' text-anchor='middle' dominant-baseline='middle'>${ safe }</text>` +
`</svg>`
return `data:image/svg+xml,${ encodeURIComponent(svg) }`
}
my_avatar_uri() {
const jid = this.my_jid().split('/')[0]
return this.avatar_uri(jid) || this._default_avatar(jid)
}
contact_avatar_uri(jid: string) {
const room = this._rooms.get(jid)
const avatar = this.avatar_uri(jid)
if (avatar) return avatar
if (room) return this._default_avatar(jid, room.name || jid.split('@')[0])
return this._default_avatar(jid)
}
chat_avatar_uri(jid: string) { return this.contact_avatar_uri(jid) }
@ $mol_mem_key
msg_avatar_uri(id: string) {
const msg = this._msg_by_id.get(id)
if (!msg) return ''
if (msg.nick !== undefined) {
const room = this._rooms.get(msg.from)
if (room?.nick === msg.nick) return this.my_avatar_uri()
// Try to get real JID for the nick
const occupants = this._room_occupants.get(msg.from)
const real_bare = occupants?.get(msg.nick)
if (real_bare) {
const avatar = this.avatar_uri(real_bare)
if (avatar) return avatar
}
return this._default_avatar(`${ msg.from }/${ msg.nick }`, msg.nick)
}
return this.avatar_uri(msg.from) || this._default_avatar(msg.from)
}
@ $mol_mem
rooms(next?: Xmpp_room[]) { return next ?? [] }
@ $mol_mem
room_jid(next?: string) { return next ?? '' }
@ $mol_mem
room_nick(next?: string) { return next ?? '' }
@ $mol_mem
new_chat_jid(next?: string) { return next ?? '' }
@ $mol_mem
search_query(next?: string) { return next ?? '' }
@ $mol_mem_key
compose(_jid: string, next?: string) { return next ?? '' }
@ $mol_mem_key
recording(_jid: string, next?: boolean) { return next ?? false }
// в”Ђв”Ђ Panes (Telegram-like 3-column layout) в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
panes() {
this._apply_theme()
if (this.status() !== 'connected') {
this._maybe_auto_connect()
return [this.Login_pane()]
}
const list: $mol_view[] = [this.Folders_pane(), this.Roster_pane(), this.Chat_pane()]
if (this.settings_open()) list.push(this.Settings_modal())
return list
}
@ $mol_mem
settings_open(next?: boolean) { return next ?? false }
do_open_settings() { this.settings_open(true) }
do_close_settings() { this.settings_open(false) }
// Per-chat reactive flag — true when scroll is near the bottom of Messages_list.
@ $mol_mem_key
scroll_at_bottom(_jid: string, next?: boolean) { return next ?? true }
scroll_down_hidden(jid: string): string | null {
return this.scroll_at_bottom(jid) ? 'hidden' : null
}
do_scroll_down(jid: string) {
this._scroll_to_bottom(jid)
}
// ── Notifications mode ────────────────────────────────────────────────
notif_mode(next?: 'inapp' | 'system') {
return (this.$.$mol_state_local.value('xmpp_notif_mode', next) as 'inapp' | 'system' | null) ?? 'inapp'
}
notif_inapp_active() { return this.notif_mode() === 'inapp' }
notif_system_active() { return this.notif_mode() === 'system' }
do_set_notif_inapp() { this.notif_mode('inapp') }
do_set_notif_system() {
this.notif_mode('system')
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
void Notification.requestPermission()
}
}
// ── Notification sound ────────────────────────────────────────────────
notif_sound_uri(next?: string | null) {
return (this.$.$mol_state_local.value('xmpp_notif_sound', next) as string | null) ?? ''
}
sound_status_text() {
return this.notif_sound_uri() ? '✓ Custom sound loaded' : 'Default beep'
}
do_pick_sound() {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'audio/*'
input.onchange = () => {
const file = input.files?.[0]
if (!file) return
if (file.size > 1_000_000) {
this.error_text('Sound file too large (max 1 MB)')
return
}
const reader = new FileReader()
reader.onload = () => {
this.notif_sound_uri(String(reader.result))
}
reader.readAsDataURL(file)
}
input.click()
}
do_test_sound() { this._beep() }
do_clear_sound() { this.notif_sound_uri(null) }
// ── Theme ─────────────────────────────────────────────────────────────
theme_preset(next?: string | null) {
return (this.$.$mol_state_local.value('xmpp_theme_preset', next) as string | null) ?? 'default'
}
theme_hue(next?: number | null) {
return (this.$.$mol_state_local.value('xmpp_theme_hue', next) as number | null) ?? 240
}
// Built-in presets keyed by hue (deg).
private _theme_presets(): Record<string, { hue: number; label: string }> {
return {
default: { hue: 240, label: 'Default' },
ocean: { hue: 200, label: 'Ocean' },
forest: { hue: 130, label: 'Forest' },
sunset: { hue: 20, label: 'Sunset' },
cherry: { hue: 350, label: 'Cherry' },
violet: { hue: 290, label: 'Violet' },
}
}
theme_preset_views() {
const presets = this._theme_presets()
return Object.keys(presets).map(name => this.Theme_preset(name))
}
theme_preset_label(name: string) { return this._theme_presets()[name]?.label ?? name }
theme_preset_active(name: string) { return this.theme_preset() === name }
do_apply_preset(name: string) {
this.theme_preset(name)
const p = this._theme_presets()[name]
if (p) this.theme_hue(p.hue)
}
// Bridge between HSL hue and the <input type="color"> hex value.
theme_color(next?: string) {
if (next === undefined) {
return this._hsl_to_hex(this.theme_hue(), 60, 50)
}
const hue = this._hex_to_hue(next)
this.theme_preset('custom')
this.theme_hue(hue)
return next
}
private _hsl_to_hex(h: number, s: number, l: number): string {
s /= 100; l /= 100
const k = (n: number) => (n + h / 30) % 12
const a = s * Math.min(l, 1 - l)
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))
const to = (x: number) => Math.round(x * 255).toString(16).padStart(2, '0')
return `#${ to(f(0)) }${ to(f(8)) }${ to(f(4)) }`
}
private _hex_to_hue(hex: string): number {
const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i)
if (!m) return 240
const r = parseInt(m[1], 16) / 255
const g = parseInt(m[2], 16) / 255
const b = parseInt(m[3], 16) / 255
const max = Math.max(r, g, b), min = Math.min(r, g, b)
if (max === min) return 0
const d = max - min
let h = 0
if (max === r) h = ((g - b) / d) % 6
else if (max === g) h = (b - r) / d + 2
else h = (r - g) / d + 4
h = Math.round(h * 60)
if (h < 0) h += 360
return h
}
// Reactively apply theme variables to the document root.
@ $mol_mem
_apply_theme() {
if (typeof document === 'undefined') return
const hue = this.theme_hue()
document.documentElement.style.setProperty('--mol_theme_hue', `${ hue }deg`)
}
chat_pane_content() {
const peer = this.$.$mol_state_arg.value('chat')
if (!peer) return [this.Chat_placeholder()]
this._maybe_load_history(peer)
return [this.Chat_view(peer)]
}
folder() { return (this.$.$mol_state_arg.value('folder') as string | null) ?? '' }
chat() { return (this.$.$mol_state_arg.value('chat') as string | null) ?? '' }
// ── User folders (drag chat → drop on +zone or existing folder) ──────
@ $mol_mem
folders_ver(next?: number) { return next ?? 0 }
// ── Pins per folder ─────────────────────────────────────────────────
@ $mol_mem
pins_ver(next?: number) { return next ?? 0 }
// Folder key used in pin storage. Empty/null URL arg → 'all'.
private _folder_key(): string {
const f = this.folder()
return f ? f : 'all'
}
private _pins_obj(): Record<string, string[]> {
this.pins_ver()
return (this.$.$mol_state_local.value('xmpp_pins') as Record<string, string[]> | null) ?? {}
}
private _save_pins(obj: Record<string, string[]>) {
this.$.$mol_state_local.value('xmpp_pins', obj)
this.pins_ver(this.pins_ver() + 1)
}
pinned_jids(folder: string): string[] {
return this._pins_obj()[folder] ?? []
}
contact_pinned(jid: string): boolean {
return this.pinned_jids(this._folder_key()).includes(jid)
}
toggle_pin(jid: string) {
const folder = this._folder_key()
const obj = { ...this._pins_obj() }
const arr = [...(obj[folder] ?? [])]
const idx = arr.indexOf(jid)
if (idx >= 0) arr.splice(idx, 1)
else arr.unshift(jid)
if (arr.length === 0) delete obj[folder]
else obj[folder] = arr
this._save_pins(obj)
}
// Drop handler on a contact row: reorder pins. `target_jid` is this row's jid (anchor),
// `source_jid` is the dragged contact. Insert source before target in the pin list.
reorder_pin(target_jid: string, source_jid: string) {
if (!source_jid || target_jid === source_jid) return
const folder = this._folder_key()
const obj = { ...this._pins_obj() }
let arr = [...(obj[folder] ?? [])].filter(j => j !== source_jid)
const idx = arr.indexOf(target_jid)
if (idx >= 0) {
arr.splice(idx, 0, source_jid)
} else {
// Target not pinned → pin it first, then place source above it.
arr.push(target_jid)
arr.unshift(source_jid)
}
obj[folder] = arr
this._save_pins(obj)
}
private _folders_obj(): Record<string, string[]> {
return (this.$.$mol_state_local.value('xmpp_folders') as Record<string, string[]> | null) ?? {}
}
private _save_folders(obj: Record<string, string[]>) {
this.$.$mol_state_local.value('xmpp_folders', obj)
this.folders_ver(this.folders_ver() + 1)
}
folders_list(): string[] {
this.folders_ver()
return Object.keys(this._folders_obj()).sort()
}
private _add_to_folder(name: string, jid: string) {
const obj = { ...this._folders_obj() }
const arr = [...(obj[name] ?? [])]
if (!arr.includes(jid)) arr.push(jid)
obj[name] = arr
this._save_folders(obj)
}
private _remove_from_folder(name: string, jid: string) {
const obj = { ...this._folders_obj() }
const arr = (obj[name] ?? []).filter(j => j !== jid)
if (arr.length === 0) delete obj[name]
else obj[name] = arr
this._save_folders(obj)
}
// View bindings for the user folder list rendered in the left pane.
user_folder_views() {
return this.folders_list().map(name => this.User_folder(name))
}
user_folder_name(name: string) { return name }
user_folder_label(name: string) { return name }
delete_folder(name: string) {
const obj = this._folders_obj()
delete obj[name]
this._save_folders(obj)
if (this.folder() === name) this.$.$mol_state_arg.value('folder', null)
}
// ── Roster header (shows folder name; user folders are renamable + deletable) ──
roster_header_sub() {
const f = this.folder()
if (!f || f === 'chats' || f === 'rooms') return [this.Roster_static_header()]
return [this.Roster_folder_header()]
}
roster_static_text() {
const f = this.folder()
if (f === 'chats') return 'Chats'
if (f === 'rooms') return 'Rooms'
return 'All'
}
roster_folder_input(next?: string) {
const cur = this.folder()
if (next === undefined) return cur
const trimmed = next.trim()
if (!trimmed || trimmed === cur) return cur
if (cur && cur !== 'chats' && cur !== 'rooms') {
const obj = this._folders_obj()
if (obj[cur] && !obj[trimmed]) {
obj[trimmed] = obj[cur]
delete obj[cur]
this._save_folders(obj)
this.$.$mol_state_arg.value('folder', trimmed)
}
}
return trimmed
}
do_delete_current_folder() {
const f = this.folder()
if (!f || f === 'chats' || f === 'rooms') return
const confirmed = window.prompt(`Type "${ f }" to confirm deletion of this folder`)
if (!confirmed || confirmed.trim() !== f) return
this.delete_folder(f)
}
// $mol_drag/$mol_drop integration.
roster_jid(jid: string) { return jid }
folder_adopt(transfer: DataTransfer): string | null {
const jid = transfer.getData('text/plain')
return jid || null
}
folder_receive(name: string, jid: string) {
if (!jid) return
const obj = this._folders_obj()
const arr = obj[name] ?? []
if (arr.includes(jid)) this._remove_from_folder(name, jid)
else this._add_to_folder(name, jid)
}
new_folder_receive(jid: string) {
if (!jid) return
const name = (window.prompt('Folder name')?.trim()) ?? ''
if (!name) return
this._add_to_folder(name, jid)
}
// On first render, if we have saved credentials and aren't connecting/connected, kick off auto-connect.
private _maybe_auto_connect() {
if (this._auto_connect_tried) return
if (this.status() !== 'disconnected') return
const url = this.server().trim()
const jid = this.jid().trim()
const pw = this.password()
if (!url || !jid || !pw) return
this._auto_connect_tried = true
// Defer to escape the current reactive fiber — do_connect mutates memos.
Promise.resolve().then(() => {
if (this.status() === 'disconnected') this.do_connect()
})
}
// ── Login ─────────────────────────────────────────────────────────────
connecting() { return this.status() === 'connecting' }
connect_title() { return this.connecting() ? 'Connecting…' : 'Connect' }
do_connect() {
const url = this.server().trim()
const jid = this.jid().trim()
const pw = this.password()
if (!url || !jid || !pw) { this.error_text('Please fill in all fields'); return }
this.error_text('')
this.status('connecting')
// Prime AudioContext on user gesture so beep() can play later (browsers block autoplay)
try {
const Ctor = (window as any).AudioContext || (window as any).webkitAudioContext
if (Ctor && !this._audio_ctx) this._audio_ctx = new Ctor()
if (this._audio_ctx?.state === 'suspended') void this._audio_ctx.resume()
} catch {}
const conn = new Xmpp_conn()
this._conn = conn
conn.on_ready = bound_jid => {
this.my_jid(bound_jid)
this.status('connected')
this._connect_at = Date.now()
this._start_polling()
void this._load_persisted()
}
conn.on_bookmarks = bookmarks => {
const my_nick = this.my_jid().split('@')[0]
for (const b of bookmarks) {
if (this._rooms.has(b.jid)) continue
const nick = b.nick || my_nick
const room: Xmpp_room = { jid: b.jid, name: b.name || b.jid.split('@')[0], nick }
this._rooms.set(b.jid, room)
void this._load_avatar(b.jid)
if (b.autojoin) this._conn?.join_room(b.jid, nick)
}
this.rooms([ ...this._rooms.values() ])
}
conn.on_roster = cs => {
this.contacts(cs)
cs.forEach(c => void this._load_avatar(c.jid))
const my_bare = this.my_jid().split('/')[0]
if (my_bare) void this._load_avatar(my_bare)
}
conn.on_avatar_meta = (from, info) => {
if (!info || !info.id) {
if (this._avatars.delete(from)) this.avatar_ver(this.avatar_ver() + 1)
return
}
void this._fetch_avatar_with(from, info.id, info.mime)
}
conn.on_muc_presence = (room_jid, nick, real_bare) => {
if (real_bare) {
let occupants = this._room_occupants.get(room_jid)
if (!occupants) {
occupants = new Map()
this._room_occupants.set(room_jid, occupants)
}
occupants.set(nick, real_bare)
void this._load_avatar(real_bare)
}
}
conn.on_message = msg => {
if (this._msg_by_id.has(msg.id)) return // ← FIX: skip duplicates
msg.read = false
this._add_message(msg)
const bare = this.my_jid().split('/')[0]
const peer = msg.from === bare ? msg.to : msg.from
this._scroll_to_bottom(peer)
// Skip notification for delayed/offline messages (server replays history on reconnect)
if (msg.from !== bare && Date.now() - msg.time < 30_000) {
const c = this.contacts().find(x => x.jid === msg.from)
const name = c?.name && c.name !== msg.from ? c.name : msg.from
this._notify(name, msg.body, msg.from)
}
}
conn.on_groupchat_message = msg => {
if (this._msg_by_id.has(msg.id)) return
msg.read = false
this._add_message(msg)
this._scroll_to_bottom(msg.from)
const room = this._rooms.get(msg.from)
if (room && msg.nick && msg.nick !== room.nick && Date.now() - msg.time < 30_000) {
const title = `${ room.name || msg.from }${ msg.nick }`
this._notify(title, msg.body, msg.from)
}
}
conn.on_mam_message = msg => {
if (this._msg_by_id.has(msg.id)) return
this._add_message(msg)
// track oldest MAM result id per peer for pagination
const bare = this.my_jid().split('/')[0]
const peer = msg.from === bare ? msg.to : msg.from
if (peer) {
const oldest_id = msg.mam_id || msg.id
const cur_time = this._mam_oldest_time.get(peer) ?? Infinity
if (msg.time < cur_time) {
this._mam_oldest.set(peer, oldest_id)
this._mam_oldest_time.set(peer, msg.time)
}
}
// MAM-on-delivery: server may wrap live messages in <result>; notify if recent
if (Date.now() - msg.time < 30_000 && msg.from !== bare) {
if (msg.nick !== undefined) {
const room = this._rooms.get(msg.from)
if (room && msg.nick !== room.nick) {
this._notify(`${ room.name || msg.from }${ msg.nick }`, msg.body, msg.from)
}
} else {
const c = this.contacts().find(x => x.jid === msg.from)
const name = c?.name && c.name !== msg.from ? c.name : msg.from
this._notify(name, msg.body, msg.from)
}
}
}
conn.on_markable = (msg_id, from, type) => {
const my_bare = this.my_jid().split('/')[0]
const from_bare = from.split('/')[0]
if (from_bare === my_bare) return // skip our own echoes
if (!this._conn) return
if (type === 'chat') {
this._conn.send_marker(from_bare, 'received', msg_id, 'chat')
}
// MUC: per-occupant markers go through private messages — skipping for now
}
conn.on_marker = (kind, msg_id, _from) => {
const priority = { received: 1, displayed: 2, acknowledged: 3 } as const
const cur = this._markers.get(msg_id)
if (!cur || priority[kind] > priority[cur]) {
this._markers.set(msg_id, kind)
this.marker_ver(this.marker_ver() + 1)
}
}
conn.on_mam_fin = with_jid => {
this._loading_more.delete(with_jid)
const cnt = (this._fin_count.get(with_jid) ?? 0) + 1
this._fin_count.set(with_jid, cnt)
if (cnt === 1) this._scroll_to_bottom(with_jid)
}
conn.on_presence = (from, show, stat) => {
this.contacts(this.contacts().map(c => c.jid === from ? { ...c, show, status: stat } : c))
}
conn.on_room_error = (err) => {
this.error_text(err)
// remove optimistically-added room whose join was rejected
const current = this.$.$mol_state_arg.value('chat')
if (current && this._rooms.has(current)) {
this._rooms.delete(current)
this.rooms([ ...this._rooms.values() ])
this.$.$mol_state_arg.value('chat', null)
}
}
conn.on_error = err => { this.error_text(err); this.status('disconnected') }
conn.on_close = () => {
if (this.status() === 'connecting') this.error_text('Connection closed')
this.status('disconnected')
}
conn.connect(url, jid, pw)
}
do_disconnect() {
this._conn?.disconnect()
this._conn = null
this.status('disconnected')
this.my_jid('')
this.contacts([])
this._msgs = []
this._msg_by_id.clear()
this._history_loaded.clear()
this._mam_oldest.clear()
this._loading_more.clear()
this._fin_count.clear()
this._scroll_setup.clear()
this._rooms.clear()
this.rooms([])
this._oldest_time.clear()
this._stop_polling()
this.messages_ver(0)
this.$.$mol_state_arg.value('chat', null)
}
// ── Roster ────────────────────────────────────────────────────────────
@ $mol_mem
roster_rows() {
this.folders_ver()
this.pins_ver()
const folder = this.folder()
const contacts = this.contacts()
const rooms = this.rooms()
let jids: string[] = []
if (folder === 'chats') jids = contacts.map(c => c.jid)
else if (folder === 'rooms') jids = rooms.map(r => r.jid)
else if (!folder) jids = [...contacts.map(c => c.jid), ...rooms.map(r => r.jid)]
else jids = this._folders_obj()[folder] ?? []
const q = this.search_query().trim().toLowerCase()
if (q) {
jids = jids.filter(jid => {
const c = contacts.find(x => x.jid === jid)
const r = this._rooms.get(jid)
const name = (c?.name || r?.name || jid).toLowerCase()
return jid.toLowerCase().includes(q) || name.includes(q)
})
}
// Pinned at top in pin order, rest below preserving original order.
const pinned_set = new Set(this.pinned_jids(this._folder_key()))
const pinned = this.pinned_jids(this._folder_key()).filter(j => jids.includes(j))
const rest = jids.filter(j => !pinned_set.has(j))
return [...pinned, ...rest].map(j => this.Roster_contact(j))
}
// Suggestion buttons appear when the search query looks like a JID and isn't already in the list.
search_actions_sub() {
const q = this.search_query().trim()
if (!q.includes('@')) return []
const has_existing = this._rooms.has(q) || this.contacts().some(c => c.jid === q)
return has_existing ? [] : [this.Search_action_chat(), this.Search_action_room()]
}
search_action_chat_title() { return `+ Start chat with ${ this.search_query().trim() }` }
search_action_room_title() { return `# Join room ${ this.search_query().trim() }` }
do_search_chat() {
const jid = this.search_query().trim()
if (!jid.includes('@')) return
this.search_query('')
this.$.$mol_state_arg.value('chat', jid)
}
do_search_room() {
const jid = this.search_query().trim()
if (!jid.includes('@') || !this._conn) return
const nick = this.my_jid().split('@')[0]
this._conn.join_room(jid, nick)
const room: Xmpp_room = { jid, name: jid.split('@')[0], nick }
this._rooms.set(jid, room)
void this._load_avatar(jid)
this.rooms([ ...this._rooms.values() ])
this.search_query('')
this.$.$mol_state_arg.value('chat', jid)
}
contact_display(jid: string) {
const room = this._rooms.get(jid)
if (room) return `# ${ room.name || jid.split('@')[0] }`
const c = this.contacts().find(x => x.jid === jid)
const name = c?.name && c.name !== jid ? c.name : jid
const dot = c?.show === 'offline' ? ' ●' : c?.show ? ' ○' : ''
return name + dot
}
contact_name(jid: string) {
const room = this._rooms.get(jid)
if (room) return `# ${ room.name || jid.split('@')[0] }`
const c = this.contacts().find(x => x.jid === jid)
return c?.name && c.name !== jid ? c.name : jid
}
@ $mol_mem_key
last_message(jid: string) {
this.messages_ver()
const bare = this.my_jid().split('/')[0]
const msgs = this._msgs
.filter(m => m.from === jid || m.to === jid
|| (m.from === bare && m.to === jid)
|| (m.from === jid && m.to === bare))
.sort((a, b) => a.time - b.time)
const last = msgs[msgs.length - 1]
return last ? (last.body || '[media]') : ''
}
@ $mol_mem_key
unread_indicator(jid: string) {
this.messages_ver()
const current_chat = this.chat()
if (current_chat === jid && this.scroll_at_bottom(jid)) return false
const bare = this.my_jid().split('/')[0]
const msgs = this._msgs
.filter(m => m.from === jid || m.to === jid
|| (m.from === bare && m.to === jid)
|| (m.from === jid && m.to === bare))
return msgs.some(m => m.from !== bare && !m.read)
}
@ $mol_mem_key
unread_dot(jid: string) {
return this.unread_indicator(jid) ? '●' : ''
}
open_chat(jid: string) {
this.$.$mol_state_arg.value('chat', jid)
this._mark_last_read(jid)
}
private _mark_last_read(jid: string) {
const bare = this.my_jid().split('/')[0]
const msgs = this._msgs
.filter(m => m.from === jid || m.to === jid
|| (m.from === bare && m.to === jid)
|| (m.from === jid && m.to === bare))
const unread_msgs = msgs.filter(m => m.from !== bare && !m.read)
for (const msg of unread_msgs) {
msg.read = true
void this._persist_msg(msg)
}
if (unread_msgs.length > 0) {
this.messages_ver(Date.now())
}
}
do_new_chat() {
const jid = this.new_chat_jid().trim()
if (!jid || !jid.includes('@')) {
this.error_text('Enter a valid JID like user@server')
return
}
this.new_chat_jid('')
this.$.$mol_state_arg.value('chat', jid)
}
// ── Rooms ─────────────────────────────────────────────────────────────
do_join_room() {
const jid = this.room_jid().trim()
const nick = this.room_nick().trim() || this.my_jid().split('@')[0]
if (!jid || !this._conn) return
this._conn.join_room(jid, nick)
const room: Xmpp_room = { jid, name: jid.split('@')[0], nick }
this._rooms.set(jid, room)
void this._load_avatar(jid)
this.rooms([ ...this._rooms.values() ])
this.room_jid('')
this.$.$mol_state_arg.value('chat', jid)
}
do_leave_room(jid: string) {
const room = this._rooms.get(jid)
if (!room) return
this._conn?.leave_room(jid, room.nick)
this._rooms.delete(jid)
this._room_occupants.delete(jid)
this.rooms([ ...this._rooms.values() ])
this._msgs = this._msgs.filter(m => m.from !== jid && m.to !== jid)
this._msg_by_id.forEach((m, k) => { if (m.from === jid || m.to === jid) this._msg_by_id.delete(k) })
this._scroll_setup.delete(jid)
this._oldest_time.delete(jid)
this._mam_oldest.delete(jid)
this._mam_oldest_time.delete(jid)
this._history_loaded.delete(jid)
this._fin_count.delete(jid)
this.messages_ver(this.messages_ver() + 1)
this.$.$mol_state_arg.value('chat', null)
}
chat_leave_hidden(jid: string): any {
return this._rooms.has(jid) ? null : ''
}
// ── Chat ──────────────────────────────────────────────────────────────
chat_with(jid: string) {
const room = this._rooms.get(jid)
return room ? `# ${ room.name }` : jid
}
// @$mol_mem_key ensures this becomes a reactive node:
// when messages_ver() changes, the list re-renders automatically.
@ $mol_mem_key
message_rows(jid: string) {
this.messages_ver() // track the version counter
const bare = this.my_jid().split('/')[0]
return this._msgs
.filter(m => m.from === jid || m.to === jid
|| (m.from === bare && m.to === jid)
|| (m.from === jid && m.to === bare))
.sort((a, b) => a.time - b.time)
.map(m => this.Msg(m.id))
}
// ── Message items ─────────────────────────────────────────────────────
@ $mol_mem_key // ← FIX: make reactive
msg_from(id: string) {
const msg = this._msg_by_id.get(id)
if (!msg) return ''
if (msg.nick !== undefined) {
// MUC: show "You" if the nick matches our room nick
const room = this._rooms.get(msg.from)
return room?.nick === msg.nick ? 'You' : msg.nick
}
return msg.from === this.my_jid().split('/')[0] ? 'You' : msg.from
}
@ $mol_mem_key // ← FIX: make reactive
msg_body(id: string) {
const msg = this._msg_by_id.get(id)
if (!msg) return ''
if (msg.media_uri || msg.media_kind) return ''
const body = msg.body ?? ''
return media_type(body) ? '' : body
}
@ $mol_mem_key // ← FIX: make reactive
msg_media(id: string): $mol_view[] {
const msg = this._msg_by_id.get(id)
if (!msg) return []
const uri = msg.media_uri || msg.body.trim()
const type = msg.media_kind ?? (msg.media_uri ? media_type(uri) : media_type(msg.body.trim()))
if (type === 'image') return [this.Msg_image(id)]
if (type === 'audio') return [this.Msg_audio(id)]
if (type === 'link') return [this.Msg_link(id)]
return []
}
@ $mol_mem_key // ← FIX: make reactive
msg_image_uri(id: string) { const msg = this._msg_by_id.get(id); return msg?.media_uri || (msg?.body.trim() ?? '') }
@ $mol_mem_key // ← FIX: make reactive
msg_audio_src(id: string) { const msg = this._msg_by_id.get(id); return msg?.media_uri || (msg?.body.trim() ?? '') }
@ $mol_mem_key // ← FIX: make reactive
msg_link_uri(id: string) { const msg = this._msg_by_id.get(id); return msg?.media_uri || (msg?.body.trim() ?? '') }
@ $mol_mem_key
msg_time(id: string) {
const msg = this._msg_by_id.get(id)
if (!msg) return ''
const d = new Date(msg.time)
const same_day = (() => {
const now = new Date()
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate()
})()
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
if (same_day) return time
const date = d.toLocaleDateString([], { day: '2-digit', month: '2-digit' })
return `${ date } ${ time }`
}
// XEP-0333 status for outgoing messages.
// ✓ — sent locally; ✓✓ — peer received; ✓✓ (filled) — peer displayed.
@ $mol_mem_key
msg_status(id: string) {
this.marker_ver()
const msg = this._msg_by_id.get(id)
if (!msg) return ''
const bare = this.my_jid().split('/')[0]
if (msg.from !== bare) return ''
const m = this._markers.get(id)
if (m === 'displayed' || m === 'acknowledged') return '✔✔'
if (m === 'received') return '✓✓'
return '✓'
}
// ── Compose input event handlers ──────────────────────────────────────
// Enter sends; Shift+Enter inserts a newline (browser default).
compose_keydown(jid: string, e?: Event | null) {
const ke = e as KeyboardEvent | null | undefined
if (!ke) return null
if (ke.key === 'Enter' && !ke.shiftKey && !ke.ctrlKey && !ke.altKey && !ke.metaKey) {
ke.preventDefault()
this.do_send(jid)
}
return null
}
// Paste image / file from clipboard → upload via XEP-0363 and send the link.
compose_paste(jid: string, e?: Event | null) {
const ce = e as ClipboardEvent | null | undefined
const items = ce?.clipboardData?.items
if (!items) return null
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) {
ce!.preventDefault()
void this._upload_and_send(jid, file).catch(err => this.error_text(String(err)))
return null
}
}
}
return null
}
// ── Send text ─────────────────────────────────────────────────────────
do_send(jid: string) {
const text = this.compose(jid).trim()
if (!text || !this._conn) return
if (this._rooms.has(jid)) {
this.compose(jid, '')
const id = `l${ Date.now() }_${ Math.random().toString(36).slice(2) }`
this._conn.send_groupchat(jid, text, id)
this._add_message({ id, from: this.my_jid().split('/')[0], to: jid, body: text, time: Date.now(), read: true })
}
this.compose(jid, '')
const id = `l${ Date.now() }_${ Math.random().toString(36).slice(2) }`
this._conn.send_message(jid, text, id)
this._add_message({ id, from: this.my_jid().split('/')[0], to: jid, body: text, time: Date.now(), read: true })
this._scroll_to_bottom(jid)
}
// ── Attach file (XEP-0363) ────────────────────────────────────────────
do_attach(jid: string) {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*,audio/*,video/*,*/*'
input.onchange = () => {
const file = input.files?.[0]
if (file) void this._upload_and_send(jid, file).catch(e => this.error_text(String(e)))
}
input.click()
}
// ── Voice recording ───────────────────────────────────────────────────
record_title(jid: string) { return this.recording(jid) ? '⏹ Stop' : '🎤 Record' }
do_record(jid: string) {
if (this.recording(jid)) {
this._rec.get(jid)?.recorder.stop()
return
}
navigator.mediaDevices.getUserMedia({ audio: true })
.then(stream => {
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm'
const chunks: Blob[] = []
const recorder = new MediaRecorder(stream, { mimeType: mime })
this._rec.set(jid, { recorder, chunks, stream })
recorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data) }
recorder.onstop = () => {
stream.getTracks().forEach(t => t.stop())
this._rec.delete(jid)
this.recording(jid, false)
const ext = mime.includes('ogg') ? 'ogg' : 'webm'
const file = new File(chunks, `voice_${ Date.now() }.${ ext }`, { type: mime })
void this._upload_and_send(jid, file).catch(e => this.error_text(String(e)))
}
recorder.start()
this.recording(jid, true)
})
.catch(() => this.error_text('Microphone access denied'))
}
// ── Helpers ───────────────────────────────────────────────────────────
private _scroll_to_bottom(jid: string) {
requestAnimationFrame(() => {
try {
const el = this.Messages_list(jid).dom_node()
el.scrollTop = el.scrollHeight
} catch {}
})
}
// XEP-0084 avatar fetch: pulls the latest metadata then the data blob.
private async _load_avatar(jid: string) {
if (!jid || !this._conn) return
if (this._avatars.has(jid) || this._avatar_loading.has(jid)) return
this._avatar_loading.add(jid)
try {
const meta = await this._conn.fetch_avatar_metadata(jid)
console.log('[xmpp] avatar meta', jid, meta)
if (!meta || !meta.id) return
await this._fetch_avatar_with(jid, meta.id, meta.mime)
} catch (e) {
console.warn('[xmpp] avatar load failed', jid, e)
} finally {
this._avatar_loading.delete(jid)
}
}
private async _fetch_avatar_with(jid: string, hash: string, mime: string) {
if (!this._conn) return
const b64 = await this._conn.fetch_avatar_data(jid, hash)
console.log('[xmpp] avatar data', jid, hash, b64 ? `${ b64.length }b` : 'null')
if (!b64) return
this._avatars.set(jid, `data:${ mime || 'image/png' };base64,${ b64 }`)
this.avatar_ver(this.avatar_ver() + 1)
}
// User picks an image — compute SHA-1, base64-encode, publish to PEP.
do_set_avatar() {
console.log('[xmpp] do_set_avatar called, conn=', !!this._conn)
if (!this._conn) return
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = async () => {
const file = input.files?.[0]
console.log('[xmpp] avatar file picked', file)
if (!file || !this._conn) return
try {
const buf = await file.arrayBuffer()
const hash_buf = await crypto.subtle.digest('SHA-1', buf)
const hash = Array.from(new Uint8Array(hash_buf))
.map(b => b.toString(16).padStart(2, '0')).join('')
const bytes = new Uint8Array(buf)
let bin = ''
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
const b64 = btoa(bin)
const mime = file.type || 'image/png'
console.log('[xmpp] publish_avatar', { hash, mime, bytes: file.size, b64_len: b64.length })
this._conn.publish_avatar(hash, b64, mime, file.size)
const my_bare = this.my_jid().split('/')[0]
if (my_bare) {
this._avatars.set(my_bare, `data:${ mime };base64,${ b64 }`)
this.avatar_ver(this.avatar_ver() + 1)
console.log('[xmpp] my avatar set, my_bare=', my_bare)
}
} catch (e) {
this.error_text('Avatar upload failed: ' + String(e))
}
}
input.click()
}
// In-app notifications (toasts) + sound, fired only when the message is NOT in the open chat.
private _notify(title: string, body: string, peer: string) {
// Grace period after a fresh connection — server may replay offline messages without <delay>
if (this._connect_at && Date.now() - this._connect_at < 5_000) return
const open_peer = this.$.$mol_state_arg.value('chat')
const visible = typeof document !== 'undefined' && document.visibilityState === 'visible'
if (open_peer === peer && visible) return
if (this.notif_mode() === 'system') this._system_notify(title, body, peer)
else this._show_toast(peer, title, body)
this._beep()
}
private _system_notify(title: string, body: string, peer: string) {
if (typeof Notification === 'undefined') {
this._show_toast(peer, title, body)
return
}
if (Notification.permission === 'default') {
void Notification.requestPermission().then(p => {
if (p === 'granted') this._system_notify(title, body, peer)
else this._show_toast(peer, title, body)
})
return
}
if (Notification.permission !== 'granted') {
this._show_toast(peer, title, body)
return
}
try {
const n = new Notification(title, { body, tag: peer, requireInteraction: true })
n.onclick = () => {
try { window.focus() } catch {}
this.$.$mol_state_arg.value('chat', peer)
n.close()
}
} catch {
this._show_toast(peer, title, body)
}
}
private _ensure_toast_container(): HTMLDivElement {
if (this._toast_container && this._toast_container.isConnected) return this._toast_container
const c = document.createElement('div')
c.setAttribute('xmpp_Toasts', '')
document.body.appendChild(c)
this._toast_container = c
return c
}
private _show_toast(peer: string, title: string, body: string) {
const c = this._ensure_toast_container()
const t = document.createElement('div')
t.setAttribute('xmpp_Toast', '')
t.setAttribute('role', 'button')
t.tabIndex = 0
const t_title = document.createElement('div')
t_title.setAttribute('xmpp_Toast_title', '')
t_title.textContent = title
const t_body = document.createElement('div')
t_body.setAttribute('xmpp_Toast_body', '')
t_body.textContent = body
const t_close = document.createElement('div')
t_close.setAttribute('xmpp_Toast_close', '')
t_close.setAttribute('role', 'button')
t_close.setAttribute('aria-label', 'Close')
t_close.textContent = '×'
t.appendChild(t_close)
t.appendChild(t_title)
t.appendChild(t_body)
const dismiss = () => { if (t.parentNode) t.parentNode.removeChild(t) }
t.onclick = () => { this.$.$mol_state_arg.value('chat', peer); dismiss() }
t_close.onclick = e => { e.stopPropagation(); dismiss() }
c.appendChild(t)
setTimeout(dismiss, 10000)
}
private _beep() {
const custom = this.notif_sound_uri()
if (custom) {
try {
const audio = new Audio(custom)
audio.volume = 0.5
void audio.play().catch(() => {})
return
} catch {}
}
try {
const Ctor = (window as any).AudioContext || (window as any).webkitAudioContext
if (!Ctor) return
if (!this._audio_ctx) this._audio_ctx = new Ctor()
const ctx = this._audio_ctx!
if (ctx.state === 'suspended') void ctx.resume()
const t = ctx.currentTime
const osc = ctx.createOscillator()
const g = ctx.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(880, t)
osc.frequency.exponentialRampToValueAtTime(660, t + 0.18)
g.gain.setValueAtTime(0.0001, t)
g.gain.exponentialRampToValueAtTime(0.25, t + 0.02)
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.35)
osc.connect(g); g.connect(ctx.destination)
osc.start(t); osc.stop(t + 0.4)
} catch (e) { console.warn('[xmpp] beep failed', e) }
}
private _load_more_history(jid: string) {
if (this._loading_more.has(jid) || !this._conn) return
let oldest_id = this._mam_oldest.get(jid)
if (!oldest_id) {
const oldest_msg = this._msgs
.filter(m => m.from === jid || m.to === jid || (m.from === this.my_jid().split('/')[0] && m.to === jid) || (m.from === jid && m.to === this.my_jid().split('/')[0]))
.sort((a, b) => a.time - b.time)[0]
if (oldest_msg?.mam_id) oldest_id = oldest_msg.mam_id
}
this._loading_more.add(jid)
if (this._rooms.has(jid)) {
// cursor available after first MAM batch; otherwise use timestamp to avoid duplicating join-history
const before_time = oldest_id ? undefined : this._oldest_time.get(jid)
this._conn.request_mam_room(jid, 50, oldest_id, before_time)
} else {
if (!oldest_id) { this._loading_more.delete(jid); return }
this._conn.request_mam(jid, 50, oldest_id)
}
}
private _setup_scroll_listener(jid: string) {
if (this._scroll_setup.has(jid)) return
this._scroll_setup.add(jid)
requestAnimationFrame(() => {
try {
const el = this.Messages_list(jid).dom_node() as HTMLElement
const update_at_bottom = () => {
const near = el.scrollHeight - el.scrollTop - el.clientHeight < 100
this.scroll_at_bottom(jid, near)
}
update_at_bottom()
el.addEventListener('scroll', () => {
if (el.scrollTop < 200) this._load_more_history(jid)
update_at_bottom()
}, { passive: true })
// Late-loading images push content; if user is near the bottom, follow.
el.addEventListener('load', e => {
if (!(e.target instanceof HTMLImageElement)) return
const near = el.scrollHeight - el.scrollTop - el.clientHeight < 200
if (near) el.scrollTop = el.scrollHeight
}, true)
} catch {
this._scroll_setup.delete(jid)
}
})
}
private _add_message(msg: Xmpp_message) {
if (this._msg_by_id.has(msg.id)) return // ← FIX: skip duplicates
this._msg_by_id.set(msg.id, msg)
this._msgs.push(msg)
if (msg.nick !== undefined) {
const cur = this._oldest_time.get(msg.from) ?? Infinity
if (msg.time < cur) this._oldest_time.set(msg.from, msg.time)
}
this.messages_ver(this.messages_ver() + 1)
if (!this._loading_persisted) void this._persist_msg(msg)
}
// ── Persistence (IndexedDB via $mol_db) ──────────────────────────────
private _ensure_db(): Promise<$mol_db_database<Xmpp_db_schema>> {
if (this._db) return Promise.resolve(this._db)
if (this._db_init) return this._db_init
this._db_init = this.$.$mol_db<Xmpp_db_schema>('xmpp',
mig => { mig.store_make('Messages') },
).then(db => { this._db = db; return db })
return this._db_init
}
private _peer_of(msg: Xmpp_message, account: string): string {
if (msg.nick !== undefined) return msg.from
return msg.from === account ? msg.to : msg.from
}
private async _persist_msg(msg: Xmpp_message) {
const account = this.my_jid().split('/')[0]
if (!account) return
try {
const db = await this._ensure_db()
const peer = this._peer_of(msg, account)
const { Messages } = db.change('Messages').stores
const doc: Xmpp_db_schema['Messages']['Doc'] = {
account, peer,
id: msg.id, from: msg.from, to: msg.to, body: msg.body, time: msg.time,
...(msg.nick !== undefined ? { nick: msg.nick } : {}),
...(msg.media_uri !== undefined ? { media_uri: msg.media_uri } : {}),
...(msg.media_mime !== undefined ? { media_mime: msg.media_mime } : {}),
...(msg.media_name !== undefined ? { media_name: msg.media_name } : {}),
...(msg.media_kind !== undefined ? { media_kind: msg.media_kind } : {}),
...(msg.media_size !== undefined ? { media_size: msg.media_size } : {}),
...(msg.media_hash !== undefined ? { media_hash: msg.media_hash } : {}),
...(msg.media_hash_algo !== undefined ? { media_hash_algo: msg.media_hash_algo } : {}),
...(msg.mam_id !== undefined ? { mam_id: msg.mam_id } : {}),
read: msg.read ?? false,
}
await Messages.put(doc, [account, msg.id])
} catch (e) { console.warn('[xmpp] persist failed', e) }
}
private async _load_persisted() {
const account = this.my_jid().split('/')[0]
if (!account) return
try {
const db = await this._ensure_db()
const range = this.$.$mol_dom_context.IDBKeyRange.bound([account, ''], [account, 'пїї'])
const docs = await db.read('Messages').Messages.select(range, 100_000)
if (!docs?.length) return
this._loading_persisted = true
try {
for (const doc of docs) {
const msg: Xmpp_message = {
id: doc.id, from: doc.from, to: doc.to,
body: doc.body, time: doc.time,
...(doc.nick !== undefined ? { nick: doc.nick } : {}),
...(doc.media_uri !== undefined ? { media_uri: doc.media_uri } : {}),
...(doc.media_mime !== undefined ? { media_mime: doc.media_mime } : {}),
...(doc.media_name !== undefined ? { media_name: doc.media_name } : {}),
...(doc.media_kind !== undefined ? { media_kind: doc.media_kind } : {}),
...(doc.media_size !== undefined ? { media_size: doc.media_size } : {}),
...(doc.media_hash !== undefined ? { media_hash: doc.media_hash } : {}),
...(doc.media_hash_algo !== undefined ? { media_hash_algo: doc.media_hash_algo } : {}),
...(doc.mam_id !== undefined ? { mam_id: doc.mam_id } : {}),
read: doc.read ?? true,
}
this._add_message(msg)
if (msg.mam_id) {
const peer = this._peer_of(msg, account)
const cur_time = this._mam_oldest_time.get(peer) ?? Infinity
if (msg.time < cur_time) {
this._mam_oldest.set(peer, msg.mam_id)
this._mam_oldest_time.set(peer, msg.time)
}
}
}
} finally {
this._loading_persisted = false
}
} catch (e) { console.warn('[xmpp] load persisted failed', e) }
}
private _maybe_load_history(jid: string) {
this._setup_scroll_listener(jid)
this._maybe_send_displayed(jid)
if (this._rooms.has(jid)) {
this._scroll_to_bottom(jid)
return // initial history arrives via join; MAM fires on first scroll-up
}
if (this._history_loaded.has(jid)) {
this._scroll_to_bottom(jid)
return
}
if (!this._conn) return
this._history_loaded.add(jid)
this._loading_more.add(jid)
this._conn.request_mam(jid)
}
// XEP-0333: send a Displayed marker for the newest peer message in this chat (if newer than the previous one we marked).
private _maybe_send_displayed(jid: string) {
if (!this._conn) return
if (this._rooms.has(jid)) return // skip MUC for now
const bare = this.my_jid().split('/')[0]
let newest: Xmpp_message | null = null
for (const m of this._msgs) {
if (m.from === bare) continue
if (m.from !== jid) continue
if (!newest || m.time > newest.time) newest = m
}
if (!newest) return
const last_t = this._last_displayed_sent.get(jid) ?? 0
if (newest.time <= last_t) return
this._conn.send_marker(jid, 'displayed', newest.id, 'chat')
this._last_displayed_sent.set(jid, newest.time)
}
// Periodically poll for new messages in loaded chats to recover from connection issues.
private _start_polling() {
if (this._poll_timer) clearInterval(this._poll_timer)
this._poll_count = 0
this._poll_timer = setInterval(() => {
if (!this._conn) return
this._poll_count++
const current_chat = this.$.$mol_state_arg.value('chat')
const is_full_poll = this._poll_count % 6 === 0 // every 30 seconds
// Always poll current chat if open
if (current_chat) {
if (this._history_loaded.has(current_chat) && !this._loading_more.has(current_chat)) {
this._conn.request_mam(current_chat, 5)
} else if (this._rooms.has(current_chat) && !this._loading_more.has(current_chat)) {
this._conn.request_mam_room(current_chat, 5)
}
}
// Poll all others every 30 seconds
if (is_full_poll) {
// Poll 1:1 chats that have history loaded (except current)
for (const peer of this._history_loaded) {
if (peer === current_chat) continue
if (!this._loading_more.has(peer)) {
this._conn.request_mam(peer, 5)
}
}
// Poll rooms that are joined (except current)
for (const room of this._rooms.values()) {
if (room.jid === current_chat) continue
if (!this._loading_more.has(room.jid)) {
this._conn.request_mam_room(room.jid, 5)
}
}
}
}, 5000) // every 5 seconds
}
private _stop_polling() {
if (this._poll_timer) {
clearInterval(this._poll_timer)
this._poll_timer = null
this._poll_count = 0
}
}
private async _upload_and_send(jid: string, file: File): Promise<void> {
if (!this._conn) throw new Error('Not connected')
const { put, get } = await this._conn.request_slot(file.name, file.size, file.type)
await Xmpp_conn.upload(put, file)
const hash = await Xmpp_conn.hash_file(file)
const body = `Shared file: ${ file.name }`
const id = `l${ Date.now() }_${ Math.random().toString(36).slice(2) }`
const type = this._rooms.has(jid) ? 'groupchat' : 'chat'
this._conn.send_media_sharing(jid, body, file.name, file.size, file.type || 'application/octet-stream', 'sha-256', hash, get, type, id)
this._add_message({
id, from: this.my_jid().split('/')[0], to: jid, body, time: Date.now(),
media_uri: get,
media_mime: file.type || 'application/octet-stream',
media_name: file.name,
media_kind: media_type_from_mime(file.type) ?? media_type(get),
media_size: file.size,
media_hash: hash,
media_hash_algo: 'sha-256',
read: true,
})
this._scroll_to_bottom(jid)
}
}
}