Files
movim-rs/frontend/widgets/ChatActions/chatactions.js
T
2026-10-24 17:46:20 +07:00

205 lines
8.2 KiB
JavaScript

var ChatActions = {
message: null,
// OMEMO messages of the open conversation: {jid, entries: [[mid, id, messageid, published]], bodies}
index: null,
keywords: '',
setMessage: function (message) {
ChatActions.message = message;
},
focusSearch: function () {
document.querySelector('form[name=search] input').focus();
},
// Same as utils::search_normalize on the server
normalize: function (text) {
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replaceAll('đ', 'd');
},
setIndex: function (jid, entries) {
ChatActions.index = { jid: jid, entries: entries, bodies: null };
},
// Decrypted bodies of the indexed messages, from this browser: mid → body
loadBodies: async function () {
let index = ChatActions.index;
if (!index) return new Map();
if (index.bodies) return index.bodies;
let bodies = new Map();
if (typeof ChatOmemoDB != 'undefined' && ChatOmemoDB.db) {
for (const [mid, id, messageid] of index.entries) {
let body = await ChatOmemoDB.getMessage(id);
if (typeof body != 'string' && messageid) body = await ChatOmemoDB.getMessage(messageid);
if (typeof body == 'string' && body) bodies.set(mid, body);
}
}
index.bodies = bodies;
return bodies;
},
published: function (mid) {
let entry = ChatActions.index?.entries.find(e => e[0] == mid);
return entry ? entry[3] : '';
},
// Search: the encrypted messages here, then the server merges them with
// the clear text ones
search: async function (jid, keywords, muc) {
ChatActions.keywords = keywords;
let words = ChatActions.normalize(keywords).split(/\s+/).filter(w => w);
let mids = [];
if (words.length && ChatActions.index && ChatActions.index.jid == jid) {
for (const [mid, body] of await ChatActions.loadBodies()) {
if (typeof AESGCM_REGEX != 'undefined' && AESGCM_REGEX.test(body.trim())) continue;
let normalized = ChatActions.normalize(body);
if (words.every(w => normalized.includes(w))) mids.push(mid);
if (mids.length >= 20) break;
}
}
// A newer search started meanwhile
if (keywords != ChatActions.keywords) return;
ChatActions_ajaxSearchMessages(jid, keywords, muc, mids);
},
fillEncrypted: async function () {
let bodies = await ChatActions.loadBodies();
let words = ChatActions.keywords.split(/\s+/).filter(w => w);
document.querySelectorAll('#chat_search p.encrypted[data-mid]').forEach(p => {
let body = bodies.get(parseInt(p.dataset.mid));
if (!body) return;
p.classList.remove('encrypted');
ChatActions.highlight(p, body, words);
});
},
// Text with the words marked, built with the DOM (the body is not trusted)
highlight: function (element, text, words) {
element.textContent = '';
let escaped = words.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
let parts = escaped.length ? text.split(new RegExp('(' + escaped.join('|') + ')', 'gi')) : [text];
parts.forEach((part, i) => {
if (i % 2 == 1) {
let mark = document.createElement('mark');
mark.textContent = part;
element.appendChild(mark);
} else if (part) {
element.appendChild(document.createTextNode(part));
}
});
},
// Shared files drawer
filesTab: function (tab) {
document.querySelectorAll('#chat_files .tabs li').forEach(li => li.classList.toggle('active', li.dataset.tab == tab));
document.querySelectorAll('#chat_files .files_tab').forEach(div => div.classList.toggle('hide', div.dataset.tab != tab));
ChatActions.filesEmpty();
},
filesEmpty: function () {
let current = document.querySelector('#chat_files .files_tab:not(.hide) [data-list]');
let empty = document.querySelector('#chat_files .files_empty');
if (current && empty) empty.classList.toggle('hide', current.children.length > 0);
},
addEncryptedFiles: async function (jid) {
if (typeof AESGCM_REGEX == 'undefined') return ChatActions.filesEmpty();
for (const [mid, body] of await ChatActions.loadBodies()) {
let url = body.trim();
let matches = url.match(AESGCM_REGEX);
if (!matches) continue;
let filename = matches[2];
let type = AESGCM_TYPES[matches[3].toLowerCase()] ?? '';
let tab = type.startsWith('image/') ? 'images'
: type.startsWith('video/') ? 'videos'
: type.startsWith('audio/') ? 'recordings'
: 'files';
let list = document.querySelector('#chat_files [data-list=' + tab + ']');
if (!list) return;
let li = tab == 'images'
? ChatActions.encryptedPicture(url, filename)
: ChatActions.encryptedFileItem(jid, mid, url, filename, tab, list.dataset.icon);
li.dataset.published = ChatActions.published(mid);
// Keep the newest first, mixed with the clear text files
let older = Array.from(list.children).find(item => item.dataset.published < li.dataset.published);
list.insertBefore(li, older ?? null);
}
ChatActions.filesEmpty();
},
encryptedPicture: function (url, filename) {
let li = document.createElement('li');
let img = document.createElement('img');
img.alt = img.title = filename;
li.appendChild(img);
// Downloaded and decrypted when shown
new IntersectionObserver((entries, observer) => {
if (!entries.some(e => e.isIntersecting)) return;
observer.disconnect();
ChatOmemo.decryptFile(url).then(blobUrl => {
img.src = blobUrl;
img.onclick = () => window.open(blobUrl, '_blank');
}).catch(e => li.classList.add('error'));
}).observe(li);
return li;
},
encryptedFileItem: function (jid, mid, url, filename, tab, icon) {
let li = document.createElement('li');
let primary = document.createElement('span');
primary.className = 'primary icon gray';
primary.innerHTML = '<i class="material-symbols"></i>';
primary.firstChild.textContent = icon;
let control = document.createElement('span');
control.className = 'control icon active gray';
control.innerHTML = '<i class="material-symbols">chat</i>';
control.onclick = e => {
e.stopPropagation();
Chat_ajaxGetMessageContext(jid, mid);
Drawer.clear();
};
let div = document.createElement('div');
let name = document.createElement('p');
name.className = 'line';
name.textContent = filename;
let info = document.createElement('p');
info.className = 'line';
let published = ChatActions.published(mid);
info.textContent = published ? new Date(published.replace(' ', 'T') + 'Z').toLocaleString() : '';
div.append(name, info);
div.onclick = async () => {
if (tab == 'recordings') {
// Play it in place
if (div.querySelector('audio')) return;
let audio = document.createElement('audio');
audio.controls = true;
audio.src = await ChatOmemo.decryptFile(url);
name.after(audio);
audio.play();
} else if (tab == 'videos') {
window.open(await ChatOmemo.decryptFile(url), '_blank');
} else {
ChatOmemo.getEncryptedFile(url);
}
};
li.append(primary, control, div);
return li;
}
}
MovimEvents.registerWindow('keydown', 'search_message', (e) => {
if (e.key == 'f' && e.ctrlKey && MovimUtils.urlParts().page == 'chat' && Chat.getTextarea()) {
e.preventDefault();
ChatActions_ajaxShowSearchDialog(Chat.getTextarea().dataset.jid, Boolean(Chat.getTextarea().dataset.muc));
}
});