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 void>() private _mam_iqs = new Map() // iq_id → with_jid private _avatar_meta_iqs = new Map void>() private _avatar_data_iqs = new Map 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, '&').replace(//g, '>').replace(/"/g, '"') } private _send(xml: string) { this._ws?.send(xml) } private _stream_open() { this._send(``) } 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('') this._ws?.close() this._ws = null } send_message(to: string, body: string, id?: string): string { const msg_id = id ?? this._id() this._send( `` + `${ this._esc(body) }` + `` + `` ) return msg_id } static async hash_file(file: File, algorithm = 'SHA-256'): Promise { 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( `` + `${ this._esc(body) }` + `` + `` + `` + `${ this._esc(mime) }` + `${ this._esc(file_name) }` + `${ size }` + `${ this._esc(hash_value) }` + `` + `` + `` + `` + `` + `` + `` + `` ) 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( `` + `<${ kind } xmlns="urn:xmpp:chat-markers:0" id="${ this._esc(id) }"/>` + `` ) } // ── 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( `` + `` + `` + `` + `${ b64 }` + `` ) // 2. publish metadata pointer const meta_id = this._id() this._send( `` + `` + `` + `` + `` + `` + `` ) } 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( `` + `` + `1` + `` ) }) } fetch_avatar_data(jid: string, hash: string): Promise { 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( `` + `` + `` + `` + `` ) }) } 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( `` + `` + `` ) } leave_room(room_jid: string, nick: string) { this._send(``) } send_groupchat(room_jid: string, body: string, id?: string): string { const msg_id = id ?? this._id() this._send( `` + `${ this._esc(body) }` + `` + `` ) 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( `` + `` + `` ) }) } static async upload(put_url: string, file: File): Promise { 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 ? `` + `urn:xmpp:mam:2` + `${ this._esc(new Date(before_time - 1).toISOString()) }` + `` : '' const before = before_id ? `${ this._esc(before_id) }` : `` this._send( `` + `` + time_filter + `` + `${ max }${ before }` + `` + `` ) } // 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 ? `${ this._esc(before_id) }` : `` this._send( `` + `` + `` + `urn:xmpp:mam:2` + `${ this._esc(with_jid) }` + `` + `` + `${ max }${ before }` + `` + `` ) } 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(`${ encoded }`) this._state = 'auth' } else if (this._state === 'reopen') { const id = this._id() this._send(`mol`) 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(``) this._send(``) this._send('') this._send(``) this._send(``) 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(``) }) 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 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() private _msgs: Xmpp_message[] = [] private _rec = new Map() private _history_loaded = new Set() private _mam_oldest = new Map() // jid → oldest MAM result id private _mam_oldest_time = new Map() // jid → oldest MAM result time private _loading_more = new Set() private _fin_count = new Map() private _scroll_setup = new Set() // scroll listener installed private _rooms = new Map() private _room_occupants = new Map>() // room_jid → nick → real_bare_jid private _oldest_time = new Map() // 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() // msg id → highest received marker private _last_displayed_sent = new Map() // peer/room jid → time of last msg we marked displayed // XEP-0084 avatars private _avatars = new Map() // bare jid в†’ data: URI private _avatar_loading = new Set() // Suppress notifications during the first 5s after a fresh connection // — covers servers that replay offline messages without proper . private _connect_at = 0 // IndexedDB for persistent message history (via $mol_db). private _db: $mol_db_database | null = null private _db_init: Promise<$mol_db_database> | 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 = `` + `` + `${ safe }` + `` 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 { 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 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 { this.pins_ver() return (this.$.$mol_state_local.value('xmpp_pins') as Record | null) ?? {} } private _save_pins(obj: Record) { 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 { return (this.$.$mol_state_local.value('xmpp_folders') as Record | null) ?? {} } private _save_folders(obj: Record) { 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 ; 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 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> { if (this._db) return Promise.resolve(this._db) if (this._db_init) return this._db_init this._db_init = this.$.$mol_db('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 { 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) } } }