XEP-0084: avatars
XEP-0333: marking messengers
This commit is contained in:
parent
4e5787fb98
commit
8847e3a273
3 changed files with 617 additions and 10 deletions
131
xmpp.view.css
131
xmpp.view.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
445
xmpp.view.ts
445
xmpp.view.ts
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue