namespace $.$$ {

	type Xmpp_contact = {
		jid: string
		name: string
		show: string
		status: string
	}

		type Xmpp_message = {
			id: string
			from: string
			to: string
			body: string
			time: number
			nick?: string  // sender nick for MUC groupchat messages
			mam_id?: string // archive result id for MAM pagination
			media_uri?: string
			media_mime?: string
			media_name?: string
			media_kind?: Media_type
			media_size?: number
			media_hash?: string
			media_hash_algo?: string
		read?: boolean
	}

	type Xmpp_room = {
		jid: string
		name: string
		nick: string   // my nickname in this room
	}

	type Media_type = 'image' | 'audio' | 'link' | null

	// $mol_db schema for persisted messages.
	type Xmpp_db_schema = {
		Messages: {
			Key: [ string, string ]  // [account_bare_jid, msg_id]
			Doc: {
				account: string
				peer: string
				id: string
				from: string
				to: string
				body: string
				time: number
				nick?: string
				mam_id?: string
				media_uri?: string
				media_mime?: string
				media_name?: string
				media_kind?: Media_type
				media_size?: number
				media_hash?: string
				media_hash_algo?: string
				read?: boolean
			}
			Indexes: {}
		}
	}

	function media_type(url: string): Media_type {
		const s = url.trim()
		if (!/^https?:\/\/\S+$/.test(s)) return null
		const path = s.split('?')[0].toLowerCase()
		if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|avif)$/.test(path)) return 'image'
		if (/\.(mp3|ogg|wav|webm|opus|m4a|aac|flac)$/.test(path)) return 'audio'
		return 'link'
	}

	function media_type_from_mime(mime: string): Media_type {
		const s = mime.trim().toLowerCase()
		if (s.startsWith('image/')) return 'image'
		if (s.startsWith('audio/')) return 'audio'
		return null
	}

	// ─── XMPP over WebSocket (RFC 7590) ──────────────────────────────────────

	class Xmpp_conn {

		private _ws: WebSocket | null = null
		private _count = 0
		private _jid = ''
		private _pass = ''
		private _domain = ''
		private _state: 'open' | 'auth' | 'reopen' | 'bind' | 'ready' = 'open'

		private _slots      = new Map<string, (put: string, get: string) => void>()
		private _mam_iqs    = new Map<string, string>() // iq_id → with_jid
		private _avatar_meta_iqs = new Map<string, (m: { id: string; mime: string } | null) => void>()
		private _avatar_data_iqs = new Map<string, (b64: string | null) => void>()
		upload_service = ''

		on_ready: ((jid: string) => void) | null = null
		on_bookmarks: ((rooms: Array<{ jid: string; name: string; nick: string; autojoin: boolean }>) => void) | null = null
		on_roster: ((contacts: Xmpp_contact[]) => void) | null = null
		on_message: ((msg: Xmpp_message) => void) | null = null
		on_groupchat_message: ((msg: Xmpp_message) => void) | null = null
		on_mam_message: ((msg: Xmpp_message) => void) | null = null
		on_mam_fin: ((with_jid: string) => void) | null = null
		on_markable: ((msg_id: string, from: string, type: 'chat' | 'groupchat') => void) | null = null
		on_marker: ((kind: 'received' | 'displayed' | 'acknowledged', msg_id: string, from: string) => void) | null = null
		on_avatar_meta: ((from: string, info: { id: string; mime: string } | null) => void) | null = null
		on_presence: ((jid: string, show: string, status: string) => void) | null = null
		on_muc_presence: ((room_jid: string, nick: string, real_bare?: string) => void) | null = null
		on_error: ((err: string) => void) | null = null
		on_room_error: ((err: string) => void) | null = null
		on_close: (() => void) | null = null

		private _id() { return `x${ ++this._count }` }

		private _esc(s: string) {
			return s
				.replace(/&/g, '&amp;').replace(/</g, '&lt;')
				.replace(/>/g, '&gt;').replace(/"/g, '&quot;')
		}

		private _send(xml: string) { this._ws?.send(xml) }

		private _stream_open() {
			this._send(`<open xmlns="urn:ietf:params:xml:ns:xmpp-framing" to="${ this._esc(this._domain) }" version="1.0"/>`)
		}

		connect(url: string, jid: string, password: string) {
			this._jid = jid
			this._pass = password
			this._domain = jid.split('@')[1] || jid
			this._state = 'open'
			this.upload_service = `upload.${ this._domain }`

			const ws = new WebSocket(url, 'xmpp')
			this._ws = ws
			ws.onopen = () => this._stream_open()
			ws.onmessage = e => this._handle(e.data as string)
			ws.onerror = () => this.on_error?.('WebSocket connection failed')
			ws.onclose = () => this.on_close?.()
		}

		disconnect() {
			this._send('<close xmlns="urn:ietf:params:xml:ns:xmpp-framing"/>')
			this._ws?.close()
			this._ws = null
		}

		send_message(to: string, body: string, id?: string): string {
			const msg_id = id ?? this._id()
			this._send(
				`<message to="${ this._esc(to) }" type="chat" id="${ msg_id }">` +
				`<body>${ this._esc(body) }</body>` +
				`<markable xmlns="urn:xmpp:chat-markers:0"/>` +
				`</message>`
			)
			return msg_id
		}

		static async hash_file(file: File, algorithm = 'SHA-256'): Promise<string> {
			const buffer = await file.arrayBuffer()
			const digest = await crypto.subtle.digest(algorithm, buffer)
			const bytes = new Uint8Array(digest)
			let binary = ''
			const chunk = 0x8000
			for (let i = 0; i < bytes.length; i += chunk) {
				binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
			}
			return btoa(binary)
		}

		send_media_sharing(to: string, body: string, file_name: string, size: number, mime: string, hash_algo: string, hash_value: string, get_url: string, type: 'chat' | 'groupchat' = 'chat', id?: string): string {
			const msg_id = id ?? this._id()
			const begin = body.indexOf(file_name)
			const end = begin >= 0 ? begin + file_name.length : body.length
			this._send(
				`<message to="${ this._esc(to) }" type="${ this._esc(type) }" id="${ msg_id }">` +
				`<body>${ this._esc(body) }</body>` +
				`<reference xmlns="urn:xmpp:reference:0" type="data" begin="${ begin >= 0 ? begin : 0 }" end="${ end }">` +
				`<media-sharing xmlns="urn:xmpp:sims:1">` +
				`<file xmlns="urn:xmpp:jingle:apps:file-transfer:5">` +
				`<media-type>${ this._esc(mime) }</media-type>` +
				`<name>${ this._esc(file_name) }</name>` +
				`<size>${ size }</size>` +
				`<hash xmlns="urn:xmpp:hashes:2" algo="${ this._esc(hash_algo) }">${ this._esc(hash_value) }</hash>` +
				`</file>` +
				`<sources>` +
				`<reference xmlns="urn:xmpp:reference:0" type="data" uri="${ this._esc(get_url) }"/>` +
				`</sources>` +
				`</media-sharing>` +
				`</reference>` +
				`<markable xmlns="urn:xmpp:chat-markers:0"/>` +
				`</message>`
			)
			return msg_id
		}

		// XEP-0333: send a chat marker (received/displayed/acknowledged) for a previously-received message id.
		send_marker(to: string, kind: 'received' | 'displayed' | 'acknowledged', id: string, type: 'chat' | 'groupchat' = 'chat') {
			console.log('[xmpp] → send_marker', { to, kind, id, type })
			this._send(
				`<message to="${ this._esc(to) }" type="${ type }">` +
				`<${ kind } xmlns="urn:xmpp:chat-markers:0" id="${ this._esc(id) }"/>` +
				`</message>`
			)
		}

		// ── XEP-0084 User Avatars (over PEP/XEP-0163) ─────────────────────────

		publish_avatar(hash: string, b64: string, mime: string, bytes: number) {
			// 1. publish raw bytes
			const data_id = this._id()
			this._send(
				`<iq type="set" id="${ data_id }">` +
				`<pubsub xmlns="http://jabber.org/protocol/pubsub">` +
				`<publish node="urn:xmpp:avatar:data">` +
				`<item id="${ this._esc(hash) }">` +
				`<data xmlns="urn:xmpp:avatar:data">${ b64 }</data>` +
				`</item></publish></pubsub></iq>`
			)
			// 2. publish metadata pointer
			const meta_id = this._id()
			this._send(
				`<iq type="set" id="${ meta_id }">` +
				`<pubsub xmlns="http://jabber.org/protocol/pubsub">` +
				`<publish node="urn:xmpp:avatar:metadata">` +
				`<item id="${ this._esc(hash) }">` +
				`<metadata xmlns="urn:xmpp:avatar:metadata">` +
				`<info bytes="${ bytes }" type="${ this._esc(mime) }" id="${ this._esc(hash) }"/>` +
				`</metadata></item></publish></pubsub></iq>`
			)
		}

		fetch_avatar_metadata(jid: string): Promise<{ id: string; mime: string } | null> {
			return new Promise(resolve => {
				const id = this._id()
				this._avatar_meta_iqs.set(id, resolve)
				setTimeout(() => { if (this._avatar_meta_iqs.delete(id)) resolve(null) }, 10_000)
				this._send(
					`<iq type="get" to="${ this._esc(jid) }" id="${ id }">` +
					`<pubsub xmlns="http://jabber.org/protocol/pubsub">` +
					`<items node="urn:xmpp:avatar:metadata"><max>1</max></items>` +
					`</pubsub></iq>`
				)
			})
		}

		fetch_avatar_data(jid: string, hash: string): Promise<string | null> {
			return new Promise(resolve => {
				const id = this._id()
				this._avatar_data_iqs.set(id, resolve)
				setTimeout(() => { if (this._avatar_data_iqs.delete(id)) resolve(null) }, 15_000)
				this._send(
					`<iq type="get" to="${ this._esc(jid) }" id="${ id }">` +
					`<pubsub xmlns="http://jabber.org/protocol/pubsub">` +
					`<items node="urn:xmpp:avatar:data">` +
					`<item id="${ this._esc(hash) }"/>` +
					`</items></pubsub></iq>`
				)
			})
		}

private _find(el: Element, ns: string, tag: string): Element | null {
    return el.getElementsByTagNameNS(ns, tag)[0] || null;
}
private _text(el: Element, ns: string, tag: string): string {
    const found = this._find(el, ns, tag);
    return found?.textContent ?? '';
}
private _getBody(el: Element): string | null {
    let body = this._find(el, 'jabber:client', 'body');
    if (!body) body = this._find(el, '', 'body'); // fallback
    return body?.textContent ?? null;
}

		// ── XEP-0045: Multi-User Chat ─────────────────────────────────────────

		join_room(room_jid: string, nick: string) {
			const to = `${ this._esc(room_jid) }/${ this._esc(nick) }`
			this._send(
				`<presence to="${ to }">` +
				`<x xmlns="http://jabber.org/protocol/muc"/>` +
				`</presence>`
			)
		}

		leave_room(room_jid: string, nick: string) {
			this._send(`<presence to="${ this._esc(room_jid) }/${ this._esc(nick) }" type="unavailable"/>`)
		}

		send_groupchat(room_jid: string, body: string, id?: string): string {
			const msg_id = id ?? this._id()
			this._send(
				`<message to="${ this._esc(room_jid) }" type="groupchat" id="${ msg_id }">` +
				`<body>${ this._esc(body) }</body>` +
				`<markable xmlns="urn:xmpp:chat-markers:0"/>` +
				`</message>`
			)
			return msg_id
		}

		// XEP-0363: request an HTTP upload slot
		request_slot(filename: string, size: number, mime: string): Promise<{ put: string; get: string }> {
			return new Promise((resolve, reject) => {
				const id = this._id()
				this._slots.set(id, (put, get) => resolve({ put, get }))
				setTimeout(() => { if (this._slots.delete(id)) reject(new Error('Upload slot timeout')) }, 15_000)
				this._send(
					`<iq to="${ this._esc(this.upload_service) }" type="get" id="${ id }">` +
					`<request xmlns="urn:xmpp:http:upload:0"` +
					` filename="${ this._esc(filename) }"` +
					` size="${ size }"` +
					` content-type="${ this._esc(mime) }"/>` +
					`</iq>`
				)
			})
		}

		static async upload(put_url: string, file: File): Promise<void> {
			const resp = await fetch(put_url, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } })
			if (!resp.ok) throw new Error(`Upload failed: HTTP ${ resp.status }`)
		}

		// XEP-0313 for MUC: query sent TO the room, no "with" filter, optional end-timestamp to avoid duplicating join-history
		request_mam_room(room_jid: string, max = 50, before_id?: string, before_time?: number): void {
			if (!this._ws) return
			room_jid = room_jid.split('/')[0]
			const qid = this._id()
			const id  = this._id()
			this._mam_iqs.set(id, room_jid)
			const time_filter = before_time !== undefined
				? `<x xmlns="jabber:x:data" type="submit">` +
				  `<field var="FORM_TYPE" type="hidden"><value>urn:xmpp:mam:2</value></field>` +
				  `<field var="end"><value>${ this._esc(new Date(before_time - 1).toISOString()) }</value></field>` +
				  `</x>`
				: ''
			const before = before_id ? `<before>${ this._esc(before_id) }</before>` : ``
			this._send(
				`<iq to="${ this._esc(room_jid) }" type="set" id="${ id }">` +
				`<query xmlns="urn:xmpp:mam:2" queryid="${ qid }">` +
				time_filter +
				`<set xmlns="http://jabber.org/protocol/rsm">` +
				`<max>${ max }</max>${ before }` +
				`</set>` +
				`</query></iq>`
			)
		}

		// XEP-0313: request last `max` messages with a given peer; pass `before_id` for pagination
		request_mam(with_jid: string, max = 50, before_id?: string): void {
			if (!this._ws) return
			with_jid = with_jid.split('/')[0]
			const qid = this._id()
			const id  = this._id()
			this._mam_iqs.set(id, with_jid)
			const before = before_id ? `<before>${ this._esc(before_id) }</before>` : ``
			this._send(
				`<iq type="set" id="${ id }">` +
				`<query xmlns="urn:xmpp:mam:2" queryid="${ qid }">` +
				`<x xmlns="jabber:x:data" type="submit">` +
				`<field var="FORM_TYPE" type="hidden"><value>urn:xmpp:mam:2</value></field>` +
				`<field var="with"><value>${ this._esc(with_jid) }</value></field>` +
				`</x>` +
				`<set xmlns="http://jabber.org/protocol/rsm">` +
				`<max>${ max }</max>${ before }` +
				`</set>` +
				`</query></iq>`
			)
		}

		private _parse(data: string) {
			return new DOMParser().parseFromString(data, 'text/xml')
		}

		private _parse_media_sharing(el: Element) {
			const reference = el.getElementsByTagNameNS('urn:xmpp:reference:0', 'reference')[0]
			if (!reference || reference.getAttribute('type') !== 'data') return null
			const media = reference.getElementsByTagNameNS('urn:xmpp:sims:1', 'media-sharing')[0]
			if (!media) return null
			const file = media.getElementsByTagNameNS('urn:xmpp:jingle:apps:file-transfer:5', 'file')[0]
			if (!file) return null
			const name = file.getElementsByTagNameNS('urn:xmpp:jingle:apps:file-transfer:5', 'name')[0]?.textContent || undefined
			const mime = file.getElementsByTagNameNS('urn:xmpp:jingle:apps:file-transfer:5', 'media-type')[0]?.textContent || undefined
			const sizeText = file.getElementsByTagNameNS('urn:xmpp:jingle:apps:file-transfer:5', 'size')[0]?.textContent
			const hashElem = file.getElementsByTagNameNS('urn:xmpp:hashes:2', 'hash')[0]
			const sourceRef = Array.from(media.getElementsByTagNameNS('urn:xmpp:reference:0', 'reference'))
				.find(r => r.getAttribute('type') === 'data')
			const uri = sourceRef?.getAttribute('uri') || undefined
			const size = sizeText ? Number(sizeText) : undefined
			const hash = hashElem?.textContent || undefined
			const hash_algo = hashElem?.getAttribute('algo') || undefined
			const kind = mime ? media_type_from_mime(mime) : uri ? media_type(uri) : null
			return { media_uri: uri, media_mime: mime, media_name: name, media_kind: kind, media_size: size, media_hash: hash, media_hash_algo: hash_algo }
		}

		private _handle(data: string) {
			const doc = this._parse(data)
			const root = doc.documentElement
			if (!root) return
			const tag = root.localName
			const ns  = root.namespaceURI || ''

			if (tag === 'open') return
			if (tag === 'features') { this._handle_features(root); return }
			if (tag === 'success' && ns.includes('xmpp-sasl')) { this._state = 'reopen'; this._stream_open(); return }
			if (tag === 'failure') { this.on_error?.('Authentication failed — check JID and password'); return }
			if (tag === 'iq')      { this._handle_iq(root); return }
			if (tag === 'message') { this._handle_message(root); return }
			if (tag === 'presence'){ this._handle_presence(root); return }
		}

		private _handle_features(el: Element) {
			if (this._state === 'open') {
				const mechs = el.querySelector('mechanisms')
				if (!mechs) { this.on_error?.('No SASL mechanisms offered'); return }
				const ok = Array.from(mechs.querySelectorAll('mechanism')).some(m => m.textContent?.trim() === 'PLAIN')
				if (!ok) { this.on_error?.('PLAIN auth not available'); return }
				const user    = this._jid.split('@')[0]
				const encoded = btoa(unescape(encodeURIComponent(`\0${ user }\0${ this._pass }`)))
				this._send(`<auth xmlns="urn:ietf:params:xml:ns:xmpp-sasl" mechanism="PLAIN">${ encoded }</auth>`)
				this._state = 'auth'
			} else if (this._state === 'reopen') {
				const id = this._id()
				this._send(`<iq type="set" id="${ id }"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><resource>mol</resource></bind></iq>`)
				this._state = 'bind'
			}
		}

		private _handle_iq(el: Element) {
			const id = el.getAttribute('id') || ''
			if (this._avatar_meta_iqs.has(id) || this._avatar_data_iqs.has(id)) {
				try { console.log('[xmpp] ← avatar iq', new XMLSerializer().serializeToString(el)) } catch {}
			}

			// bind result
			const jid_el = el.querySelector('bind > jid')
			if (jid_el?.textContent) {
				this._jid = jid_el.textContent
				this._state = 'ready'
				// XEP-0280: enable message carbons so messages from other users always reach this resource
				this._send(`<iq type="set" id="${ this._id() }"><enable xmlns="urn:xmpp:carbons:2"/></iq>`)
				this._send(`<iq type="get" id="${ this._id() }"><query xmlns="jabber:iq:roster"/></iq>`)
				this._send('<presence/>')
				this._send(`<iq type="get" id="${ this._id() }"><query xmlns="jabber:iq:private"><storage xmlns="storage:bookmarks"/></query></iq>`)
				this._send(`<iq to="${ this._esc(this._domain) }" type="get" id="${ this._id() }"><query xmlns="http://jabber.org/protocol/disco#items"/></iq>`)
				this.on_ready?.(this._jid)
				return
			}

			// roster
			const roster_q = el.querySelector('query')
			if (roster_q?.namespaceURI === 'jabber:iq:roster') {
				const contacts: Xmpp_contact[] = Array.from(roster_q.querySelectorAll('item'))
					.map(item => ({ jid: item.getAttribute('jid') || '', name: item.getAttribute('name') || item.getAttribute('jid') || '', show: '', status: '' }))
					.filter(c => c.jid)
				this.on_roster?.(contacts)
				return
			}

			// XEP-0363 upload slot
			const slot = el.querySelector('slot')
			if (slot) {
				const put = slot.querySelector('put')?.getAttribute('url') || ''
				const get = slot.querySelector('get')?.getAttribute('url') || ''
				this._slots.get(id)?.(put, get)
				this._slots.delete(id)
				return
			}

			// XEP-0048 bookmarks
			const priv = el.querySelector('query')
			if (priv?.namespaceURI === 'jabber:iq:private') {
				const storage = priv.querySelector('storage')
				if (storage?.namespaceURI === 'storage:bookmarks') {
					const rooms = Array.from(storage.querySelectorAll('conference'))
						.map(c => ({
							jid:      c.getAttribute('jid') || '',
							name:     c.getAttribute('name') || '',
							nick:     c.querySelector('nick')?.textContent || '',
							autojoin: c.getAttribute('autojoin') === 'true' || c.getAttribute('autojoin') === '1',
						}))
						.filter(r => r.jid)
					this.on_bookmarks?.(rooms)
				}
				return
			}

			// XEP-0084 avatar metadata IQ result
			const avatar_meta_cb = this._avatar_meta_iqs.get(id)
			if (avatar_meta_cb) {
				this._avatar_meta_iqs.delete(id)
				const ps = el.querySelector('pubsub')
				const items = ps?.querySelector('items')
				if (items?.getAttribute('node') === 'urn:xmpp:avatar:metadata') {
					const info = items.querySelector('info')
					if (info && info.getAttribute('id')) {
						avatar_meta_cb({
							id:   info.getAttribute('id') || '',
							mime: info.getAttribute('type') || 'image/png',
						})
						return
					}
				}
				avatar_meta_cb(null)
				return
			}

			// XEP-0084 avatar data IQ result
			const avatar_data_cb = this._avatar_data_iqs.get(id)
			if (avatar_data_cb) {
				this._avatar_data_iqs.delete(id)
				const ps = el.querySelector('pubsub')
				const items = ps?.querySelector('items')
				if (items?.getAttribute('node') === 'urn:xmpp:avatar:data') {
					const data = items.querySelector('data')?.textContent
					avatar_data_cb(data ? data.replace(/\s+/g, '') : null)
					return
				}
				avatar_data_cb(null)
				return
			}

			// XEP-0313 fin — MAM query complete
			const fin = el.querySelector('fin')
			if (fin?.namespaceURI === 'urn:xmpp:mam:2') {
				const with_jid = this._mam_iqs.get(id)
				if (with_jid) {
					this._mam_iqs.delete(id)
					this.on_mam_fin?.(with_jid)
				}
				return
			}

			// disco#items → query each item for disco#info
			const di = el.querySelector('query[xmlns="http://jabber.org/protocol/disco#items"]')
			if (di) {
				Array.from(di.querySelectorAll('item')).forEach(item => {
					const jid = item.getAttribute('jid')
					if (jid) this._send(`<iq to="${ this._esc(jid) }" type="get" id="${ this._id() }"><query xmlns="http://jabber.org/protocol/disco#info"/></iq>`)
				})
				return
			}

			// disco#info → detect upload service
			const dinfo = el.querySelector('query[xmlns="http://jabber.org/protocol/disco#info"]')
			if (dinfo) {
				const has = Array.from(dinfo.querySelectorAll('feature')).some(f => f.getAttribute('var') === 'urn:xmpp:http:upload:0')
				if (has) this.upload_service = el.getAttribute('from') || this.upload_service
			}
		}

