xmpp/xmpp.view.ts
2026-05-08 00:51:26 +03:00

1904 lines
68 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
}
type Xmpp_room = {
jid: string
name: string
nick: string // my nickname in this room
}
// $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
}
Indexes: {}
}
}
type Media_type = 'image' | 'audio' | 'link' | null
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'
}
// ─── 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_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
}
// 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
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
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 _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';
const body = this._getBody(el);
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,
});
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,
});
}
private _handle_mam(result: Element) {
const fwd = result.querySelector('forwarded')
if (!fwd) return
const msg = fwd.querySelector('message')
if (!msg) return
const body = msg.querySelector('body')?.textContent
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
this.on_mam_message?.({
id: result.getAttribute('id') || this._id(),
from,
to: (msg.getAttribute('to') || '').split('/')[0],
body, time,
...(nick !== undefined ? { nick } : {}),
})
}
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) return // ignore occupant presence updates for now
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 _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 _oldest_time = new Map<string, number>() // room_jid → oldest msg timestamp
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)
if (room) return this._default_avatar(jid, room.name || jid.split('@')[0])
return this.avatar_uri(jid) || 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) 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) ?? '' }
// ── 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()
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)
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_message = msg => {
if (this._msg_by_id.has(msg.id)) return // ← FIX: skip duplicates
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
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 cur = this._mam_oldest.get(peer)
const cur_time = cur ? (this._msg_by_id.get(cur)?.time ?? Infinity) : Infinity
if (msg.time < cur_time) this._mam_oldest.set(peer, msg.id)
}
// 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.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)
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
}
open_chat(jid: string) { this.$.$mol_state_arg.value('chat', jid) }
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)
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.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._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 body = this._msg_by_id.get(id)?.body ?? ''
return media_type(body) ? '' : body
}
@ $mol_mem_key // ← FIX: make reactive
msg_media(id: string): $mol_view[] {
const body = this._msg_by_id.get(id)?.body.trim() ?? ''
const type = media_type(body)
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) { return this._msg_by_id.get(id)?.body.trim() ?? '' }
@ $mol_mem_key // ← FIX: make reactive
msg_audio_src(id: string) { return this._msg_by_id.get(id)?.body.trim() ?? '' }
@ $mol_mem_key // ← FIX: make reactive
msg_link_uri(id: string) { return this._msg_by_id.get(id)?.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, '')
this._conn.send_groupchat(jid, text)
this._scroll_to_bottom(jid)
return
}
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() })
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
const oldest_id = this._mam_oldest.get(jid)
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 } : {}),
}
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) {
this._add_message({
id: doc.id, from: doc.from, to: doc.to,
body: doc.body, time: doc.time,
...(doc.nick !== undefined ? { nick: doc.nick } : {}),
})
}
} 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)
}
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 id = `l${ Date.now() }_${ Math.random().toString(36).slice(2) }`
this._conn.send_message(jid, get, id)
this._add_message({ id, from: this.my_jid().split('/')[0], to: jid, body: get, time: Date.now() })
this._scroll_to_bottom(jid)
}
}
}