xmpp/xmpp.view.ts
2026-05-07 04:32:54 +03:00

860 lines
31 KiB
TypeScript

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<string, (put: string, get: string) => void>()
private _mam_iqs = new Map<string, string>() // 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, '&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>` +
`</message>`
)
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(
`<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>` +
`</message>`
)
return msg_id
}
// XEP-0363: request an HTTP upload slot
request_slot(filename: string, size: number, mime: string): Promise<{ put: string; get: string }> {
return new Promise((resolve, reject) => {
const id = this._id()
this._slots.set(id, (put, get) => resolve({ put, get }))
setTimeout(() => { if (this._slots.delete(id)) reject(new Error('Upload slot timeout')) }, 15_000)
this._send(
`<iq to="${ this._esc(this.upload_service) }" type="get" id="${ id }">` +
`<request xmlns="urn:xmpp:http:upload:0"` +
` filename="${ this._esc(filename) }"` +
` size="${ size }"` +
` content-type="${ this._esc(mime) }"/>` +
`</iq>`
)
})
}
static async upload(put_url: string, file: File): Promise<void> {
const resp = await fetch(put_url, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } })
if (!resp.ok) throw new Error(`Upload failed: HTTP ${ resp.status }`)
}
// XEP-0313 for MUC: query sent TO the room, no "with" filter, optional end-timestamp to avoid duplicating join-history
request_mam_room(room_jid: string, max = 50, before_id?: string, before_time?: number): void {
if (!this._ws) return
const qid = this._id()
const id = this._id()
this._mam_iqs.set(id, room_jid)
const time_filter = before_time !== undefined
? `<x xmlns="jabber:x:data" type="submit">` +
`<field var="FORM_TYPE" type="hidden"><value>urn:xmpp:mam:2</value></field>` +
`<field var="end"><value>${ this._esc(new Date(before_time - 1).toISOString()) }</value></field>` +
`</x>`
: ''
const before = before_id ? `<before>${ this._esc(before_id) }</before>` : `<before/>`
this._send(
`<iq to="${ this._esc(room_jid) }" type="set" id="${ id }">` +
`<query xmlns="urn:xmpp:mam:2" queryid="${ qid }">` +
time_filter +
`<set xmlns="http://jabber.org/protocol/rsm">` +
`<max>${ max }</max>${ before }` +
`</set>` +
`</query></iq>`
)
}
// XEP-0313: request last `max` messages with a given peer; pass `before_id` for pagination
request_mam(with_jid: string, max = 50, before_id?: string): void {
if (!this._ws) return
const qid = this._id()
const id = this._id()
this._mam_iqs.set(id, with_jid)
const before = before_id ? `<before>${ this._esc(before_id) }</before>` : `<before/>`
this._send(
`<iq type="set" id="${ id }">` +
`<query xmlns="urn:xmpp:mam:2" queryid="${ qid }">` +
`<x xmlns="jabber:x:data" type="submit">` +
`<field var="FORM_TYPE" type="hidden"><value>urn:xmpp:mam:2</value></field>` +
`<field var="with"><value>${ this._esc(with_jid) }</value></field>` +
`</x>` +
`<set xmlns="http://jabber.org/protocol/rsm">` +
`<max>${ max }</max>${ before }` +
`</set>` +
`</query></iq>`
)
}
private _parse(data: string) {
return new DOMParser().parseFromString(data, 'text/xml')
}
private _handle(data: string) {
const doc = this._parse(data)
const root = doc.documentElement
if (!root) return
const tag = root.localName
const ns = root.namespaceURI || ''
if (tag === 'open') return
if (tag === 'features') { this._handle_features(root); return }
if (tag === 'success' && ns.includes('xmpp-sasl')) { this._state = 'reopen'; this._stream_open(); return }
if (tag === 'failure') { this.on_error?.('Authentication failed — check JID and password'); return }
if (tag === 'iq') { this._handle_iq(root); return }
if (tag === 'message') { this._handle_message(root); return }
if (tag === 'presence'){ this._handle_presence(root); return }
}
private _handle_features(el: Element) {
if (this._state === 'open') {
const mechs = el.querySelector('mechanisms')
if (!mechs) { this.on_error?.('No SASL mechanisms offered'); return }
const ok = Array.from(mechs.querySelectorAll('mechanism')).some(m => m.textContent?.trim() === 'PLAIN')
if (!ok) { this.on_error?.('PLAIN auth not available'); return }
const user = this._jid.split('@')[0]
const encoded = btoa(unescape(encodeURIComponent(`\0${ user }\0${ this._pass }`)))
this._send(`<auth xmlns="urn:ietf:params:xml:ns:xmpp-sasl" mechanism="PLAIN">${ encoded }</auth>`)
this._state = 'auth'
} else if (this._state === 'reopen') {
const id = this._id()
this._send(`<iq type="set" id="${ id }"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><resource>mol</resource></bind></iq>`)
this._state = 'bind'
}
}
private _handle_iq(el: Element) {
const id = el.getAttribute('id') || ''
// 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-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; }
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<string, Xmpp_message>()
private _msgs: Xmpp_message[] = []
private _rec = new Map<string, { recorder: MediaRecorder; chunks: Blob[]; stream: MediaStream }>()
private _history_loaded = new Set<string>()
private _mam_oldest = new Map<string, string>() // jid → oldest MAM result id
private _loading_more = new Set<string>()
private _fin_count = new Map<string, number>()
private _scroll_setup = new Set<string>() // scroll listener installed
private _rooms = new Map<string, Xmpp_room>()
private _oldest_time = new Map<string, number>() // room_jid → oldest msg timestamp
private _ver = 0
@ $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<void> {
if (!this._conn) throw new Error('Not connected')
const { put, get } = await this._conn.request_slot(file.name, file.size, file.type)
await Xmpp_conn.upload(put, file)
const id = `l${ Date.now() }_${ Math.random().toString(36).slice(2) }`
this._conn.send_message(jid, get, id)
this._add_message({ id, from: this.my_jid().split('/')[0], to: jid, body: get, time: Date.now() })
}
}
}