private _handle_message(el: Element) {
    // XEP-0280 Message Carbons — messages from other users delivered to this resource
    const carbon = this._find(el, 'urn:xmpp:carbons:2', 'received')
                 || this._find(el, 'urn:xmpp:carbons:2', 'sent');
    if (carbon) {
        const fwd = this._find(carbon, 'urn:xmpp:forward:0', 'forwarded');
        if (fwd) {
            const inner = this._find(fwd, 'jabber:client', 'message')
                       || this._find(fwd, '', 'message');
            if (inner) this._handle_message(inner);
        }
        return;
    }

    // XEP-0313 MAM result (archive history)
    const mam = this._find(el, 'urn:xmpp:mam:2', 'result');
    if (mam) { this._handle_mam(mam); return; }

    // XEP-0163 PEP push: avatar metadata updates arrive as <event> messages
    const pep_event = this._find(el, 'http://jabber.org/protocol/pubsub#event', 'event')
    if (pep_event) {
        const items = pep_event.querySelector('items')
        if (items?.getAttribute('node') === 'urn:xmpp:avatar:metadata') {
            const from = (el.getAttribute('from') || '').split('/')[0]
            const info = items.querySelector('info')
            if (from) {
                if (info && info.getAttribute('id')) {
                    this.on_avatar_meta?.(from, {
                        id:   info.getAttribute('id') || '',
                        mime: info.getAttribute('type') || 'image/png',
                    })
                } else {
                    this.on_avatar_meta?.(from, null)
                }
            }
        }
        return
    }

    // XEP-0333 chat markers — these messages may have no body
    const cm_ns = 'urn:xmpp:chat-markers:0'
    for (const kind of ['received', 'displayed', 'acknowledged'] as const) {
        const marker = this._find(el, cm_ns, kind)
        if (marker) {
            const mid = marker.getAttribute('id')
            const mfrom = (el.getAttribute('from') || '').split('/')[0]
            console.log('[xmpp] ← marker', { kind, mid, mfrom })
            if (mid && mfrom) this.on_marker?.(kind, mid, mfrom)
            return
        }
    }

    const type = el.getAttribute('type') || 'chat';
    let body = this._getBody(el);
    const media = this._parse_media_sharing(el)
    if (!body) {
        if (media?.media_name) body = `Shared file: ${ media.media_name }`
        else if (media?.media_uri) body = media.media_uri
    }
    if (!body) return;

    // XEP-0333: peer requested chat markers for this message
    if (this._find(el, cm_ns, 'markable')) {
        const mid = el.getAttribute('id')
        const mfrom = el.getAttribute('from') || ''
        console.log('[xmpp] ← markable', { mid, mfrom, type })
        if (mid && mfrom) this.on_markable?.(mid, mfrom, type === 'groupchat' ? 'groupchat' : 'chat')
    }

    // delay
    const delay = this._find(el, 'urn:xmpp:delay', 'delay');
    const stamp = delay?.getAttribute('stamp');
    const time = stamp ? new Date(stamp).getTime() : Date.now();

    if (type === 'groupchat') {
        const from_full = el.getAttribute('from') || '';
        const slash = from_full.indexOf('/');
        if (slash < 0) return;
        const room_jid = from_full.slice(0, slash);
        const nick = from_full.slice(slash + 1);
        this.on_groupchat_message?.({
            id: el.getAttribute('id') || this._id(),
            from: room_jid,
            to: (el.getAttribute('to') || '').split('/')[0],
            body, time, nick,
            ...(media || {}),
        });
        return;
    }

    if (type !== 'chat' && type !== 'normal') return;
    this.on_message?.({
        id: el.getAttribute('id') || this._id(),
        from: (el.getAttribute('from') || '').split('/')[0],
        to: (el.getAttribute('to') || '').split('/')[0],
        body, time,
        ...(media || {}),
    });
}

		private _handle_mam(result: Element) {
			const fwd  = result.querySelector('forwarded')
			if (!fwd) return
			const msg  = fwd.querySelector('message')
			if (!msg) return
			let body = msg.querySelector('body')?.textContent
			const media = this._parse_media_sharing(msg)
			if (!body) {
				if (media?.media_name) body = `Shared file: ${ media.media_name }`
				else if (media?.media_uri) body = media.media_uri
			}
			if (!body) return
			const type = msg.getAttribute('type') || 'chat'
			if (type !== 'chat' && type !== 'normal' && type !== 'groupchat') return
			const delay = fwd.querySelector('delay')
			const stamp = delay?.getAttribute('stamp')
			const time  = stamp ? new Date(stamp).getTime() : Date.now()
			const from_full = msg.getAttribute('from') || ''
			const slash     = from_full.indexOf('/')
			const from      = slash >= 0 ? from_full.slice(0, slash) : from_full
			// Only groupchat carries a meaningful nick after the slash; for 1:1 the resource is just a device id.
			const nick      = type === 'groupchat' && slash >= 0 ? from_full.slice(slash + 1) : undefined
			const stanza_id = msg.getAttribute('id') || result.getAttribute('id') || this._id()
			const mam_id = result.getAttribute('id') || stanza_id
			this.on_mam_message?.({
				id:   stanza_id,
				from,
				to:   (msg.getAttribute('to') || '').split('/')[0],
				body, time,
				mam_id,
				...(nick !== undefined ? { nick } : {}),
				...(media || {}),
			})
		}

		private _handle_presence(el: Element) {
			const from_full = el.getAttribute('from') || ''
			const type      = el.getAttribute('type') || ''

			// MUC error (e.g. join denied) — don't disconnect, just report
			if (type === 'error') {
				const err = el.querySelector('error')
				const msg = err?.firstElementChild?.localName ?? 'unknown error'
				this.on_room_error?.(`Room error: ${ msg }`)
				return
			}

			// MUC room presence — from contains resource (nick)
			const slash = from_full.indexOf('/')
			if (slash >= 0) {
				const room_jid = from_full.slice(0, slash)
				const nick = from_full.slice(slash + 1)
				// Extract real JID from MUC presence
				const muc_x = el.querySelector('x[xmlns="http://jabber.org/protocol/muc#user"]')
				const item = muc_x?.querySelector('item')
				const real_jid_full = item?.getAttribute('jid') || ''
				const real_bare = real_jid_full.split('/')[0]
				this.on_muc_presence?.(room_jid, nick, real_bare || undefined)
				return
			}

			const from = from_full || ''
			if (!from) return
			const show = type === 'unavailable' ? 'offline' : el.querySelector('show')?.textContent || 'online'
			this.on_presence?.(from, show, el.querySelector('status')?.textContent || '')
		}
	}

	// ─── $xmpp component ─────────────────────────────────────────────────────

	export class $xmpp extends $.$xmpp {

		private _conn: Xmpp_conn | null = null
		private _msg_by_id  = new Map<string, Xmpp_message>()
		private _msgs: Xmpp_message[] = []
		private _rec = new Map<string, { recorder: MediaRecorder; chunks: Blob[]; stream: MediaStream }>()
		private _history_loaded = new Set<string>()
		private _mam_oldest      = new Map<string, string>()  // jid → oldest MAM result id
		private _mam_oldest_time = new Map<string, number>()  // jid → oldest MAM result time
		private _loading_more    = new Set<string>()
		private _fin_count       = new Map<string, number>()
		private _scroll_setup    = new Set<string>()           // scroll listener installed
		private _rooms           = new Map<string, Xmpp_room>()
		private _room_occupants  = new Map<string, Map<string, string>>() // room_jid → nick → real_bare_jid
		private _oldest_time     = new Map<string, number>()  // room_jid → oldest msg timestamp
		private _poll_timer: number | null = null
		private _poll_count: number = 0
		private _ver             = 0

		private _toast_container: HTMLDivElement | null = null
		private _audio_ctx: AudioContext | null = null

		// XEP-0333 chat markers
		private _markers              = new Map<string, 'received' | 'displayed' | 'acknowledged'>() // msg id → highest received marker
		private _last_displayed_sent  = new Map<string, number>() // peer/room jid → time of last msg we marked displayed

		// XEP-0084 avatars
		private _avatars        = new Map<string, string>() // bare jid в†’ data: URI
		private _avatar_loading = new Set<string>()

		// Suppress notifications during the first 5s after a fresh connection
		// вЂ” covers servers that replay offline messages without proper <delay>.
		private _connect_at = 0

		// IndexedDB for persistent message history (via $mol_db).
		private _db: $mol_db_database<Xmpp_db_schema> | null = null
		private _db_init: Promise<$mol_db_database<Xmpp_db_schema>> | null = null
		private _loading_persisted = false

		private _auto_connect_tried = false

		@ $mol_mem
		status(next?: 'disconnected' | 'connecting' | 'connected') { return next ?? 'disconnected' }

		@ $mol_mem
		my_jid(next?: string) { return next ?? '' }

		// Persisted in localStorage so the user doesn't have to re-enter on reload.
		server(next?: string) {
			return (this.$.$mol_state_local.value('xmpp_server', next) as string | null) ?? ''
		}
		jid(next?: string) {
			return (this.$.$mol_state_local.value('xmpp_jid', next) as string | null) ?? ''
		}
		password(next?: string) {
			return (this.$.$mol_state_local.value('xmpp_password', next) as string | null) ?? ''
		}

		@ $mol_mem
		error_text(next?: string) { return next ?? '' }

		@ $mol_mem
		contacts(next?: Xmpp_contact[]) { return next ?? [] }

		// Version counter вЂ” incremented on every new message to trigger reactive re-renders.
		// Necessary because _msgs is a plain (non-reactive) array mutated in WebSocket callbacks.
		@ $mol_mem
		messages_ver(next?: number) { return next ?? 0 }

		// Bumped whenever a chat-marker arrives so msg_status() re-renders.
		@ $mol_mem
		marker_ver(next?: number) { return next ?? 0 }

		// Bumped whenever an avatar is loaded/replaced so avatar_uri() re-renders.
		@ $mol_mem
		avatar_ver(next?: number) { return next ?? 0 }

		@ $mol_mem_key
		avatar_uri(jid: string) {
			this.avatar_ver()
			return this._avatars.get(jid) ?? ''
		}

		// Stable SVG fallback: colored circle with first letter, hue derived from a hash of the seed.
		private _default_avatar(seed: string, label?: string): string {
			if (!seed) return ''
			const text = ((label || seed.split('@')[0] || seed).trim().slice(0, 1) || '?').toUpperCase()
			const safe = text.replace(/[<>&"']/g, '')
			let h = 0
			for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0
			const hue = Math.abs(h) % 360
			const bg = `hsl(${ hue },55%,50%)`
			const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64'>` +
				`<rect width='64' height='64' fill='${ bg }'/>` +
				`<text x='50%' y='54%' font-family='system-ui,sans-serif' font-size='34' font-weight='600' fill='white' text-anchor='middle' dominant-baseline='middle'>${ safe }</text>` +
				`</svg>`
			return `data:image/svg+xml,${ encodeURIComponent(svg) }`
		}

		my_avatar_uri() {
			const jid = this.my_jid().split('/')[0]
			return this.avatar_uri(jid) || this._default_avatar(jid)
		}

		contact_avatar_uri(jid: string) {
			const room = this._rooms.get(jid)
			const avatar = this.avatar_uri(jid)
			if (avatar) return avatar
			if (room) return this._default_avatar(jid, room.name || jid.split('@')[0])
			return this._default_avatar(jid)
		}

		chat_avatar_uri(jid: string) { return this.contact_avatar_uri(jid) }

		@ $mol_mem_key
		msg_avatar_uri(id: string) {
			const msg = this._msg_by_id.get(id)
			if (!msg) return ''
			if (msg.nick !== undefined) {
				const room = this._rooms.get(msg.from)
				if (room?.nick === msg.nick) return this.my_avatar_uri()
				// Try to get real JID for the nick
				const occupants = this._room_occupants.get(msg.from)
				const real_bare = occupants?.get(msg.nick)
				if (real_bare) {
					const avatar = this.avatar_uri(real_bare)
					if (avatar) return avatar
				}
				return this._default_avatar(`${ msg.from }/${ msg.nick }`, msg.nick)
			}
			return this.avatar_uri(msg.from) || this._default_avatar(msg.from)
		}

		@ $mol_mem
		rooms(next?: Xmpp_room[]) { return next ?? [] }

		@ $mol_mem
		room_jid(next?: string) { return next ?? '' }

		@ $mol_mem
		room_nick(next?: string) { return next ?? '' }

		@ $mol_mem
		new_chat_jid(next?: string) { return next ?? '' }

		@ $mol_mem
		search_query(next?: string) { return next ?? '' }

		@ $mol_mem_key
		compose(_jid: string, next?: string) { return next ?? '' }

		@ $mol_mem_key
		recording(_jid: string, next?: boolean) { return next ?? false }

		// в”Ђв”Ђ Panes (Telegram-like 3-column layout) в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ

		panes() {
			this._apply_theme()
			if (this.status() !== 'connected') {
				this._maybe_auto_connect()
				return [this.Login_pane()]
			}
			const list: $mol_view[] = [this.Folders_pane(), this.Roster_pane(), this.Chat_pane()]
			if (this.settings_open()) list.push(this.Settings_modal())
			return list
		}

		@ $mol_mem
		settings_open(next?: boolean) { return next ?? false }

		do_open_settings() { this.settings_open(true) }
		do_close_settings() { this.settings_open(false) }

		// Per-chat reactive flag вЂ” true when scroll is near the bottom of Messages_list.
		@ $mol_mem_key
		scroll_at_bottom(_jid: string, next?: boolean) { return next ?? true }

		scroll_down_hidden(jid: string): string | null {
			return this.scroll_at_bottom(jid) ? 'hidden' : null
		}

		do_scroll_down(jid: string) {
			this._scroll_to_bottom(jid)
		}

		// ── Notifications mode ────────────────────────────────────────────────

		notif_mode(next?: 'inapp' | 'system') {
			return (this.$.$mol_state_local.value('xmpp_notif_mode', next) as 'inapp' | 'system' | null) ?? 'inapp'
		}

		notif_inapp_active() { return this.notif_mode() === 'inapp' }
		notif_system_active() { return this.notif_mode() === 'system' }

		do_set_notif_inapp() { this.notif_mode('inapp') }
		do_set_notif_system() {
			this.notif_mode('system')
			if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
				void Notification.requestPermission()
			}
		}

		// ── Notification sound ────────────────────────────────────────────────

		notif_sound_uri(next?: string | null) {
			return (this.$.$mol_state_local.value('xmpp_notif_sound', next) as string | null) ?? ''
		}

		sound_status_text() {
			return this.notif_sound_uri() ? '✓ Custom sound loaded' : 'Default beep'
		}

		do_pick_sound() {
			const input = document.createElement('input')
			input.type = 'file'
			input.accept = 'audio/*'
			input.onchange = () => {
				const file = input.files?.[0]
				if (!file) return
				if (file.size > 1_000_000) {
					this.error_text('Sound file too large (max 1 MB)')
					return
				}
				const reader = new FileReader()
				reader.onload = () => {
					this.notif_sound_uri(String(reader.result))
				}
				reader.readAsDataURL(file)
			}
			input.click()
		}

		do_test_sound() { this._beep() }

		do_clear_sound() { this.notif_sound_uri(null) }

		// ── Theme ─────────────────────────────────────────────────────────────

		theme_preset(next?: string | null) {
			return (this.$.$mol_state_local.value('xmpp_theme_preset', next) as string | null) ?? 'default'
		}

		theme_hue(next?: number | null) {
			return (this.$.$mol_state_local.value('xmpp_theme_hue', next) as number | null) ?? 240
		}

		// Built-in presets keyed by hue (deg).
		private _theme_presets(): Record<string, { hue: number; label: string }> {
			return {
				default: { hue: 240, label: 'Default' },
				ocean:   { hue: 200, label: 'Ocean' },
				forest:  { hue: 130, label: 'Forest' },
				sunset:  { hue:  20, label: 'Sunset' },
				cherry:  { hue: 350, label: 'Cherry' },
				violet:  { hue: 290, label: 'Violet' },
			}
		}

		theme_preset_views() {
			const presets = this._theme_presets()
			return Object.keys(presets).map(name => this.Theme_preset(name))
		}

		theme_preset_label(name: string) { return this._theme_presets()[name]?.label ?? name }
		theme_preset_active(name: string) { return this.theme_preset() === name }

		do_apply_preset(name: string) {
			this.theme_preset(name)
			const p = this._theme_presets()[name]
			if (p) this.theme_hue(p.hue)
		}

		// Bridge between HSL hue and the <input type="color"> hex value.
		theme_color(next?: string) {
			if (next === undefined) {
				return this._hsl_to_hex(this.theme_hue(), 60, 50)
			}
			const hue = this._hex_to_hue(next)
			this.theme_preset('custom')
			this.theme_hue(hue)
			return next
		}

		private _hsl_to_hex(h: number, s: number, l: number): string {
			s /= 100; l /= 100
			const k = (n: number) => (n + h / 30) % 12
			const a = s * Math.min(l, 1 - l)
			const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))
			const to = (x: number) => Math.round(x * 255).toString(16).padStart(2, '0')
			return `#${ to(f(0)) }${ to(f(8)) }${ to(f(4)) }`
		}

		private _hex_to_hue(hex: string): number {
			const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i)
			if (!m) return 240
			const r = parseInt(m[1], 16) / 255
			const g = parseInt(m[2], 16) / 255
			const b = parseInt(m[3], 16) / 255
			const max = Math.max(r, g, b), min = Math.min(r, g, b)
			if (max === min) return 0
			const d = max - min
			let h = 0
			if (max === r) h = ((g - b) / d) % 6
			else if (max === g) h = (b - r) / d + 2
			else h = (r - g) / d + 4
			h = Math.round(h * 60)
			if (h < 0) h += 360
			return h
		}

		// Reactively apply theme variables to the document root.
		@ $mol_mem
		_apply_theme() {
			if (typeof document === 'undefined') return
			const hue = this.theme_hue()
			document.documentElement.style.setProperty('--mol_theme_hue', `${ hue }deg`)
		}

		chat_pane_content() {
			const peer = this.$.$mol_state_arg.value('chat')
			if (!peer) return [this.Chat_placeholder()]
			this._maybe_load_history(peer)
			return [this.Chat_view(peer)]
		}

		folder() { return (this.$.$mol_state_arg.value('folder') as string | null) ?? '' }

		chat() { return (this.$.$mol_state_arg.value('chat') as string | null) ?? '' }

		// ── User folders (drag chat → drop on +zone or existing folder) ──────

		@ $mol_mem
		folders_ver(next?: number) { return next ?? 0 }

		// ── Pins per folder ─────────────────────────────────────────────────

		@ $mol_mem
		pins_ver(next?: number) { return next ?? 0 }

		// Folder key used in pin storage. Empty/null URL arg → 'all'.
		private _folder_key(): string {
			const f = this.folder()
			return f ? f : 'all'
		}

		private _pins_obj(): Record<string, string[]> {
			this.pins_ver()
			return (this.$.$mol_state_local.value('xmpp_pins') as Record<string, string[]> | null) ?? {}
		}

		private _save_pins(obj: Record<string, string[]>) {
			this.$.$mol_state_local.value('xmpp_pins', obj)
			this.pins_ver(this.pins_ver() + 1)
		}

		pinned_jids(folder: string): string[] {
			return this._pins_obj()[folder] ?? []
		}

		contact_pinned(jid: string): boolean {
			return this.pinned_jids(this._folder_key()).includes(jid)
		}

		toggle_pin(jid: string) {
			const folder = this._folder_key()
			const obj = { ...this._pins_obj() }
			const arr = [...(obj[folder] ?? [])]
			const idx = arr.indexOf(jid)
			if (idx >= 0) arr.splice(idx, 1)
			else arr.unshift(jid)
			if (arr.length === 0) delete obj[folder]
			else obj[folder] = arr
			this._save_pins(obj)
		}

		// Drop handler on a contact row: reorder pins. `target_jid` is this row's jid (anchor),
		// `source_jid` is the dragged contact. Insert source before target in the pin list.
		reorder_pin(target_jid: string, source_jid: string) {
			if (!source_jid || target_jid === source_jid) return
			const folder = this._folder_key()
			const obj = { ...this._pins_obj() }
			let arr = [...(obj[folder] ?? [])].filter(j => j !== source_jid)
			const idx = arr.indexOf(target_jid)
			if (idx >= 0) {
				arr.splice(idx, 0, source_jid)
			} else {
				// Target not pinned → pin it first, then place source above it.
				arr.push(target_jid)
				arr.unshift(source_jid)
			}
			obj[folder] = arr
			this._save_pins(obj)
		}

		private _folders_obj(): Record<string, string[]> {
			return (this.$.$mol_state_local.value('xmpp_folders') as Record<string, string[]> | null) ?? {}
		}

		private _save_folders(obj: Record<string, string[]>) {
			this.$.$mol_state_local.value('xmpp_folders', obj)
			this.folders_ver(this.folders_ver() + 1)
		}

		folders_list(): string[] {
			this.folders_ver()
			return Object.keys(this._folders_obj()).sort()
		}

		private _add_to_folder(name: string, jid: string) {
			const obj = { ...this._folders_obj() }
			const arr = [...(obj[name] ?? [])]
			if (!arr.includes(jid)) arr.push(jid)
			obj[name] = arr
			this._save_folders(obj)
		}

		private _remove_from_folder(name: string, jid: string) {
			const obj = { ...this._folders_obj() }
			const arr = (obj[name] ?? []).filter(j => j !== jid)
			if (arr.length === 0) delete obj[name]
			else obj[name] = arr
			this._save_folders(obj)
		}

		// View bindings for the user folder list rendered in the left pane.
		user_folder_views() {
			return this.folders_list().map(name => this.User_folder(name))
		}

		user_folder_name(name: string) { return name }
		user_folder_label(name: string) { return name }

		delete_folder(name: string) {
			const obj = this._folders_obj()
			delete obj[name]
			this._save_folders(obj)
			if (this.folder() === name) this.$.$mol_state_arg.value('folder', null)
		}

		// ── Roster header (shows folder name; user folders are renamable + deletable) ──

		roster_header_sub() {
			const f = this.folder()
			if (!f || f === 'chats' || f === 'rooms') return [this.Roster_static_header()]
			return [this.Roster_folder_header()]
		}

		roster_static_text() {
			const f = this.folder()
			if (f === 'chats') return 'Chats'
			if (f === 'rooms') return 'Rooms'
			return 'All'
		}

		roster_folder_input(next?: string) {
			const cur = this.folder()
			if (next === undefined) return cur
			const trimmed = next.trim()
			if (!trimmed || trimmed === cur) return cur
			if (cur && cur !== 'chats' && cur !== 'rooms') {
				const obj = this._folders_obj()
				if (obj[cur] && !obj[trimmed]) {
					obj[trimmed] = obj[cur]
					delete obj[cur]
					this._save_folders(obj)
					this.$.$mol_state_arg.value('folder', trimmed)
				}
			}
			return trimmed
		}

		do_delete_current_folder() {
			const f = this.folder()
			if (!f || f === 'chats' || f === 'rooms') return
			const confirmed = window.prompt(`Type "${ f }" to confirm deletion of this folder`)
			if (!confirmed || confirmed.trim() !== f) return
			this.delete_folder(f)
		}

		// $mol_drag/$mol_drop integration.
		roster_jid(jid: string) { return jid }

		folder_adopt(transfer: DataTransfer): string | null {
			const jid = transfer.getData('text/plain')
			return jid || null
		}

		folder_receive(name: string, jid: string) {
			if (!jid) return
			const obj = this._folders_obj()
			const arr = obj[name] ?? []
			if (arr.includes(jid)) this._remove_from_folder(name, jid)
			else this._add_to_folder(name, jid)
		}

		new_folder_receive(jid: string) {
			if (!jid) return
			const name = (window.prompt('Folder name')?.trim()) ?? ''
			if (!name) return
			this._add_to_folder(name, jid)
		}


		// On first render, if we have saved credentials and aren't connecting/connected, kick off auto-connect.
		private _maybe_auto_connect() {
			if (this._auto_connect_tried) return
			if (this.status() !== 'disconnected') return
			const url = this.server().trim()
			const jid = this.jid().trim()
			const pw  = this.password()
			if (!url || !jid || !pw) return
			this._auto_connect_tried = true
			// Defer to escape the current reactive fiber — do_connect mutates memos.
			Promise.resolve().then(() => {
				if (this.status() === 'disconnected') this.do_connect()
			})
		}

		// ── Login ─────────────────────────────────────────────────────────────

		connecting() { return this.status() === 'connecting' }
		connect_title() { return this.connecting() ? 'Connecting…' : 'Connect' }

		do_connect() {
			const url = this.server().trim()
			const jid = this.jid().trim()
			const pw  = this.password()
			if (!url || !jid || !pw) { this.error_text('Please fill in all fields'); return }

			this.error_text('')
			this.status('connecting')
			// Prime AudioContext on user gesture so beep() can play later (browsers block autoplay)
			try {
				const Ctor = (window as any).AudioContext || (window as any).webkitAudioContext
				if (Ctor && !this._audio_ctx) this._audio_ctx = new Ctor()
				if (this._audio_ctx?.state === 'suspended') void this._audio_ctx.resume()
			} catch {}
			const conn = new Xmpp_conn()
			this._conn = conn

			conn.on_ready = bound_jid => {
				this.my_jid(bound_jid)
				this.status('connected')
				this._connect_at = Date.now()
				this._start_polling()
				void this._load_persisted()
			}
			conn.on_bookmarks = bookmarks => {
				const my_nick = this.my_jid().split('@')[0]
				for (const b of bookmarks) {
					if (this._rooms.has(b.jid)) continue
					const nick = b.nick || my_nick
					const room: Xmpp_room = { jid: b.jid, name: b.name || b.jid.split('@')[0], nick }
					this._rooms.set(b.jid, room)
					void this._load_avatar(b.jid)
					if (b.autojoin) this._conn?.join_room(b.jid, nick)
				}
				this.rooms([ ...this._rooms.values() ])
			}
			conn.on_roster   = cs  => {
				this.contacts(cs)
				cs.forEach(c => void this._load_avatar(c.jid))
				const my_bare = this.my_jid().split('/')[0]
				if (my_bare) void this._load_avatar(my_bare)
			}
			conn.on_avatar_meta = (from, info) => {
				if (!info || !info.id) {
					if (this._avatars.delete(from)) this.avatar_ver(this.avatar_ver() + 1)
					return
				}
				void this._fetch_avatar_with(from, info.id, info.mime)
			}
			conn.on_muc_presence = (room_jid, nick, real_bare) => {
				if (real_bare) {
					let occupants = this._room_occupants.get(room_jid)
					if (!occupants) {
						occupants = new Map()
						this._room_occupants.set(room_jid, occupants)
					}
					occupants.set(nick, real_bare)
					void this._load_avatar(real_bare)
				}
			}
			conn.on_message  = msg => {
				if (this._msg_by_id.has(msg.id)) return  // ← FIX: skip duplicates
				msg.read = false
				this._add_message(msg)
				const bare = this.my_jid().split('/')[0]
				const peer = msg.from === bare ? msg.to : msg.from
				this._scroll_to_bottom(peer)
				// Skip notification for delayed/offline messages (server replays history on reconnect)
				if (msg.from !== bare && Date.now() - msg.time < 30_000) {
					const c = this.contacts().find(x => x.jid === msg.from)
					const name = c?.name && c.name !== msg.from ? c.name : msg.from
					this._notify(name, msg.body, msg.from)
				}
			}
			conn.on_groupchat_message = msg => {
				if (this._msg_by_id.has(msg.id)) return
				msg.read = false
				this._add_message(msg)
				this._scroll_to_bottom(msg.from)
				const room = this._rooms.get(msg.from)
				if (room && msg.nick && msg.nick !== room.nick && Date.now() - msg.time < 30_000) {
					const title = `${ room.name || msg.from } — ${ msg.nick }`
					this._notify(title, msg.body, msg.from)
				}
			}
			conn.on_mam_message = msg => {
				if (this._msg_by_id.has(msg.id)) return
				this._add_message(msg)
				// track oldest MAM result id per peer for pagination
				const bare = this.my_jid().split('/')[0]
				const peer = msg.from === bare ? msg.to : msg.from
				if (peer) {
					const oldest_id = msg.mam_id || msg.id
					const cur_time = this._mam_oldest_time.get(peer) ?? Infinity
					if (msg.time < cur_time) {
						this._mam_oldest.set(peer, oldest_id)
						this._mam_oldest_time.set(peer, msg.time)
					}
				}
				// MAM-on-delivery: server may wrap live messages in <result>; notify if recent
				if (Date.now() - msg.time < 30_000 && msg.from !== bare) {
					if (msg.nick !== undefined) {
						const room = this._rooms.get(msg.from)
						if (room && msg.nick !== room.nick) {
							this._notify(`${ room.name || msg.from } — ${ msg.nick }`, msg.body, msg.from)
						}
					} else {
						const c = this.contacts().find(x => x.jid === msg.from)
						const name = c?.name && c.name !== msg.from ? c.name : msg.from
						this._notify(name, msg.body, msg.from)
					}
				}
			}
			conn.on_markable = (msg_id, from, type) => {
				const my_bare = this.my_jid().split('/')[0]
				const from_bare = from.split('/')[0]
				if (from_bare === my_bare) return // skip our own echoes
				if (!this._conn) return
				if (type === 'chat') {
					this._conn.send_marker(from_bare, 'received', msg_id, 'chat')
				}
				// MUC: per-occupant markers go through private messages — skipping for now
			}
			conn.on_marker = (kind, msg_id, _from) => {
				const priority = { received: 1, displayed: 2, acknowledged: 3 } as const
				const cur = this._markers.get(msg_id)
				if (!cur || priority[kind] > priority[cur]) {
					this._markers.set(msg_id, kind)
					this.marker_ver(this.marker_ver() + 1)
				}
			}
			conn.on_mam_fin = with_jid => {
				this._loading_more.delete(with_jid)
				const cnt = (this._fin_count.get(with_jid) ?? 0) + 1
				this._fin_count.set(with_jid, cnt)
				if (cnt === 1) this._scroll_to_bottom(with_jid)
			}
			conn.on_presence = (from, show, stat) => {
				this.contacts(this.contacts().map(c => c.jid === from ? { ...c, show, status: stat } : c))
			}
			conn.on_room_error = (err) => {
				this.error_text(err)
				// remove optimistically-added room whose join was rejected
				const current = this.$.$mol_state_arg.value('chat')
				if (current && this._rooms.has(current)) {
					this._rooms.delete(current)
					this.rooms([ ...this._rooms.values() ])
					this.$.$mol_state_arg.value('chat', null)
				}
			}
			conn.on_error = err => { this.error_text(err); this.status('disconnected') }
			conn.on_close = ()  => {
				if (this.status() === 'connecting') this.error_text('Connection closed')
				this.status('disconnected')
			}
			conn.connect(url, jid, pw)
		}

		do_disconnect() {
			this._conn?.disconnect()
			this._conn = null
			this.status('disconnected')
			this.my_jid('')
			this.contacts([])
			this._msgs = []
			this._msg_by_id.clear()
			this._history_loaded.clear()
			this._mam_oldest.clear()
			this._loading_more.clear()
			this._fin_count.clear()
			this._scroll_setup.clear()
			this._rooms.clear()
			this.rooms([])
			this._oldest_time.clear()
			this._stop_polling()
			this.messages_ver(0)
			this.$.$mol_state_arg.value('chat', null)
		}

		// ── Roster ────────────────────────────────────────────────────────────

		@ $mol_mem
		roster_rows() {
			this.folders_ver()
			this.pins_ver()
			const folder = this.folder()
			const contacts = this.contacts()
			const rooms = this.rooms()
			let jids: string[] = []
			if (folder === 'chats') jids = contacts.map(c => c.jid)
			else if (folder === 'rooms') jids = rooms.map(r => r.jid)
			else if (!folder) jids = [...contacts.map(c => c.jid), ...rooms.map(r => r.jid)]
			else jids = this._folders_obj()[folder] ?? []
			const q = this.search_query().trim().toLowerCase()
			if (q) {
				jids = jids.filter(jid => {
					const c = contacts.find(x => x.jid === jid)
					const r = this._rooms.get(jid)
					const name = (c?.name || r?.name || jid).toLowerCase()
					return jid.toLowerCase().includes(q) || name.includes(q)
				})
			}
			// Pinned at top in pin order, rest below preserving original order.
			const pinned_set = new Set(this.pinned_jids(this._folder_key()))
			const pinned = this.pinned_jids(this._folder_key()).filter(j => jids.includes(j))
			const rest = jids.filter(j => !pinned_set.has(j))
			return [...pinned, ...rest].map(j => this.Roster_contact(j))
		}

		// Suggestion buttons appear when the search query looks like a JID and isn't already in the list.
		search_actions_sub() {
			const q = this.search_query().trim()
			if (!q.includes('@')) return []
			const has_existing = this._rooms.has(q) || this.contacts().some(c => c.jid === q)
			return has_existing ? [] : [this.Search_action_chat(), this.Search_action_room()]
		}

		search_action_chat_title() { return `+ Start chat with ${ this.search_query().trim() }` }
		search_action_room_title() { return `# Join room ${ this.search_query().trim() }` }

		do_search_chat() {
			const jid = this.search_query().trim()
			if (!jid.includes('@')) return
			this.search_query('')
			this.$.$mol_state_arg.value('chat', jid)
		}

		do_search_room() {
			const jid = this.search_query().trim()
			if (!jid.includes('@') || !this._conn) return
			const nick = this.my_jid().split('@')[0]
			this._conn.join_room(jid, nick)
			const room: Xmpp_room = { jid, name: jid.split('@')[0], nick }
			this._rooms.set(jid, room)
			void this._load_avatar(jid)
			this.rooms([ ...this._rooms.values() ])
			this.search_query('')
			this.$.$mol_state_arg.value('chat', jid)
		}

		contact_display(jid: string) {
			const room = this._rooms.get(jid)
			if (room) return `# ${ room.name || jid.split('@')[0] }`
			const c    = this.contacts().find(x => x.jid === jid)
			const name = c?.name && c.name !== jid ? c.name : jid
			const dot  = c?.show === 'offline' ? ' ●' : c?.show ? ' ○' : ''
			return name + dot
		}

		contact_name(jid: string) {
			const room = this._rooms.get(jid)
			if (room) return `# ${ room.name || jid.split('@')[0] }`
			const c    = this.contacts().find(x => x.jid === jid)
			return c?.name && c.name !== jid ? c.name : jid
		}

		@ $mol_mem_key
		last_message(jid: string) {
			this.messages_ver()
			const bare = this.my_jid().split('/')[0]
			const msgs = this._msgs
				.filter(m => m.from === jid || m.to === jid
					|| (m.from === bare && m.to === jid)
					|| (m.from === jid  && m.to === bare))
				.sort((a, b) => a.time - b.time)
			const last = msgs[msgs.length - 1]
			return last ? (last.body || '[media]') : ''
		}

		@ $mol_mem_key
		unread_indicator(jid: string) {
			this.messages_ver()
			const current_chat = this.chat()
			if (current_chat === jid && this.scroll_at_bottom(jid)) return false
			const bare = this.my_jid().split('/')[0]
			const msgs = this._msgs
				.filter(m => m.from === jid || m.to === jid
					|| (m.from === bare && m.to === jid)
					|| (m.from === jid  && m.to === bare))
			return msgs.some(m => m.from !== bare && !m.read)
		}

		@ $mol_mem_key
		unread_dot(jid: string) {
			return this.unread_indicator(jid) ? '●' : ''
		}

		open_chat(jid: string) {
			this.$.$mol_state_arg.value('chat', jid)
			this._mark_last_read(jid)
		}

		private _mark_last_read(jid: string) {
			const bare = this.my_jid().split('/')[0]
			const msgs = this._msgs
				.filter(m => m.from === jid || m.to === jid
					|| (m.from === bare && m.to === jid)
					|| (m.from === jid  && m.to === bare))
			const unread_msgs = msgs.filter(m => m.from !== bare && !m.read)
			for (const msg of unread_msgs) {
				msg.read = true
				void this._persist_msg(msg)
			}
			if (unread_msgs.length > 0) {
				this.messages_ver(Date.now())
			}
		}

		do_new_chat() {
			const jid = this.new_chat_jid().trim()
			if (!jid || !jid.includes('@')) {
				this.error_text('Enter a valid JID like user@server')
				return
			}
			this.new_chat_jid('')
			this.$.$mol_state_arg.value('chat', jid)
		}

		// ── Rooms ─────────────────────────────────────────────────────────────

		do_join_room() {
			const jid  = this.room_jid().trim()
			const nick = this.room_nick().trim() || this.my_jid().split('@')[0]
			if (!jid || !this._conn) return
			this._conn.join_room(jid, nick)
			const room: Xmpp_room = { jid, name: jid.split('@')[0], nick }
			this._rooms.set(jid, room)
			void this._load_avatar(jid)
			this.rooms([ ...this._rooms.values() ])
			this.room_jid('')
			this.$.$mol_state_arg.value('chat', jid)
		}

		do_leave_room(jid: string) {
			const room = this._rooms.get(jid)
			if (!room) return
			this._conn?.leave_room(jid, room.nick)
			this._rooms.delete(jid)
			this._room_occupants.delete(jid)
			this.rooms([ ...this._rooms.values() ])
			this._msgs = this._msgs.filter(m => m.from !== jid && m.to !== jid)
			this._msg_by_id.forEach((m, k) => { if (m.from === jid || m.to === jid) this._msg_by_id.delete(k) })
			this._scroll_setup.delete(jid)
			this._oldest_time.delete(jid)
			this._mam_oldest.delete(jid)
			this._mam_oldest_time.delete(jid)
			this._history_loaded.delete(jid)
			this._fin_count.delete(jid)
			this.messages_ver(this.messages_ver() + 1)
			this.$.$mol_state_arg.value('chat', null)
		}

		chat_leave_hidden(jid: string): any {
			return this._rooms.has(jid) ? null : ''
		}

		// ── Chat ──────────────────────────────────────────────────────────────

		chat_with(jid: string) {
			const room = this._rooms.get(jid)
			return room ? `# ${ room.name }` : jid
		}

		// @$mol_mem_key ensures this becomes a reactive node:
		// when messages_ver() changes, the list re-renders automatically.
		@ $mol_mem_key
		message_rows(jid: string) {
			this.messages_ver() // track the version counter
			const bare = this.my_jid().split('/')[0]
			return this._msgs
				.filter(m => m.from === jid || m.to === jid
					|| (m.from === bare && m.to === jid)
					|| (m.from === jid  && m.to === bare))
				.sort((a, b) => a.time - b.time)
				.map(m => this.Msg(m.id))
		}

		// ── Message items ─────────────────────────────────────────────────────

		@ $mol_mem_key  // ← FIX: make reactive
		msg_from(id: string) {
			const msg = this._msg_by_id.get(id)
			if (!msg) return ''
			if (msg.nick !== undefined) {
				// MUC: show "You" if the nick matches our room nick
				const room = this._rooms.get(msg.from)
				return room?.nick === msg.nick ? 'You' : msg.nick
			}
			return msg.from === this.my_jid().split('/')[0] ? 'You' : msg.from
		}

		@ $mol_mem_key  // ← FIX: make reactive
		msg_body(id: string) {
			const msg = this._msg_by_id.get(id)
			if (!msg) return ''
			if (msg.media_uri || msg.media_kind) return ''
			const body = msg.body ?? ''
			return media_type(body) ? '' : body
		}

		@ $mol_mem_key  // ← FIX: make reactive
		msg_media(id: string): $mol_view[] {
			const msg = this._msg_by_id.get(id)
			if (!msg) return []
			const uri = msg.media_uri || msg.body.trim()
			const type = msg.media_kind ?? (msg.media_uri ? media_type(uri) : media_type(msg.body.trim()))
			if (type === 'image') return [this.Msg_image(id)]
			if (type === 'audio') return [this.Msg_audio(id)]
			if (type === 'link')  return [this.Msg_link(id)]
			return []
		}

		@ $mol_mem_key  // ← FIX: make reactive
		msg_image_uri(id: string) { const msg = this._msg_by_id.get(id); return msg?.media_uri || (msg?.body.trim() ?? '') }

		@ $mol_mem_key  // ← FIX: make reactive
		msg_audio_src(id: string) { const msg = this._msg_by_id.get(id); return msg?.media_uri || (msg?.body.trim() ?? '') }

		@ $mol_mem_key  // ← FIX: make reactive
		msg_link_uri(id: string)  { const msg = this._msg_by_id.get(id); return msg?.media_uri || (msg?.body.trim() ?? '') }

		@ $mol_mem_key
		msg_time(id: string) {
			const msg = this._msg_by_id.get(id)
			if (!msg) return ''
			const d = new Date(msg.time)
			const same_day = (() => {
				const now = new Date()
				return d.getFullYear() === now.getFullYear()
					&& d.getMonth() === now.getMonth()
					&& d.getDate() === now.getDate()
			})()
			const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
			if (same_day) return time
			const date = d.toLocaleDateString([], { day: '2-digit', month: '2-digit' })
			return `${ date } ${ time }`
		}

		// XEP-0333 status for outgoing messages.
		// ✓ — sent locally; ✓✓ — peer received; ✓✓ (filled) — peer displayed.
		@ $mol_mem_key
		msg_status(id: string) {
			this.marker_ver()
			const msg = this._msg_by_id.get(id)
			if (!msg) return ''
			const bare = this.my_jid().split('/')[0]
			if (msg.from !== bare) return ''
			const m = this._markers.get(id)
			if (m === 'displayed' || m === 'acknowledged') return '✔✔'
			if (m === 'received') return '✓✓'
			return '✓'
		}

		// ── Compose input event handlers ──────────────────────────────────────

		// Enter sends; Shift+Enter inserts a newline (browser default).
		compose_keydown(jid: string, e?: Event | null) {
			const ke = e as KeyboardEvent | null | undefined
			if (!ke) return null
			if (ke.key === 'Enter' && !ke.shiftKey && !ke.ctrlKey && !ke.altKey && !ke.metaKey) {
				ke.preventDefault()
				this.do_send(jid)
			}
			return null
		}

		// Paste image / file from clipboard → upload via XEP-0363 and send the link.
		compose_paste(jid: string, e?: Event | null) {
			const ce = e as ClipboardEvent | null | undefined
			const items = ce?.clipboardData?.items
			if (!items) return null
			for (const item of Array.from(items)) {
				if (item.kind === 'file') {
					const file = item.getAsFile()
					if (file) {
						ce!.preventDefault()
						void this._upload_and_send(jid, file).catch(err => this.error_text(String(err)))
						return null
					}
				}
			}
			return null
		}

		// ── Send text ─────────────────────────────────────────────────────────

		do_send(jid: string) {
			const text = this.compose(jid).trim()
			if (!text || !this._conn) return

			if (this._rooms.has(jid)) {
				this.compose(jid, '')
			const id = `l${ Date.now() }_${ Math.random().toString(36).slice(2) }`
			this._conn.send_groupchat(jid, text, id)
			this._add_message({ id, from: this.my_jid().split('/')[0], to: jid, body: text, time: Date.now(), read: true })
			}

			this.compose(jid, '')
			const id = `l${ Date.now() }_${ Math.random().toString(36).slice(2) }`
			this._conn.send_message(jid, text, id)
			this._add_message({ id, from: this.my_jid().split('/')[0], to: jid, body: text, time: Date.now(), read: true })
			this._scroll_to_bottom(jid)
		}

		// ── Attach file (XEP-0363) ────────────────────────────────────────────

		do_attach(jid: string) {
			const input = document.createElement('input')
			input.type = 'file'
			input.accept = 'image/*,audio/*,video/*,*/*'
			input.onchange = () => {
				const file = input.files?.[0]
				if (file) void this._upload_and_send(jid, file).catch(e => this.error_text(String(e)))
			}
			input.click()
		}

		// ── Voice recording ───────────────────────────────────────────────────

		record_title(jid: string) { return this.recording(jid) ? '⏹ Stop' : '🎤 Record' }

		do_record(jid: string) {
			if (this.recording(jid)) {
				this._rec.get(jid)?.recorder.stop()
				return
			}
			navigator.mediaDevices.getUserMedia({ audio: true })
				.then(stream => {
					const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm'
					const chunks: Blob[] = []
					const recorder = new MediaRecorder(stream, { mimeType: mime })
					this._rec.set(jid, { recorder, chunks, stream })
					recorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data) }
					recorder.onstop = () => {
						stream.getTracks().forEach(t => t.stop())
						this._rec.delete(jid)
						this.recording(jid, false)
						const ext  = mime.includes('ogg') ? 'ogg' : 'webm'
						const file = new File(chunks, `voice_${ Date.now() }.${ ext }`, { type: mime })
						void this._upload_and_send(jid, file).catch(e => this.error_text(String(e)))
					}
					recorder.start()
					this.recording(jid, true)
				})
				.catch(() => this.error_text('Microphone access denied'))
		}

		// ── Helpers ───────────────────────────────────────────────────────────

		private _scroll_to_bottom(jid: string) {
			requestAnimationFrame(() => {
				try {
					const el = this.Messages_list(jid).dom_node()
					el.scrollTop = el.scrollHeight
				} catch {}
			})
		}

		// XEP-0084 avatar fetch: pulls the latest metadata then the data blob.
		private async _load_avatar(jid: string) {
			if (!jid || !this._conn) return
			if (this._avatars.has(jid) || this._avatar_loading.has(jid)) return
			this._avatar_loading.add(jid)
			try {
				const meta = await this._conn.fetch_avatar_metadata(jid)
				console.log('[xmpp] avatar meta', jid, meta)
				if (!meta || !meta.id) return
				await this._fetch_avatar_with(jid, meta.id, meta.mime)
			} catch (e) {
				console.warn('[xmpp] avatar load failed', jid, e)
			} finally {
				this._avatar_loading.delete(jid)
			}
		}

		private async _fetch_avatar_with(jid: string, hash: string, mime: string) {
			if (!this._conn) return
			const b64 = await this._conn.fetch_avatar_data(jid, hash)
			console.log('[xmpp] avatar data', jid, hash, b64 ? `${ b64.length }b` : 'null')
			if (!b64) return
			this._avatars.set(jid, `data:${ mime || 'image/png' };base64,${ b64 }`)
			this.avatar_ver(this.avatar_ver() + 1)
		}

		// User picks an image — compute SHA-1, base64-encode, publish to PEP.
		do_set_avatar() {
			console.log('[xmpp] do_set_avatar called, conn=', !!this._conn)
			if (!this._conn) return
			const input = document.createElement('input')
			input.type = 'file'
			input.accept = 'image/*'
			input.onchange = async () => {
				const file = input.files?.[0]
				console.log('[xmpp] avatar file picked', file)
				if (!file || !this._conn) return
				try {
					const buf = await file.arrayBuffer()
					const hash_buf = await crypto.subtle.digest('SHA-1', buf)
					const hash = Array.from(new Uint8Array(hash_buf))
						.map(b => b.toString(16).padStart(2, '0')).join('')
					const bytes = new Uint8Array(buf)
					let bin = ''
					for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
					const b64 = btoa(bin)
					const mime = file.type || 'image/png'
					console.log('[xmpp] publish_avatar', { hash, mime, bytes: file.size, b64_len: b64.length })
					this._conn.publish_avatar(hash, b64, mime, file.size)
					const my_bare = this.my_jid().split('/')[0]
					if (my_bare) {
						this._avatars.set(my_bare, `data:${ mime };base64,${ b64 }`)
						this.avatar_ver(this.avatar_ver() + 1)
						console.log('[xmpp] my avatar set, my_bare=', my_bare)
					}
				} catch (e) {
					this.error_text('Avatar upload failed: ' + String(e))
				}
			}
			input.click()
		}

		// In-app notifications (toasts) + sound, fired only when the message is NOT in the open chat.
		private _notify(title: string, body: string, peer: string) {
			// Grace period after a fresh connection — server may replay offline messages without <delay>
			if (this._connect_at && Date.now() - this._connect_at < 5_000) return
			const open_peer = this.$.$mol_state_arg.value('chat')
			const visible   = typeof document !== 'undefined' && document.visibilityState === 'visible'
			if (open_peer === peer && visible) return
			if (this.notif_mode() === 'system') this._system_notify(title, body, peer)
			else this._show_toast(peer, title, body)
			this._beep()
		}

		private _system_notify(title: string, body: string, peer: string) {
			if (typeof Notification === 'undefined') {
				this._show_toast(peer, title, body)
				return
			}
			if (Notification.permission === 'default') {
				void Notification.requestPermission().then(p => {
					if (p === 'granted') this._system_notify(title, body, peer)
					else this._show_toast(peer, title, body)
				})
				return
			}
			if (Notification.permission !== 'granted') {
				this._show_toast(peer, title, body)
				return
			}
			try {
				const n = new Notification(title, { body, tag: peer, requireInteraction: true })
				n.onclick = () => {
					try { window.focus() } catch {}
					this.$.$mol_state_arg.value('chat', peer)
					n.close()
				}
			} catch {
				this._show_toast(peer, title, body)
			}
		}

		private _ensure_toast_container(): HTMLDivElement {
			if (this._toast_container && this._toast_container.isConnected) return this._toast_container
			const c = document.createElement('div')
			c.setAttribute('xmpp_Toasts', '')
			document.body.appendChild(c)
			this._toast_container = c
			return c
		}

		private _show_toast(peer: string, title: string, body: string) {
			const c = this._ensure_toast_container()
			const t = document.createElement('div')
			t.setAttribute('xmpp_Toast', '')
			t.setAttribute('role', 'button')
			t.tabIndex = 0
			const t_title = document.createElement('div')
			t_title.setAttribute('xmpp_Toast_title', '')
			t_title.textContent = title
			const t_body = document.createElement('div')
			t_body.setAttribute('xmpp_Toast_body', '')
			t_body.textContent = body
			const t_close = document.createElement('div')
			t_close.setAttribute('xmpp_Toast_close', '')
			t_close.setAttribute('role', 'button')
			t_close.setAttribute('aria-label', 'Close')
			t_close.textContent = '×'
			t.appendChild(t_close)
			t.appendChild(t_title)
			t.appendChild(t_body)
			const dismiss = () => { if (t.parentNode) t.parentNode.removeChild(t) }
			t.onclick = () => { this.$.$mol_state_arg.value('chat', peer); dismiss() }
			t_close.onclick = e => { e.stopPropagation(); dismiss() }
			c.appendChild(t)
			setTimeout(dismiss, 10000)
		}

		private _beep() {
			const custom = this.notif_sound_uri()
			if (custom) {
				try {
					const audio = new Audio(custom)
					audio.volume = 0.5
					void audio.play().catch(() => {})
					return
				} catch {}
			}
			try {
				const Ctor = (window as any).AudioContext || (window as any).webkitAudioContext
				if (!Ctor) return
				if (!this._audio_ctx) this._audio_ctx = new Ctor()
				const ctx = this._audio_ctx!
				if (ctx.state === 'suspended') void ctx.resume()
				const t   = ctx.currentTime
				const osc = ctx.createOscillator()
				const g   = ctx.createGain()
				osc.type = 'sine'
				osc.frequency.setValueAtTime(880, t)
				osc.frequency.exponentialRampToValueAtTime(660, t + 0.18)
				g.gain.setValueAtTime(0.0001, t)
				g.gain.exponentialRampToValueAtTime(0.25, t + 0.02)
				g.gain.exponentialRampToValueAtTime(0.0001, t + 0.35)
				osc.connect(g); g.connect(ctx.destination)
				osc.start(t); osc.stop(t + 0.4)
			} catch (e) { console.warn('[xmpp] beep failed', e) }
		}

		private _load_more_history(jid: string) {
			if (this._loading_more.has(jid) || !this._conn) return
			let oldest_id = this._mam_oldest.get(jid)
			if (!oldest_id) {
				const oldest_msg = this._msgs
					.filter(m => m.from === jid || m.to === jid || (m.from === this.my_jid().split('/')[0] && m.to === jid) || (m.from === jid && m.to === this.my_jid().split('/')[0]))
					.sort((a, b) => a.time - b.time)[0]
				if (oldest_msg?.mam_id) oldest_id = oldest_msg.mam_id
			}
			this._loading_more.add(jid)
			if (this._rooms.has(jid)) {
				// cursor available after first MAM batch; otherwise use timestamp to avoid duplicating join-history
				const before_time = oldest_id ? undefined : this._oldest_time.get(jid)
				this._conn.request_mam_room(jid, 50, oldest_id, before_time)
			} else {
				if (!oldest_id) { this._loading_more.delete(jid); return }
				this._conn.request_mam(jid, 50, oldest_id)
			}
		}

		private _setup_scroll_listener(jid: string) {
			if (this._scroll_setup.has(jid)) return
			this._scroll_setup.add(jid)
			requestAnimationFrame(() => {
				try {
					const el = this.Messages_list(jid).dom_node() as HTMLElement
					const update_at_bottom = () => {
						const near = el.scrollHeight - el.scrollTop - el.clientHeight < 100
						this.scroll_at_bottom(jid, near)
					}
					update_at_bottom()
					el.addEventListener('scroll', () => {
						if (el.scrollTop < 200) this._load_more_history(jid)
						update_at_bottom()
					}, { passive: true })
					// Late-loading images push content; if user is near the bottom, follow.
					el.addEventListener('load', e => {
						if (!(e.target instanceof HTMLImageElement)) return
						const near = el.scrollHeight - el.scrollTop - el.clientHeight < 200
						if (near) el.scrollTop = el.scrollHeight
					}, true)
				} catch {
					this._scroll_setup.delete(jid)
				}
			})
		}

		private _add_message(msg: Xmpp_message) {
			if (this._msg_by_id.has(msg.id)) return  // ← FIX: skip duplicates
			this._msg_by_id.set(msg.id, msg)
			this._msgs.push(msg)
			if (msg.nick !== undefined) {
				const cur = this._oldest_time.get(msg.from) ?? Infinity
				if (msg.time < cur) this._oldest_time.set(msg.from, msg.time)
			}
			this.messages_ver(this.messages_ver() + 1)
			if (!this._loading_persisted) void this._persist_msg(msg)
		}

		// ── Persistence (IndexedDB via $mol_db) ──────────────────────────────

		private _ensure_db(): Promise<$mol_db_database<Xmpp_db_schema>> {
			if (this._db) return Promise.resolve(this._db)
			if (this._db_init) return this._db_init
			this._db_init = this.$.$mol_db<Xmpp_db_schema>('xmpp',
				mig => { mig.store_make('Messages') },
			).then(db => { this._db = db; return db })
			return this._db_init
		}

		private _peer_of(msg: Xmpp_message, account: string): string {
			if (msg.nick !== undefined) return msg.from
			return msg.from === account ? msg.to : msg.from
		}

		private async _persist_msg(msg: Xmpp_message) {
			const account = this.my_jid().split('/')[0]
			if (!account) return
			try {
				const db = await this._ensure_db()
				const peer = this._peer_of(msg, account)
				const { Messages } = db.change('Messages').stores
				const doc: Xmpp_db_schema['Messages']['Doc'] = {
					account, peer,
					id: msg.id, from: msg.from, to: msg.to, body: msg.body, time: msg.time,
					...(msg.nick !== undefined ? { nick: msg.nick } : {}),
					...(msg.media_uri !== undefined ? { media_uri: msg.media_uri } : {}),
					...(msg.media_mime !== undefined ? { media_mime: msg.media_mime } : {}),
					...(msg.media_name !== undefined ? { media_name: msg.media_name } : {}),
					...(msg.media_kind !== undefined ? { media_kind: msg.media_kind } : {}),
					...(msg.media_size !== undefined ? { media_size: msg.media_size } : {}),
					...(msg.media_hash !== undefined ? { media_hash: msg.media_hash } : {}),
					...(msg.media_hash_algo !== undefined ? { media_hash_algo: msg.media_hash_algo } : {}),
					...(msg.mam_id !== undefined ? { mam_id: msg.mam_id } : {}),
					read: msg.read ?? false,
				}
				await Messages.put(doc, [account, msg.id])
			} catch (e) { console.warn('[xmpp] persist failed', e) }
		}

		private async _load_persisted() {
			const account = this.my_jid().split('/')[0]
			if (!account) return
			try {
				const db = await this._ensure_db()
				const range = this.$.$mol_dom_context.IDBKeyRange.bound([account, ''], [account, 'пїї'])
				const docs = await db.read('Messages').Messages.select(range, 100_000)
				if (!docs?.length) return
				this._loading_persisted = true
				try {
					for (const doc of docs) {
						const msg: Xmpp_message = {
							id: doc.id, from: doc.from, to: doc.to,
							body: doc.body, time: doc.time,
							...(doc.nick !== undefined ? { nick: doc.nick } : {}),
							...(doc.media_uri !== undefined ? { media_uri: doc.media_uri } : {}),
							...(doc.media_mime !== undefined ? { media_mime: doc.media_mime } : {}),
							...(doc.media_name !== undefined ? { media_name: doc.media_name } : {}),
							...(doc.media_kind !== undefined ? { media_kind: doc.media_kind } : {}),
							...(doc.media_size !== undefined ? { media_size: doc.media_size } : {}),
							...(doc.media_hash !== undefined ? { media_hash: doc.media_hash } : {}),
							...(doc.media_hash_algo !== undefined ? { media_hash_algo: doc.media_hash_algo } : {}),
							...(doc.mam_id !== undefined ? { mam_id: doc.mam_id } : {}),
							read: doc.read ?? true,
						}
						this._add_message(msg)
						if (msg.mam_id) {
							const peer = this._peer_of(msg, account)
							const cur_time = this._mam_oldest_time.get(peer) ?? Infinity
							if (msg.time < cur_time) {
								this._mam_oldest.set(peer, msg.mam_id)
								this._mam_oldest_time.set(peer, msg.time)
							}
						}
					}
				} finally {
					this._loading_persisted = false
				}
			} catch (e) { console.warn('[xmpp] load persisted failed', e) }
		}

		private _maybe_load_history(jid: string) {
			this._setup_scroll_listener(jid)
			this._maybe_send_displayed(jid)
			if (this._rooms.has(jid)) {
				this._scroll_to_bottom(jid)
				return  // initial history arrives via join; MAM fires on first scroll-up
			}
			if (this._history_loaded.has(jid)) {
				this._scroll_to_bottom(jid)
				return
			}
			if (!this._conn) return
			this._history_loaded.add(jid)
			this._loading_more.add(jid)
			this._conn.request_mam(jid)
		}

		// XEP-0333: send a Displayed marker for the newest peer message in this chat (if newer than the previous one we marked).
		private _maybe_send_displayed(jid: string) {
			if (!this._conn) return
			if (this._rooms.has(jid)) return // skip MUC for now
			const bare = this.my_jid().split('/')[0]
			let newest: Xmpp_message | null = null
			for (const m of this._msgs) {
				if (m.from === bare) continue
				if (m.from !== jid) continue
				if (!newest || m.time > newest.time) newest = m
			}
			if (!newest) return
			const last_t = this._last_displayed_sent.get(jid) ?? 0
			if (newest.time <= last_t) return
			this._conn.send_marker(jid, 'displayed', newest.id, 'chat')
			this._last_displayed_sent.set(jid, newest.time)
		}

		// Periodically poll for new messages in loaded chats to recover from connection issues.
		private _start_polling() {
			if (this._poll_timer) clearInterval(this._poll_timer)
			this._poll_count = 0
			this._poll_timer = setInterval(() => {
				if (!this._conn) return
				this._poll_count++
				const current_chat = this.$.$mol_state_arg.value('chat')
				const is_full_poll = this._poll_count % 6 === 0  // every 30 seconds

				// Always poll current chat if open
				if (current_chat) {
					if (this._history_loaded.has(current_chat) && !this._loading_more.has(current_chat)) {
						this._conn.request_mam(current_chat, 5)
					} else if (this._rooms.has(current_chat) && !this._loading_more.has(current_chat)) {
						this._conn.request_mam_room(current_chat, 5)
					}
				}

				// Poll all others every 30 seconds
				if (is_full_poll) {
					// Poll 1:1 chats that have history loaded (except current)
					for (const peer of this._history_loaded) {
						if (peer === current_chat) continue
						if (!this._loading_more.has(peer)) {
							this._conn.request_mam(peer, 5)
						}
					}
					// Poll rooms that are joined (except current)
					for (const room of this._rooms.values()) {
						if (room.jid === current_chat) continue
						if (!this._loading_more.has(room.jid)) {
							this._conn.request_mam_room(room.jid, 5)
						}
					}
				}
			}, 5000)  // every 5 seconds
		}

		private _stop_polling() {
			if (this._poll_timer) {
				clearInterval(this._poll_timer)
				this._poll_timer = null
				this._poll_count = 0
			}
		}

		private async _upload_and_send(jid: string, file: File): Promise<void> {
			if (!this._conn) throw new Error('Not connected')
			const { put, get } = await this._conn.request_slot(file.name, file.size, file.type)
			await Xmpp_conn.upload(put, file)
			const hash = await Xmpp_conn.hash_file(file)
			const body = `Shared file: ${ file.name }`
			const id   = `l${ Date.now() }_${ Math.random().toString(36).slice(2) }`
			const type = this._rooms.has(jid) ? 'groupchat' : 'chat'
			this._conn.send_media_sharing(jid, body, file.name, file.size, file.type || 'application/octet-stream', 'sha-256', hash, get, type, id)
			this._add_message({
				id, from: this.my_jid().split('/')[0], to: jid, body, time: Date.now(),
				media_uri: get,
				media_mime: file.type || 'application/octet-stream',
				media_name: file.name,
				media_kind: media_type_from_mime(file.type) ?? media_type(get),
				media_size: file.size,
				media_hash: hash,
				media_hash_algo: 'sha-256',
				read: true,
			})
			this._scroll_to_bottom(jid)
		}
	}
}
