From 4e5787fb985f6c64a7f676df5a36c256011a40e0 Mon Sep 17 00:00:00 2001 From: koplenov Date: Thu, 7 May 2026 04:32:54 +0300 Subject: [PATCH] init --- index.html | 15 + xmpp.view.css | 53 +++ xmpp.view.tree | 127 ++++++++ xmpp.view.ts | 860 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1055 insertions(+) create mode 100644 index.html create mode 100644 xmpp.view.css create mode 100644 xmpp.view.tree create mode 100644 xmpp.view.ts diff --git a/index.html b/index.html new file mode 100644 index 0000000..fb93969 --- /dev/null +++ b/index.html @@ -0,0 +1,15 @@ + + + + + XMPP + + + + + + +
+ + + diff --git a/xmpp.view.css b/xmpp.view.css new file mode 100644 index 0000000..e2f7050 --- /dev/null +++ b/xmpp.view.css @@ -0,0 +1,53 @@ +[xmpp_Msg] { + display: flex; + flex-direction: column; + padding: .5rem 1rem; + gap: .25rem; +} + +[xmpp_Msg_from] { + font-size: .75rem; + opacity: .6; + font-weight: bold; +} + +[xmpp_Msg_body] { + padding: .4rem .7rem; + border-radius: .5rem; + background: var(--mol_theme_card, #f0f0f0); + max-width: 80%; + word-break: break-word; +} + +[xmpp_Compose_input] { + flex: 1; +} + +/* media */ + +[xmpp_Msg_media]:empty { + display: none; +} + +[xmpp_Msg_image] { + display: block; + max-width: min(320px, 80%); + max-height: 240px; + border-radius: .5rem; + object-fit: contain; + cursor: pointer; +} + +[xmpp_Msg_audio] { + max-width: min(320px, 80%); + display: block; +} + +[xmpp_Msg_link] { + font-size: .85rem; + opacity: .8; +} + +[xmpp_Room_join_form] { + margin-top: 1rem; +} diff --git a/xmpp.view.tree b/xmpp.view.tree new file mode 100644 index 0000000..c92313b --- /dev/null +++ b/xmpp.view.tree @@ -0,0 +1,127 @@ +$xmpp $mol_book2 + plugins / + <= Theme $mol_theme_auto + - + Login_page $mol_page + title @ \XMPP Client + tools / + <= Lights $mol_lights_toggle + body / + <= Login_form $mol_form + form_fields / + <= Server_field $mol_form_field + name @ \WebSocket URL + control <= Server_input $mol_string + hint \wss://jabber.example.com/ws + value? <=> server? \ + <= Jid_field $mol_form_field + name @ \JID + control <= Jid_input $mol_string + hint \user@jabber.example.com + value? <=> jid? \ + <= Pass_field $mol_form_field + name @ \Password + control <= Pass_input $mol_string + type \password + value? <=> password? \ + <= Error_field $mol_form_field + name @ \Error + control <= Error_view $mol_view + sub / + <= error_text \ + buttons / + <= Connect_button $mol_button_major + title <= connect_title @ \Connect + click? <=> do_connect? null + disabled <= connecting false + - + Roster_page $mol_page + title <= roster_title @ \Contacts + tools / + <= Lights $mol_lights_toggle + <= Disconnect_button $mol_button_minor + title <= disconnect_label @ \Disconnect + click? <=> do_disconnect? null + body / + <= Roster_list $mol_list + rows <= roster_rows / + <= Room_join_form $mol_form + form_fields / + <= Room_jid_field $mol_form_field + name @ \Room JID + control <= Room_jid_input $mol_string + hint \room@conference.example.com + value? <=> room_jid? \ + <= Room_nick_field $mol_form_field + name @ \Nickname + control <= Room_nick_input $mol_string + hint @ \nickname + value? <=> room_nick? \ + buttons / + <= Room_join_button $mol_button_major + title @ \Join Room + click? <=> do_join_room? null + - + Chat_page* $mol_page + title <= chat_with* \ + tools / + <= Chat_leave* $mol_button_minor + title @ \Leave + click? <=> do_leave_room*? null + attr * + ^ + hidden <= chat_leave_hidden* \ + <= Chat_close* $mol_link + arg * + chat null + sub / + <= Chat_close_icon* $mol_icon_close + body / + <= Messages_list* $mol_list + rows <= message_rows* / + foot / + <= Compose_input* $mol_string + hint @ \Type a message… + value? <=> compose*? \ + <= Attach_button* $mol_button_minor + title @ \Attach + click? <=> do_attach*? null + <= Record_button* $mol_button_minor + title <= record_title* @ \Record + click? <=> do_record*? null + <= Send_button* $mol_button_major + title <= send_label @ \Send + click? <=> do_send*? null + - + Roster_contact* $mol_button_minor + title <= contact_display* \ + click? <=> open_chat*? null + - + Msg* $mol_view + sub / + <= Msg_from* $mol_view + sub / + <= msg_from* \ + <= Msg_body* $mol_view + sub / + <= msg_body* \ + <= Msg_media* $mol_view + sub <= msg_media* / + - + Msg_image* $mol_view + dom_name \img + attr * + ^ + src <= msg_image_uri* \ + - + Msg_audio* $mol_view + dom_name \audio + attr * + ^ + controls \ + src <= msg_audio_src* \ + - + Msg_link* $mol_link + uri <= msg_link_uri* \ + sub / + <= msg_link_label* @ \Open file diff --git a/xmpp.view.ts b/xmpp.view.ts new file mode 100644 index 0000000..cc38594 --- /dev/null +++ b/xmpp.view.ts @@ -0,0 +1,860 @@ +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() }) + } + } +}