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 } 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 void>() private _mam_iqs = new Map() // iq_id → with_jid 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_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, '&').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 } 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 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 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 _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') || '' // 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-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; } const type = el.getAttribute('type') || 'chat'; const body = this._getBody(el); if (!body) return; // 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 const nick = 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() private _msgs: Xmpp_message[] = [] private _rec = new Map() private _history_loaded = new Set() private _mam_oldest = new Map() // jid → oldest MAM result id private _loading_more = new Set() private _fin_count = new Map() private _scroll_setup = new Set() // scroll listener installed private _rooms = new Map() private _oldest_time = new Map() // room_jid → oldest msg timestamp private _ver = 0 @ $mol_mem status(next?: 'disconnected' | 'connecting' | 'connected') { return next ?? 'disconnected' } @ $mol_mem my_jid(next?: string) { return next ?? '' } @ $mol_mem server(next?: string) { return next ?? '' } @ $mol_mem jid(next?: string) { return next ?? '' } @ $mol_mem password(next?: string) { return next ?? '' } @ $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 } @ $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_key compose(_jid: string, next?: string) { return next ?? '' } @ $mol_mem_key recording(_jid: string, next?: boolean) { return next ?? false } // ── Pages ───────────────────────────────────────────────────────────── pages() { if (this.status() !== 'connected') return [this.Login_page()] const pages: $mol_view[] = [this.Roster_page()] const peer = this.$.$mol_state_arg.value('chat') if (peer) { pages.push(this.Chat_page(peer)) // load MAM history the first time this chat page is shown this._maybe_load_history(peer) } return pages } // ── 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') const conn = new Xmpp_conn() this._conn = conn conn.on_ready = bound_jid => { this.my_jid(bound_jid) this.status('connected') } 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) } 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) } conn.on_groupchat_message = msg => { if (this._msg_by_id.has(msg.id)) return this._add_message(msg) this._scroll_to_bottom(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) } } 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 ──────────────────────────────────────────────────────────── roster_rows() { const contacts = this.contacts().map(c => this.Roster_contact(c.jid)) const rooms = this.rooms().map(r => this.Roster_contact(r.jid)) return [...contacts, ...rooms] } 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) } // ── 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() ?? '' } // ── 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 { this.Chat_page(jid).Body().dom_node().scrollTop = 999999 } catch {} }) } 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.Chat_page(jid).Body().dom_node() as HTMLElement el.addEventListener('scroll', () => { if (el.scrollTop < 200) this._load_more_history(jid) }, { passive: 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) } private _maybe_load_history(jid: string) { this._setup_scroll_listener(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) } 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 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() }) } } }