openvibers/openvibe.shared messages.js

messages.js

main 12.8 KB · 158 lines · javascript

1'use strict';
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 */
15const text = (parent, value) => parent.appendChild(document.createTextNode(String(value == null ? '' : value)));
16function 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}
26function 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; }
27function 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. */
29function hueOf(id) { let x = 7; for (const c of String(id || '')) x = (x * 31 + c.codePointAt(0)) % 360; return x; }
30function 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).
36const 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};
44const LABEL = { reply: 'Reply', edit: 'Edit your message', react: 'React', translate: 'Translate', copy: 'Copy text', delete: 'Delete your message' };
45const OWN = new Set(['edit', 'delete']);
46function 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. */
55function 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. */
79function 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. */
90function 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}
102function 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}
150const api = { render, actions, edited, tapToAct };
151if (typeof module !== 'undefined' && module.exports) module.exports = api;
152if (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