205 lines
8.2 KiB
JavaScript
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));
|
|
}
|
|
});
|