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 = `
${esc(c.name)}
${c.messageCount} messages · ${date}
`;
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 += ``;
lastDate = dateStr;
}
const isMatch = searchTerm && (m.text || "").toLowerCase().includes(searchTerm);
if (isMatch && firstMatchIdx === -1) firstMatchIdx = msgIdx;
if (m.isSystem) {
html += `${highlight(esc(m.text), searchTerm)}
`;
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 = `${esc(m.sender)}
`;
}
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 += `
`;
break;
case "sticker":
attachHtml += `
`;
break;
case "video":
attachHtml += ``;
break;
case "audio":
attachHtml += ``;
break;
default:
attachHtml += `${esc(a.filename)}`;
}
}
}
// 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 = `${highlight(esc(displayText), searchTerm)}
`;
}
html += `
${senderHtml}${attachHtml}${textHtml}
${timeStr}
`;
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, "$1");
}
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();