293 lines
9.6 KiB
JavaScript
293 lines
9.6 KiB
JavaScript
let conversations = [];
|
|
let currentConv = null;
|
|
let currentMessages = [];
|
|
let selfName = "";
|
|
let searchTerm = "";
|
|
let searchTimer = null;
|
|
let matchIndices = [];
|
|
let currentMatchPos = -1;
|
|
|
|
// Color palette for group chat senders
|
|
const senderColors = [
|
|
"#075e54", "#25d366", "#128c7e", "#00a884",
|
|
"#6b3fa0", "#d4653f", "#1f97c7", "#c74375",
|
|
"#e68a2e", "#7b5ea7", "#3d8c5c", "#c44d58"
|
|
];
|
|
const senderColorMap = {};
|
|
let colorIdx = 0;
|
|
|
|
function getSenderColor(name) {
|
|
if (!senderColorMap[name]) {
|
|
senderColorMap[name] = senderColors[colorIdx % senderColors.length];
|
|
colorIdx++;
|
|
}
|
|
return senderColorMap[name];
|
|
}
|
|
|
|
async function init() {
|
|
await fetchConversations();
|
|
renderConversationList();
|
|
|
|
document.getElementById("search-input").addEventListener("input", onSearch);
|
|
document.getElementById("self-name").addEventListener("change", onSelfChange);
|
|
}
|
|
|
|
async function fetchConversations(query) {
|
|
const url = query ? `/api/conversations?q=${encodeURIComponent(query)}` : "/api/conversations";
|
|
const resp = await fetch(url);
|
|
conversations = await resp.json() || [];
|
|
}
|
|
|
|
function renderConversationList() {
|
|
const list = document.getElementById("conversation-list");
|
|
list.innerHTML = "";
|
|
for (const c of conversations) {
|
|
const div = document.createElement("div");
|
|
div.className = "conv-item";
|
|
if (currentConv && c.id === currentConv.id) div.className += " active";
|
|
div.dataset.id = c.id;
|
|
const date = c.lastMessageDate ? formatDate(new Date(c.lastMessageDate)) : "";
|
|
div.innerHTML = `
|
|
<div class="conv-name">${esc(c.name)}</div>
|
|
<div class="conv-meta">${c.messageCount} messages · ${date}</div>
|
|
`;
|
|
div.onclick = () => loadConversation(c.id);
|
|
list.appendChild(div);
|
|
}
|
|
}
|
|
|
|
async function loadConversation(id) {
|
|
// Update active state
|
|
document.querySelectorAll(".conv-item").forEach(el => {
|
|
el.classList.toggle("active", el.dataset.id === id);
|
|
});
|
|
|
|
const resp = await fetch(`/api/conversation/${id}`);
|
|
currentConv = await resp.json();
|
|
currentMessages = currentConv.messages || [];
|
|
|
|
document.getElementById("chat-name").textContent = currentConv.name;
|
|
|
|
// Detect participants and set up self selector
|
|
const participants = new Set();
|
|
for (const m of currentMessages) {
|
|
if (m.sender) participants.add(m.sender);
|
|
}
|
|
|
|
const sel = document.getElementById("self-name");
|
|
sel.innerHTML = "";
|
|
for (const p of participants) {
|
|
const opt = document.createElement("option");
|
|
opt.value = p;
|
|
opt.textContent = p;
|
|
sel.appendChild(opt);
|
|
}
|
|
|
|
// Auto-detect: first sender is likely "self"
|
|
if (participants.size > 0) {
|
|
const firstSender = currentMessages.find(m => m.sender)?.sender || "";
|
|
selfName = firstSender;
|
|
sel.value = selfName;
|
|
}
|
|
|
|
document.getElementById("self-selector").style.display = participants.size > 1 ? "" : "none";
|
|
|
|
renderMessages();
|
|
}
|
|
|
|
function onSelfChange() {
|
|
selfName = document.getElementById("self-name").value;
|
|
renderMessages();
|
|
}
|
|
|
|
function onSearch() {
|
|
clearTimeout(searchTimer);
|
|
searchTimer = setTimeout(async () => {
|
|
searchTerm = document.getElementById("search-input").value.toLowerCase().trim();
|
|
await fetchConversations(searchTerm);
|
|
renderConversationList();
|
|
if (currentConv) {
|
|
renderMessages();
|
|
}
|
|
}, 200);
|
|
}
|
|
|
|
function renderMessages() {
|
|
const container = document.getElementById("chat-messages");
|
|
|
|
let msgs = currentMessages;
|
|
let firstMatchIdx = -1;
|
|
|
|
// Detect if group chat (>2 participants)
|
|
const participants = new Set();
|
|
for (const m of currentMessages) {
|
|
if (m.sender) participants.add(m.sender);
|
|
}
|
|
const isGroup = participants.size > 2;
|
|
|
|
let html = "";
|
|
let lastDate = "";
|
|
let msgIdx = 0;
|
|
|
|
for (const m of msgs) {
|
|
const d = new Date(m.timestamp);
|
|
const dateStr = d.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
|
|
|
if (dateStr !== lastDate) {
|
|
html += `<div class="date-header"><span>${dateStr}</span></div>`;
|
|
lastDate = dateStr;
|
|
}
|
|
|
|
const isMatch = searchTerm && (m.text || "").toLowerCase().includes(searchTerm);
|
|
if (isMatch && firstMatchIdx === -1) firstMatchIdx = msgIdx;
|
|
|
|
if (m.isSystem) {
|
|
html += `<div class="message system" data-idx="${msgIdx}"><div class="msg-text">${highlight(esc(m.text), searchTerm)}</div></div>`;
|
|
msgIdx++;
|
|
continue;
|
|
}
|
|
|
|
const isSelf = m.sender === selfName;
|
|
const cls = isSelf ? "self" : "other";
|
|
const timeStr = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
|
|
|
let senderHtml = "";
|
|
if (!isSelf && isGroup) {
|
|
const color = getSenderColor(m.sender);
|
|
senderHtml = `<div class="sender-name" style="color:${color}">${esc(m.sender)}</div>`;
|
|
}
|
|
|
|
let attachHtml = "";
|
|
if (m.attachments) {
|
|
for (const a of m.attachments) {
|
|
const url = `/api/media/${currentConv.id}/${encodeURIComponent(a.filename)}`;
|
|
switch (a.type) {
|
|
case "image":
|
|
attachHtml += `<img class="msg-image" src="${url}" alt="${esc(a.filename)}" loading="lazy" onclick="openLightbox(event, '${url}')">`;
|
|
break;
|
|
case "sticker":
|
|
attachHtml += `<img class="msg-sticker" src="${url}" alt="sticker" loading="lazy">`;
|
|
break;
|
|
case "video":
|
|
attachHtml += `<video class="msg-video" controls preload="none"><source src="${url}"></video>`;
|
|
break;
|
|
case "audio":
|
|
attachHtml += `<audio class="msg-audio" controls preload="none"><source src="${url}"></audio>`;
|
|
break;
|
|
default:
|
|
attachHtml += `<a class="msg-document" href="${url}" download="${esc(a.filename)}">${esc(a.filename)}</a>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove attachment filenames from displayed text
|
|
let displayText = m.text || "";
|
|
if (m.attachments) {
|
|
for (const a of m.attachments) {
|
|
displayText = displayText.replace(a.filename, "").trim();
|
|
}
|
|
}
|
|
|
|
let textHtml = "";
|
|
if (displayText) {
|
|
textHtml = `<div class="msg-text">${highlight(esc(displayText), searchTerm)}</div>`;
|
|
}
|
|
|
|
html += `<div class="message ${cls}${isMatch ? " search-match" : ""}" data-idx="${msgIdx}">
|
|
${senderHtml}${attachHtml}${textHtml}
|
|
<div class="msg-time">${timeStr}</div>
|
|
</div>`;
|
|
msgIdx++;
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
|
|
// Build match index list
|
|
matchIndices = [];
|
|
container.querySelectorAll(".search-match").forEach(el => {
|
|
matchIndices.push(parseInt(el.dataset.idx));
|
|
});
|
|
|
|
const nav = document.getElementById("search-nav");
|
|
if (searchTerm && matchIndices.length > 0) {
|
|
nav.classList.remove("hidden");
|
|
currentMatchPos = 0;
|
|
updateSearchNav();
|
|
scrollToMatch(currentMatchPos);
|
|
} else if (searchTerm) {
|
|
nav.classList.remove("hidden");
|
|
currentMatchPos = -1;
|
|
document.getElementById("search-pos").textContent = "0/0";
|
|
} else {
|
|
nav.classList.add("hidden");
|
|
currentMatchPos = -1;
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
}
|
|
|
|
function updateSearchNav() {
|
|
document.getElementById("search-pos").textContent =
|
|
`${currentMatchPos + 1}/${matchIndices.length}`;
|
|
// Remove highlight from all, add to current
|
|
document.querySelectorAll(".search-current").forEach(el => el.classList.remove("search-current"));
|
|
if (currentMatchPos >= 0) {
|
|
const el = document.querySelector(`[data-idx="${matchIndices[currentMatchPos]}"]`);
|
|
if (el) el.classList.add("search-current");
|
|
}
|
|
}
|
|
|
|
function scrollToMatch(pos) {
|
|
if (pos < 0 || pos >= matchIndices.length) return;
|
|
const el = document.querySelector(`[data-idx="${matchIndices[pos]}"]`);
|
|
if (el) el.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
}
|
|
|
|
function searchNext() {
|
|
if (matchIndices.length === 0) return;
|
|
currentMatchPos = (currentMatchPos + 1) % matchIndices.length;
|
|
updateSearchNav();
|
|
scrollToMatch(currentMatchPos);
|
|
}
|
|
|
|
function searchPrev() {
|
|
if (matchIndices.length === 0) return;
|
|
currentMatchPos = (currentMatchPos - 1 + matchIndices.length) % matchIndices.length;
|
|
updateSearchNav();
|
|
scrollToMatch(currentMatchPos);
|
|
}
|
|
|
|
function esc(s) {
|
|
if (!s) return "";
|
|
const div = document.createElement("div");
|
|
div.textContent = s;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function highlight(html, term) {
|
|
if (!term) return html;
|
|
const re = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
|
|
return html.replace(re, "<mark>$1</mark>");
|
|
}
|
|
|
|
function formatDate(d) {
|
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
}
|
|
|
|
function openLightbox(e, url) {
|
|
e.stopPropagation();
|
|
const lb = document.getElementById("lightbox");
|
|
document.getElementById("lightbox-img").src = url;
|
|
lb.classList.remove("hidden");
|
|
}
|
|
|
|
function closeLightbox() {
|
|
document.getElementById("lightbox").classList.add("hidden");
|
|
document.getElementById("lightbox-img").src = "";
|
|
}
|
|
|
|
document.addEventListener("keydown", e => {
|
|
if (e.key === "Escape") closeLightbox();
|
|
});
|
|
|
|
init();
|