2215 lines
81 KiB
TypeScript
2215 lines
81 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
|
||
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, '&').replace(/</g, '<')
|
||
.replace(/>/g, '>').replace(/"/g, '"')
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|