XEP-0084: avatars

XEP-0333: marking messengers
This commit is contained in:
koplenov 2026-05-07 06:24:58 +03:00
parent 4e5787fb98
commit 8847e3a273
3 changed files with 617 additions and 10 deletions

View file

@ -1,8 +1,27 @@
[xmpp_Msg] {
display: flex;
flex-direction: column;
flex-direction: row;
align-items: flex-start;
padding: .5rem 1rem;
gap: .5rem;
}
[xmpp_Msg_avatar] {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
background: var(--mol_theme_back);
flex-shrink: 0;
}
[xmpp_Msg_content] {
display: flex;
flex-direction: column;
gap: .25rem;
flex: 1;
min-width: 0;
}
[xmpp_Msg_from] {
@ -48,6 +67,116 @@
opacity: .8;
}
[xmpp_Msg_status] {
align-self: flex-end;
font-size: .75rem;
opacity: .6;
color: var(--mol_theme_focus, #4a9eff);
line-height: 1;
}
[xmpp_Msg_status]:empty {
display: none;
}
[xmpp_My_avatar],
[xmpp_Contact_avatar],
[xmpp_Chat_avatar] {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
background: var(--mol_theme_back);
flex-shrink: 0;
}
[xmpp_Chat_avatar] {
margin-right: .5rem;
}
[xmpp_Roster_contact] {
display: flex;
flex-direction: row;
align-items: center;
gap: .5rem;
}
[xmpp_Contact_label] {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
[xmpp_Room_join_form] {
margin-top: 1rem;
}
[xmpp_Toasts] {
position: fixed;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: .5rem;
z-index: 1000;
pointer-events: none;
max-width: 360px;
}
[xmpp_Toast] {
position: relative;
pointer-events: auto;
cursor: pointer;
flex-direction: column;
align-items: stretch;
text-align: left;
background: var(--mol_theme_card);
border-left: 3px solid var(--mol_theme_focus, #4a9eff);
padding: .6rem 2rem .6rem .9rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.18);
min-width: 240px;
animation: xmpp_toast_in .2s ease-out;
}
[xmpp_Toast_close] {
position: absolute;
top: .2rem;
right: .35rem;
width: 1.4rem;
height: 1.4rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
opacity: .5;
font-size: 1.1rem;
line-height: 1;
user-select: none;
}
[xmpp_Toast_close]:hover {
opacity: 1;
background: rgba(0,0,0,0.1);
}
@keyframes xmpp_toast_in {
from { transform: translateX(120%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
[xmpp_Toast_title] {
font-weight: bold;
font-size: .9rem;
margin-bottom: .2rem;
}
[xmpp_Toast_body] {
font-size: .85rem;
opacity: .8;
word-break: break-word;
max-height: 4em;
overflow: hidden;
}

View file

@ -39,6 +39,15 @@ $xmpp $mol_book2
title <= roster_title @ \Contacts
tools /
<= Lights $mol_lights_toggle
<= My_avatar $mol_view
dom_name \img
attr *
^
src <= my_avatar_uri \
alt \
<= Set_avatar_button $mol_button_minor
title @ \Set avatar
click? <=> do_set_avatar? null
<= Disconnect_button $mol_button_minor
title <= disconnect_label @ \Disconnect
click? <=> do_disconnect? null
@ -64,6 +73,12 @@ $xmpp $mol_book2
-
Chat_page* $mol_page
title <= chat_with* \
Logo <= Chat_avatar* $mol_view
dom_name \img
attr *
^
src <= chat_avatar_uri* \
alt \
tools /
<= Chat_leave* $mol_button_minor
title @ \Leave
@ -94,19 +109,39 @@ $xmpp $mol_book2
click? <=> do_send*? null
-
Roster_contact* $mol_button_minor
title <= contact_display* \
click? <=> open_chat*? null
sub /
<= Contact_avatar* $mol_view
dom_name \img
attr *
^
src <= contact_avatar_uri* \
alt \
<= Contact_label* $mol_view
sub /
<= contact_display* \
-
Msg* $mol_view
sub /
<= Msg_from* $mol_view
<= Msg_avatar* $mol_view
dom_name \img
attr *
^
src <= msg_avatar_uri* \
alt \
<= Msg_content* $mol_view
sub /
<= msg_from* \
<= Msg_body* $mol_view
sub /
<= msg_body* \
<= Msg_media* $mol_view
sub <= msg_media* /
<= Msg_from* $mol_view
sub /
<= msg_from* \
<= Msg_body* $mol_view
sub /
<= msg_body* \
<= Msg_media* $mol_view
sub <= msg_media* /
<= Msg_status* $mol_view
sub /
<= msg_status* \
-
Msg_image* $mol_view
dom_name \img

View file

@ -46,6 +46,8 @@ namespace $.$$ {
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
@ -55,6 +57,9 @@ namespace $.$$ {
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_error: ((err: string) => void) | null = null
on_room_error: ((err: string) => void) | null = null
@ -100,11 +105,77 @@ namespace $.$$ {
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
}
// 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;
}
@ -138,6 +209,7 @@ private _getBody(el: Element): string | null {
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
@ -249,6 +321,9 @@ private _getBody(el: Element): string | null {
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')
@ -303,6 +378,41 @@ private _getBody(el: Element): string | null {
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') {
@ -350,10 +460,52 @@ private _handle_message(el: Element) {
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';
const body = this._getBody(el);
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');
@ -448,6 +600,21 @@ private _handle_message(el: Element) {
private _oldest_time = new Map<string, number>() // room_jid → oldest msg timestamp
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
@ $mol_mem
status(next?: 'disconnected' | 'connecting' | 'connected') { return next ?? 'disconnected' }
@ -474,6 +641,57 @@ private _handle_message(el: Element) {
@ $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)
if (room) return this._default_avatar(jid, room.name || jid.split('@')[0])
return this.avatar_uri(jid) || 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) 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 ?? [] }
@ -516,12 +734,19 @@ private _handle_message(el: Element) {
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()
}
conn.on_bookmarks = bookmarks => {
const my_nick = this.my_jid().split('@')[0]
@ -534,18 +759,41 @@ private _handle_message(el: Element) {
}
this.rooms([ ...this._rooms.values() ])
}
conn.on_roster = cs => { this.contacts(cs) }
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_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)
// 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
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
@ -558,6 +806,37 @@ private _handle_message(el: Element) {
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)
}
// 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)
@ -719,6 +998,21 @@ private _handle_message(el: Element) {
@ $mol_mem_key // ← FIX: make reactive
msg_link_uri(id: string) { return this._msg_by_id.get(id)?.body.trim() ?? '' }
// 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 '✓'
}
// ── Send text ─────────────────────────────────────────────────────────
do_send(jid: string) {
@ -792,6 +1086,136 @@ private _handle_message(el: Element) {
})
}
// 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
this._show_toast(peer, title, body)
this._beep()
}
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() {
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
const oldest_id = this._mam_oldest.get(jid)
@ -834,6 +1258,7 @@ private _handle_message(el: Element) {
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
@ -848,6 +1273,24 @@ private _handle_message(el: Element) {
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)
}
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)