/* ================================================================
Nexus Chat — Client Patch (injected by server)
Replaces socket.io with native WebSocket + adds new features.
Does NOT modify HTML structure or CSS.
================================================================ */
(function() {
'use strict';
// ── 1. Fix: allow any string as email (remove browser type=email enforcement) ──
document.addEventListener('DOMContentLoaded', () => {
['rem','lem'].forEach(id => {
const el = document.getElementById(id);
if (el) el.setAttribute('type', 'text');
});
});
// ── 2. Override window.io with native WebSocket wrapper ──────────
window.io = function(url, opts) {
const handlers = {};
let ws = null;
let reconnTimer = null;
let reconnDelay = 1000;
let closed = false;
const token = opts?.auth?.token ?? '';
function getWsUrl() {
const base = url.replace(/^http/, 'ws');
return base.replace(/\/$/, '') + '/ws?token=' + encodeURIComponent(token);
}
const sock = {
id: Math.random().toString(36).slice(2),
connected: false,
on(event, cb) {
if (!handlers[event]) handlers[event] = [];
handlers[event].push(cb);
return sock;
},
emit(event, data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ e: event, d: data }));
}
return sock;
},
disconnect() {
closed = true;
clearTimeout(reconnTimer);
if (ws) ws.close();
},
};
function fire(event, ...args) {
(handlers[event] || []).forEach(cb => { try { cb(...args); } catch(e) { console.warn('handler err', event, e); } });
}
function connect() {
if (closed) return;
try { ws = new WebSocket(getWsUrl()); } catch(e) { scheduleReconn(); return; }
ws.onopen = () => {
sock.connected = true;
reconnDelay = 1000;
fire('connect');
};
ws.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
fire(msg.e, msg.d);
};
ws.onclose = () => {
sock.connected = false;
fire('disconnect');
scheduleReconn();
};
ws.onerror = (err) => {
fire('connect_error', err);
};
}
function scheduleReconn() {
if (closed) return;
clearTimeout(reconnTimer);
reconnTimer = setTimeout(() => { if (!closed) connect(); }, reconnDelay);
reconnDelay = Math.min(reconnDelay * 1.5, 15000);
}
connect();
return sock;
};
// ── 3. Web Push subscription ────────────────────────────────────
async function setupWebPush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
if (Notification.permission !== 'granted') return;
try {
const { publicKey } = await fetch('/vapid-key').then(r => r.json());
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
}
const tok = localStorage.getItem('nx_tok');
if (tok && sub) {
await fetch('/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + tok },
body: JSON.stringify({ subscription: sub }),
});
}
} catch(e) { console.warn('Push setup:', e.message); }
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i);
return outputArray;
}
// ── 4. Patch: home list shows name + email below ────────────────
const _origRenderRooms = window.renderRooms;
function patchedRenderRooms() {
if (typeof renderRooms !== 'function') return;
// We'll hook the RL element after the original renders
const orig = window.renderRooms;
window.renderRooms = function() {
orig.call(this);
// Now patch each .ri-inf to show email below name for DMs
if (!window.me || !window.rooms) return;
const rl = document.getElementById('RL');
if (!rl) return;
rl.querySelectorAll('.ri').forEach(ri => {
const roomId = ri.dataset.room;
const room = (window.rooms || []).find(r => r.roomId === roomId);
if (!room || room.type !== 'dm') return;
const other = (room.members || []).find(m => m !== window.me?.email);
if (!other) return;
const rnEl = ri.querySelector('.rn');
if (!rnEl) return;
// Check if email already shown
if (ri.querySelector('.ri-email')) return;
const emailEl = document.createElement('div');
emailEl.className = 'ri-email';
emailEl.style.cssText = 'font-size:10px;color:var(--tx3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:1px';
emailEl.textContent = other;
const rl2 = ri.querySelector('.rl2');
if (rl2) rl2.parentNode.insertBefore(emailEl, rl2);
});
};
}
// ── 5. Patch: friend_accepted → also reload rooms + open DM ─────
function patchFriendAccepted() {
const orig = window.loadFriends;
// We hook into the socket's friend_accepted event via a patched startApp
const origStartApp = window.startApp;
window.startApp = async function() {
await origStartApp.call(this);
// Override the friend_accepted handler after socket is set up
setTimeout(() => {
if (!window.sk) return;
const handlers = window.sk._handlers || {};
// Patch: after friend accepted, also load rooms + open new DM
window.sk.on('friend_accepted', function(data) {
if (data && data.room) {
const room = data.room;
if (window.rooms && !window.rooms.find(r => r.roomId === room.roomId)) {
window.rooms.push(room);
}
if (typeof window.renderRooms === 'function') window.renderRooms();
if (typeof window.renderMyG === 'function') window.renderMyG();
}
});
window.sk.on('new_room', function(room) {
if (!room) return;
if (window.rooms && !window.rooms.find(r => r.roomId === room.roomId)) {
window.rooms.push(room);
if (typeof window.renderRooms === 'function') window.renderRooms();
if (typeof window.renderMyG === 'function') window.renderMyG();
}
});
}, 500);
// Setup web push after login
setTimeout(setupWebPush, 2000);
};
}
// ── 6. Last online time display ──────────────────────────────────
const lastSeenCache = {};
async function fetchLastSeen(emails) {
if (!emails.length) return;
const tok = localStorage.getItem('nx_tok');
if (!tok) return;
try {
const data = await fetch('/users/last-seen?emails=' + emails.join(','), {
headers: { 'Authorization': 'Bearer ' + tok }
}).then(r => r.json());
Object.assign(lastSeenCache, data);
} catch {}
}
function formatLastSeen(ts) {
if (!ts) return 'Chưa rõ';
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 2) return 'Vừa xong';
if (mins < 60) return `${mins} phút trước`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs} giờ trước`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days} ngày trước`;
return new Date(ts).toLocaleDateString('vi');
}
// ── 7. File attachment ───────────────────────────────────────────
function injectAttachButton() {
const inb = document.querySelector('.inb');
if (!inb || document.getElementById('nx-attach')) return;
const btn = document.createElement('button');
btn.id = 'nx-attach';
btn.title = 'Đính kèm file';
btn.style.cssText = 'width:44px;height:44px;border-radius:50%;flex-shrink:0;background:var(--bg2);border:1.5px solid var(--bdr);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:18px;box-shadow:var(--shsm);order:-1';
btn.textContent = '📎';
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = 'nx-file-inp';
fileInput.accept = 'image/*,video/*,.pdf,.doc,.docx,.zip,.txt,.xlsx,.pptx,.mp3,.wav';
fileInput.style.display = 'none';
fileInput.multiple = false;
fileInput.onchange = handleFileAttach;
btn.onclick = () => fileInput.click();
inb.insertBefore(fileInput, inb.firstChild);
inb.insertBefore(btn, inb.firstChild);
}
async function handleFileAttach(evt) {
const file = evt.target.files[0];
if (!file || !window.curR) return;
evt.target.value = '';
const tok = localStorage.getItem('nx_tok');
if (!tok) return;
const fd = new FormData();
fd.append('file', file);
try {
const progressEl = showUploadProgress(file.name);
const res = await fetch('/upload', { method: 'POST', headers: { Authorization: 'Bearer ' + tok }, body: fd });
if (progressEl) progressEl.remove();
if (!res.ok) { alert('Upload thất bại'); return; }
const { url, name, size, mime } = await res.json();
const type = mime?.startsWith('image/') ? 'image' : mime?.startsWith('video/') ? 'video' : 'file';
if (window.sk) {
window.sk.emit('send_message', { roomId: window.curR, content: null, type, fileUrl: url, fileName: name, fileSize: size });
}
} catch(e) { alert('Lỗi upload: ' + e.message); }
}
function showUploadProgress(name) {
const ms = document.getElementById('MS');
if (!ms) return null;
const el = document.createElement('div');
el.style.cssText = 'padding:8px 12px;background:var(--bg2);border-radius:8px;font-size:12px;color:var(--tx2);margin:4px 0';
el.textContent = `⏳ Đang tải lên ${name}…`;
ms.appendChild(el);
ms.scrollTop = ms.scrollHeight;
return el;
}
// ── 8. Patch appendMsg to render images/videos/files ────────────
function patchAppendMsg() {
const origAppendMsg = window.appendMsg;
if (!origAppendMsg) return;
window.appendMsg = function(msg) {
if (!msg.type || msg.type === 'text') {
origAppendMsg.call(this, msg);
return;
}
// Render file/image/video message
const el = document.getElementById('MS');
if (!el) return;
const isMe = msg.sender === window.me?.email;
const time = new Date(msg.timestamp).toLocaleTimeString('vi', { hour: '2-digit', minute: '2-digit' });
const col = window.COLS ? window.COLS[Math.abs(hashStr(msg.sender)) % window.COLS.length] : '#4575e8';
const ava = msg.senderAvatar
? ``
: (msg.senderName || '?')[0].toUpperCase();
let mediaHtml = '';
if (msg.type === 'image' && msg.fileUrl) {
mediaHtml = `
`;
} else if (msg.type === 'video' && msg.fileUrl) {
mediaHtml = ``;
} else if (msg.fileUrl) {
mediaHtml = `📎 ${escapeHtml(msg.fileName || 'Tệp đính kèm')}`;
}
const d = document.createElement('div');
d.className = 'mg' + (isMe ? ' me' : '');
d.dataset.mid = msg.id;
d.innerHTML = `