diff --git a/xmpp.view.css b/xmpp.view.css index e2f7050..9cffde9 100644 --- a/xmpp.view.css +++ b/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; +} diff --git a/xmpp.view.tree b/xmpp.view.tree index c92313b..4bdac9b 100644 --- a/xmpp.view.tree +++ b/xmpp.view.tree @@ -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 diff --git a/xmpp.view.ts b/xmpp.view.ts index cc38594..18311d0 100644 --- a/xmpp.view.ts +++ b/xmpp.view.ts @@ -46,6 +46,8 @@ namespace $.$$ { private _slots = new Map void>() private _mam_iqs = new Map() // iq_id → with_jid + private _avatar_meta_iqs = new Map void>() + private _avatar_data_iqs = new Map 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( `` + `${ this._esc(body) }` + + `` + `` ) 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( + `` + + `<${ kind } xmlns="urn:xmpp:chat-markers:0" id="${ this._esc(id) }"/>` + + `` + ) + } + + // ── 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( + `` + + `` + + `` + + `` + + `${ b64 }` + + `` + ) + // 2. publish metadata pointer + const meta_id = this._id() + this._send( + `` + + `` + + `` + + `` + + `` + + `` + + `` + ) + } + + 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( + `` + + `` + + `1` + + `` + ) + }) + } + + fetch_avatar_data(jid: string, hash: string): Promise { + 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( + `` + + `` + + `` + + `` + + `` + ) + }) + } + 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( `` + `${ this._esc(body) }` + + `` + `` ) 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 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() // 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() // msg id → highest received marker + private _last_displayed_sent = new Map() // peer/room jid → time of last msg we marked displayed + + // XEP-0084 avatars + private _avatars = new Map() // bare jid → data: URI + private _avatar_loading = new Set() + + // Suppress notifications during the first 5s after a fresh connection + // — covers servers that replay offline messages without proper . + 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 = `` + + `` + + `${ safe }` + + `` + 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 ; 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 + 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 { if (!this._conn) throw new Error('Not connected') const { put, get } = await this._conn.request_slot(file.name, file.size, file.type)