init
This commit is contained in:
commit
4e5787fb98
4 changed files with 1055 additions and 0 deletions
15
index.html
Normal file
15
index.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!doctype html>
|
||||
<html mol_view_root>
|
||||
<head>
|
||||
<meta charset='utf-8' />
|
||||
<title>XMPP</title>
|
||||
<meta name='viewport' content='width=device-width, height=device-height, initial-scale=1'>
|
||||
<!--<link href='/logo/logo.svg' rel='icon' />-->
|
||||
<meta name='mobile-web-app-capable' content='yes'>
|
||||
<meta name='apple-mobile-web-app-capable' content='yes'>
|
||||
</head>
|
||||
<body mol_view_root>
|
||||
<div mol_view_root='$xmpp'></div>
|
||||
<script src='web.js' charset='utf-8'></script>
|
||||
</body>
|
||||
</html>
|
||||
53
xmpp.view.css
Normal file
53
xmpp.view.css
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
[xmpp_Msg] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: .5rem 1rem;
|
||||
gap: .25rem;
|
||||
}
|
||||
|
||||
[xmpp_Msg_from] {
|
||||
font-size: .75rem;
|
||||
opacity: .6;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
[xmpp_Msg_body] {
|
||||
padding: .4rem .7rem;
|
||||
border-radius: .5rem;
|
||||
background: var(--mol_theme_card, #f0f0f0);
|
||||
max-width: 80%;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
[xmpp_Compose_input] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* media */
|
||||
|
||||
[xmpp_Msg_media]:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[xmpp_Msg_image] {
|
||||
display: block;
|
||||
max-width: min(320px, 80%);
|
||||
max-height: 240px;
|
||||
border-radius: .5rem;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[xmpp_Msg_audio] {
|
||||
max-width: min(320px, 80%);
|
||||
display: block;
|
||||
}
|
||||
|
||||
[xmpp_Msg_link] {
|
||||
font-size: .85rem;
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
[xmpp_Room_join_form] {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
127
xmpp.view.tree
Normal file
127
xmpp.view.tree
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
$xmpp $mol_book2
|
||||
plugins /
|
||||
<= Theme $mol_theme_auto
|
||||
-
|
||||
Login_page $mol_page
|
||||
title @ \XMPP Client
|
||||
tools /
|
||||
<= Lights $mol_lights_toggle
|
||||
body /
|
||||
<= Login_form $mol_form
|
||||
form_fields /
|
||||
<= Server_field $mol_form_field
|
||||
name @ \WebSocket URL
|
||||
control <= Server_input $mol_string
|
||||
hint \wss://jabber.example.com/ws
|
||||
value? <=> server? \
|
||||
<= Jid_field $mol_form_field
|
||||
name @ \JID
|
||||
control <= Jid_input $mol_string
|
||||
hint \user@jabber.example.com
|
||||
value? <=> jid? \
|
||||
<= Pass_field $mol_form_field
|
||||
name @ \Password
|
||||
control <= Pass_input $mol_string
|
||||
type \password
|
||||
value? <=> password? \
|
||||
<= Error_field $mol_form_field
|
||||
name @ \Error
|
||||
control <= Error_view $mol_view
|
||||
sub /
|
||||
<= error_text \
|
||||
buttons /
|
||||
<= Connect_button $mol_button_major
|
||||
title <= connect_title @ \Connect
|
||||
click? <=> do_connect? null
|
||||
disabled <= connecting false
|
||||
-
|
||||
Roster_page $mol_page
|
||||
title <= roster_title @ \Contacts
|
||||
tools /
|
||||
<= Lights $mol_lights_toggle
|
||||
<= Disconnect_button $mol_button_minor
|
||||
title <= disconnect_label @ \Disconnect
|
||||
click? <=> do_disconnect? null
|
||||
body /
|
||||
<= Roster_list $mol_list
|
||||
rows <= roster_rows /
|
||||
<= Room_join_form $mol_form
|
||||
form_fields /
|
||||
<= Room_jid_field $mol_form_field
|
||||
name @ \Room JID
|
||||
control <= Room_jid_input $mol_string
|
||||
hint \room@conference.example.com
|
||||
value? <=> room_jid? \
|
||||
<= Room_nick_field $mol_form_field
|
||||
name @ \Nickname
|
||||
control <= Room_nick_input $mol_string
|
||||
hint @ \nickname
|
||||
value? <=> room_nick? \
|
||||
buttons /
|
||||
<= Room_join_button $mol_button_major
|
||||
title @ \Join Room
|
||||
click? <=> do_join_room? null
|
||||
-
|
||||
Chat_page* $mol_page
|
||||
title <= chat_with* \
|
||||
tools /
|
||||
<= Chat_leave* $mol_button_minor
|
||||
title @ \Leave
|
||||
click? <=> do_leave_room*? null
|
||||
attr *
|
||||
^
|
||||
hidden <= chat_leave_hidden* \
|
||||
<= Chat_close* $mol_link
|
||||
arg *
|
||||
chat null
|
||||
sub /
|
||||
<= Chat_close_icon* $mol_icon_close
|
||||
body /
|
||||
<= Messages_list* $mol_list
|
||||
rows <= message_rows* /
|
||||
foot /
|
||||
<= Compose_input* $mol_string
|
||||
hint @ \Type a message…
|
||||
value? <=> compose*? \
|
||||
<= Attach_button* $mol_button_minor
|
||||
title @ \Attach
|
||||
click? <=> do_attach*? null
|
||||
<= Record_button* $mol_button_minor
|
||||
title <= record_title* @ \Record
|
||||
click? <=> do_record*? null
|
||||
<= Send_button* $mol_button_major
|
||||
title <= send_label @ \Send
|
||||
click? <=> do_send*? null
|
||||
-
|
||||
Roster_contact* $mol_button_minor
|
||||
title <= contact_display* \
|
||||
click? <=> open_chat*? null
|
||||
-
|
||||
Msg* $mol_view
|
||||
sub /
|
||||
<= Msg_from* $mol_view
|
||||
sub /
|
||||
<= msg_from* \
|
||||
<= Msg_body* $mol_view
|
||||
sub /
|
||||
<= msg_body* \
|
||||
<= Msg_media* $mol_view
|
||||
sub <= msg_media* /
|
||||
-
|
||||
Msg_image* $mol_view
|
||||
dom_name \img
|
||||
attr *
|
||||
^
|
||||
src <= msg_image_uri* \
|
||||
-
|
||||
Msg_audio* $mol_view
|
||||
dom_name \audio
|
||||
attr *
|
||||
^
|
||||
controls \
|
||||
src <= msg_audio_src* \
|
||||
-
|
||||
Msg_link* $mol_link
|
||||
uri <= msg_link_uri* \
|
||||
sub /
|
||||
<= msg_link_label* @ \Open file
|
||||
860
xmpp.view.ts
Normal file
860
xmpp.view.ts
Normal file
|
|
@ -0,0 +1,860 @@
|
|||
namespace $.$$ {
|
||||
|
||||
type Xmpp_contact = {
|
||||
jid: string
|
||||
name: string
|
||||
show: string
|
||||
status: string
|
||||
}
|
||||
|
||||
type Xmpp_message = {
|
||||
id: string
|
||||
from: string
|
||||
to: string
|
||||
body: string
|
||||
time: number
|
||||
nick?: string // sender nick for MUC groupchat messages
|
||||
}
|
||||
|
||||
type Xmpp_room = {
|
||||
jid: string
|
||||
name: string
|
||||
nick: string // my nickname in this room
|
||||
}
|
||||
|
||||
type Media_type = 'image' | 'audio' | 'link' | null
|
||||
|
||||
function media_type(url: string): Media_type {
|
||||
const s = url.trim()
|
||||
if (!/^https?:\/\/\S+$/.test(s)) return null
|
||||
const path = s.split('?')[0].toLowerCase()
|
||||
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|avif)$/.test(path)) return 'image'
|
||||
if (/\.(mp3|ogg|wav|webm|opus|m4a|aac|flac)$/.test(path)) return 'audio'
|
||||
return 'link'
|
||||
}
|
||||
|
||||
// ─── XMPP over WebSocket (RFC 7590) ──────────────────────────────────────
|
||||
|
||||
class Xmpp_conn {
|
||||
|
||||
private _ws: WebSocket | null = null
|
||||
private _count = 0
|
||||
private _jid = ''
|
||||
private _pass = ''
|
||||
private _domain = ''
|
||||
private _state: 'open' | 'auth' | 'reopen' | 'bind' | 'ready' = 'open'
|
||||
|
||||
private _slots = new Map<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, '&').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>` +
|
||||
`</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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue