openvibers/openvibe.shared messages.js
messages.js
| 1 | ; |
| 2 | /** |
| 3 | * Render Chat messages as a compact room or grouped conversation bubbles. |
| 4 | * |
| 5 | * render(root, { messages, view: 'room' | 'bubbles', me, now, actions, onAction }) |
| 6 | * actions: the ids to offer on each message ('reply', 'edit', 'react', 'translate', 'copy', 'delete'), or |
| 7 | * (message, { mine }) → ids. 'edit' and 'delete' are offered only on your own messages. |
| 8 | * onAction(id, message, row): a button was pressed (an 'ovm:action' event with { action, message } also bubbles). |
| 9 | * actions(row, ids, { onAction, message }) → the cluster: the same buttons for a row a site draws itself (Live's chat). |
| 10 | * edited(textNode, at) → the quiet "edited" word after a message's words, its time as the title. |
| 11 | * |
| 12 | * A message's actions are one cluster at its corner (openvibe.design rule 138): shown on hover or focus on a computer, |
| 13 | * on a tap on a phone, never in the words. "edited" is one quiet word after them, never a line of its own. |
| 14 | */ |
| 15 | const text = (parent, value) => parent.appendChild(document.createTextNode(String(value == null ? '' : value))); |
| 16 | function safeLinkify(node, value) { |
| 17 | const raw = String(value || ''), re = /https?:\/\/[^\s<>"']+/g; |
| 18 | let last = 0, match; |
| 19 | while ((match = re.exec(raw))) { |
| 20 | if (match.index > last) text(node, raw.slice(last, match.index)); |
| 21 | const a = document.createElement('a'); a.className = 'ovm-link'; a.href = match[0]; a.rel = 'nofollow noopener noreferrer'; a.target = '_blank'; text(a, match[0]); node.appendChild(a); |
| 22 | last = match.index + match[0].length; |
| 23 | } |
| 24 | if (last < raw.length) text(node, raw.slice(last)); |
| 25 | } |
| 26 | function timeValue(message) { let raw = String(message.created_at || message.at || '').replace(' ', 'T'); if (raw && !/(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw)) raw += 'Z'; const d = new Date(raw); return Number.isNaN(d.getTime()) ? null : d; } |
| 27 | function safeAvatar(value) { try { const u = new URL(String(value || '')); return u.protocol === 'https:' && !u.port && u.hostname.toLowerCase() === 'openvibe.media' ? u.href : ''; } catch { return ''; } } |
| 28 | /** A steady hue per person: their name in the room and their initial's circle keep one colour everywhere. */ |
| 29 | function hueOf(id) { let x = 7; for (const c of String(id || '')) x = (x * 31 + c.codePointAt(0)) % 360; return x; } |
| 30 | function dayLabel(date, now) { |
| 31 | const days = Math.floor((Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) - Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) / 86400000); |
| 32 | if (days === 0) return 'Today'; if (days === 1) return 'Yesterday'; |
| 33 | return new Intl.DateTimeFormat(undefined, { weekday: days < 7 ? 'short' : undefined, day: 'numeric', month: 'short' }).format(date); |
| 34 | } |
| 35 | // The actions' icons (24-unit strokes, as the composer's). |
| 36 | const ICON = { |
| 37 | reply: 'M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z', |
| 38 | edit: 'M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4', |
| 39 | react: 'M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zM8.5 14a4 4 0 0 0 7 0M9 9.5h.01M15 9.5h.01', |
| 40 | translate: 'M4 5h9M8.5 3v2M6 5c.6 3 2.6 5.5 5.5 7M11 5c-.8 3.6-3.2 6.4-6.5 8M13 21l4-10 4 10M14.5 17.5h5', |
| 41 | copy: 'M9 9h10v12H9zM5 15V3h10', |
| 42 | delete: 'M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3', |
| 43 | }; |
| 44 | const LABEL = { reply: 'Reply', edit: 'Edit your message', react: 'React', translate: 'Translate', copy: 'Copy text', delete: 'Delete your message' }; |
| 45 | const OWN = new Set(['edit', 'delete']); |
| 46 | function svgIcon(path) { |
| 47 | const ns = 'http://www.w3.org/2000/svg'; |
| 48 | const s = document.createElementNS(ns, 'svg'); |
| 49 | s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('width', '16'); s.setAttribute('height', '16'); s.setAttribute('aria-hidden', 'true'); |
| 50 | s.setAttribute('fill', 'none'); s.setAttribute('stroke', 'currentColor'); s.setAttribute('stroke-width', '2'); s.setAttribute('stroke-linecap', 'round'); s.setAttribute('stroke-linejoin', 'round'); |
| 51 | const p = document.createElementNS(ns, 'path'); p.setAttribute('d', path); s.appendChild(p); |
| 52 | return s; |
| 53 | } |
| 54 | /** One cluster of icon buttons at a row's corner. A site's own rows (Live's chat) use it too, so every chat matches. */ |
| 55 | function actions(row, ids, { onAction = null, message = null, mine = false } = {}) { |
| 56 | if (!row) return null; |
| 57 | let box = row.querySelector(':scope > .ovm-actions'); |
| 58 | if (!box) { box = document.createElement('span'); box.className = 'ovm-actions'; row.appendChild(box); } |
| 59 | box.replaceChildren(); |
| 60 | (ids || []).filter((id) => ICON[id] && (!OWN.has(id) || mine)).forEach((id) => { |
| 61 | const b = document.createElement('button'); |
| 62 | b.type = 'button'; b.className = `ovm-act ovm-act-${id}`; b.dataset.action = id; b.title = LABEL[id]; b.setAttribute('aria-label', LABEL[id]); |
| 63 | b.appendChild(svgIcon(ICON[id])); |
| 64 | b.addEventListener('click', (e) => { |
| 65 | e.stopPropagation(); |
| 66 | row.classList.remove('is-acting'); |
| 67 | if (typeof onAction === 'function') onAction(id, message, row); |
| 68 | const view = row.ownerDocument && row.ownerDocument.defaultView; |
| 69 | const CE = (view && view.CustomEvent) || (typeof CustomEvent === 'function' ? CustomEvent : null); |
| 70 | if (CE) row.dispatchEvent(new CE('ovm:action', { bubbles: true, detail: { action: id, message } })); |
| 71 | }); |
| 72 | box.appendChild(b); |
| 73 | }); |
| 74 | row.classList.add('ovm-has-actions'); |
| 75 | if (!box.children.length) { box.remove(); row.classList.remove('ovm-has-actions'); return null; } |
| 76 | return box; |
| 77 | } |
| 78 | /** "edited": one quiet word right after the words, the time it changed as its title. */ |
| 79 | function edited(after, at) { |
| 80 | if (!after || !after.parentNode) return null; |
| 81 | let mark = after.nextElementSibling && after.nextElementSibling.classList.contains('ovm-edited') ? after.nextElementSibling : null; |
| 82 | if (!mark) { mark = document.createElement('small'); mark.className = 'ovm-edited'; after.after(mark); } |
| 83 | mark.textContent = 'edited'; |
| 84 | const raw = String(at || ''); |
| 85 | const d = raw ? new Date(/(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw.replace(' ', 'T')) ? raw.replace(' ', 'T') : raw.replace(' ', 'T') + 'Z') : null; |
| 86 | mark.title = d && !isNaN(d) ? `Edited at ${new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(d)}` : 'Edited'; |
| 87 | return mark; |
| 88 | } |
| 89 | /** A phone has no hover: a tap on a message shows its actions, a tap elsewhere hides them. */ |
| 90 | function tapToAct(root) { |
| 91 | if (!root || root.__ovmTap) return; |
| 92 | root.__ovmTap = true; |
| 93 | root.addEventListener('click', (e) => { |
| 94 | const t = e.target; |
| 95 | if (!t || !t.closest || t.closest('.ovm-actions')) return; |
| 96 | const noHover = typeof matchMedia === 'function' && matchMedia('(hover: none)').matches; |
| 97 | const row = t.closest('.ovm-has-actions'); |
| 98 | root.querySelectorAll('.is-acting').forEach((r) => { if (r !== row) r.classList.remove('is-acting'); }); |
| 99 | if (noHover && row && !t.closest('a, button, input, textarea')) row.classList.toggle('is-acting'); |
| 100 | }); |
| 101 | } |
| 102 | function render(root, { messages = [], view = 'room', me = null, now = new Date(), actions: offer = null, onAction = null } = {}) { |
| 103 | if (!root || !root.ownerDocument) throw new TypeError('A message list element is required'); |
| 104 | if (view !== 'room' && view !== 'bubbles') throw new TypeError('view must be "room" or "bubbles"'); |
| 105 | root.replaceChildren(); root.classList.add('ovm-messages'); root.dataset.view = view; |
| 106 | let previous = null, activeDay = ''; |
| 107 | messages.forEach((message, index) => { |
| 108 | const date = timeValue(message), stamp = date ? date.getTime() : 0; |
| 109 | const day = date ? dayLabel(date, now) : ''; |
| 110 | if (view === 'bubbles' && day && day !== activeDay) { const separator = document.createElement('li'); separator.className = 'ovm-day'; text(separator, day); root.appendChild(separator); activeDay = day; } |
| 111 | const sender = String(message.sender_id || message.user_id || message.from || message.username || ''); |
| 112 | const mine = me != null && String(message.sender_id || message.user_id || '') === String(me); |
| 113 | const same = previous && sender === previous.sender && stamp && previous.stamp && stamp - previous.stamp >= 0 && stamp - previous.stamp <= 180000; |
| 114 | if (view === 'room') { |
| 115 | const row = document.createElement('li'); row.className = 'ovm-room'; row.dataset.id = String(message.id || ''); |
| 116 | const time = document.createElement('time'); if (date) { time.dateTime = date.toISOString(); text(time, new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(date)); } row.appendChild(time); |
| 117 | const author = document.createElement('span'); author.className = 'ovm-room-author'; author.style.setProperty('--ovm-h', String(hueOf(sender))); text(author, message.display_name || message.username || 'Someone'); row.appendChild(author); |
| 118 | const body = document.createElement('span'); body.className = 'ovm-room-text'; safeLinkify(body, message.message || message.text); row.appendChild(body); root.appendChild(row); |
| 119 | if (message.edited_at) edited(body, message.edited_at); |
| 120 | const ids = typeof offer === 'function' ? offer(message, { mine }) : offer; |
| 121 | if (ids && ids.length) actions(row, ids, { onAction, message, mine }); |
| 122 | } else { |
| 123 | const group = document.createElement('li'); group.className = 'ovm-group'; group.dataset.id = String(message.id || ''); group.dataset.mine = String(mine); group.dataset.tail = String(!messages[index + 1] || String(messages[index + 1].sender_id || messages[index + 1].user_id || messages[index + 1].from || messages[index + 1].username || '') !== sender); |
| 124 | if (same) group.dataset.continued = 'true'; |
| 125 | if (!mine && !same && safeAvatar(message.avatar_url)) { const avatar = document.createElement('img'); avatar.className = 'ovm-avatar'; avatar.alt = ''; avatar.loading = 'lazy'; avatar.referrerPolicy = 'no-referrer'; avatar.src = safeAvatar(message.avatar_url); group.appendChild(avatar); } |
| 126 | else if (!mine && !same) { const spacer = document.createElement('span'); spacer.className = 'ovm-avatar is-initial'; spacer.setAttribute('aria-hidden', 'true'); spacer.style.setProperty('--ovm-h', String(hueOf(sender))); text(spacer, String(message.display_name || message.username || '?').trim().charAt(0).toUpperCase()); group.appendChild(spacer); } |
| 127 | else if (!mine) { const gap = document.createElement('span'); gap.className = 'ovm-avatar is-gap'; gap.setAttribute('aria-hidden', 'true'); group.appendChild(gap); } // a run keeps the avatar's column |
| 128 | const body = document.createElement('div'); body.className = 'ovm-group-body'; |
| 129 | if (!mine && !same) { const author = document.createElement('div'); author.className = 'ovm-author'; text(author, message.display_name || message.username || 'Someone'); body.appendChild(author); } |
| 130 | const bubble = document.createElement('div'); bubble.className = 'ovm-bubble'; |
| 131 | if (message.reply_to) { const quote = document.createElement('span'); quote.className = 'ovm-reply'; text(quote, message.reply_to.message || message.reply_to.text || 'Reply'); bubble.appendChild(quote); } |
| 132 | const content = document.createElement('span'); content.className = 'ovm-text'; const value = String(message.message || message.text || ''); |
| 133 | if (/^(?:\p{Extended_Pictographic}|\s)+$/u.test(value) && value.trim()) bubble.classList.add('ovm-emoji-only'); |
| 134 | safeLinkify(content, value); bubble.appendChild(content); body.appendChild(bubble); |
| 135 | if (message.edited_at) edited(content, message.edited_at); |
| 136 | const ids = typeof offer === 'function' ? offer(message, { mine }) : offer; |
| 137 | if (ids && ids.length) actions(bubble, ids, { onAction, message, mine }); |
| 138 | if (!messages[index + 1] || String(messages[index + 1].sender_id || messages[index + 1].user_id || messages[index + 1].from || messages[index + 1].username || '') !== sender || !stamp || !timeValue(messages[index + 1]) || timeValue(messages[index + 1]).getTime() - stamp > 180000) { |
| 139 | const meta = document.createElement('div'); meta.className = 'ovm-meta'; const time = document.createElement('time'); if (date) { time.dateTime = date.toISOString(); text(time, new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(date)); } meta.appendChild(time); |
| 140 | if (mine && message.read) { const tick = document.createElement('span'); tick.className = 'ovm-tick'; tick.setAttribute('aria-label', 'Read'); text(tick, '✓✓'); meta.appendChild(tick); } |
| 141 | body.appendChild(meta); |
| 142 | } |
| 143 | group.appendChild(body); root.appendChild(group); |
| 144 | } |
| 145 | previous = { sender, stamp }; |
| 146 | }); |
| 147 | if (offer) tapToAct(root); |
| 148 | return root; |
| 149 | } |
| 150 | const api = { render, actions, edited, tapToAct }; |
| 151 | if (typeof module !== 'undefined' && module.exports) module.exports = api; |
| 152 | if (typeof window !== 'undefined') { |
| 153 | window.OVMessages = api; |
| 154 | const ov = window.ov = window.ov || {}; |
| 155 | ov.chat = ov.chat || {}; |
| 156 | ov.chat.messages = api; |
| 157 | } |
| 158 |