Spaces:
Running
Running
Update static/script.js
Browse files- static/script.js +463 -18
static/script.js
CHANGED
|
@@ -53,6 +53,8 @@
|
|
| 53 |
typingUsers: new Map(), // convId -> {userId -> {name, timer}}
|
| 54 |
typingSentAt: 0,
|
| 55 |
onlineUsers: new Map(),
|
|
|
|
|
|
|
| 56 |
isAtBottom: true,
|
| 57 |
latestUnseen: null,
|
| 58 |
unreadTotal: 0,
|
|
@@ -115,6 +117,16 @@
|
|
| 115 |
confirmModal: $('confirm-modal'), lightboxModal: $('lightbox-modal'),
|
| 116 |
emojiPicker: $('emoji-picker'), emojiGrid: $('emoji-grid'),
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
settingsDisplayName: $('settings-display-name'), settingsUsername: $('settings-username'),
|
| 119 |
settingsAvatarPreview: $('settings-avatar-preview'), settingsAvatarImg: $('settings-avatar-img'),
|
| 120 |
avatarInput: $('avatar-input'), avatarUploadBtn: $('avatar-upload-btn'),
|
|
@@ -153,6 +165,14 @@
|
|
| 153 |
return str.length > n ? str.substr(0, n - 1) + '…' : str;
|
| 154 |
}
|
| 155 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
function escapeHtml(text) {
|
| 157 |
if (text === null || text === undefined) return '';
|
| 158 |
const div = document.createElement('div');
|
|
@@ -571,7 +591,18 @@
|
|
| 571 |
// ============================================================
|
| 572 |
function convById(id) { return State.conversations.get(id); }
|
| 573 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
function upsertConversation(summary) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 575 |
State.conversations.set(summary.id, summary);
|
| 576 |
if (!State.convCache.has(summary.id)) {
|
| 577 |
State.convCache.set(summary.id, { ids: [], cursor: null, hasMore: true, lastTs: 0 });
|
|
@@ -579,10 +610,6 @@
|
|
| 579 |
renderSidebar();
|
| 580 |
}
|
| 581 |
|
| 582 |
-
function deleteConversationLocally(id) {
|
| 583 |
-
// reserved: DM deletion UI may land in a later version
|
| 584 |
-
}
|
| 585 |
-
|
| 586 |
function convSortKey(c) {
|
| 587 |
if (c.id === GLOBAL_CONV) return [0, 0];
|
| 588 |
return [1, -(c.last_message_ts || 0)];
|
|
@@ -610,11 +637,20 @@
|
|
| 610 |
}
|
| 611 |
DOM.convList.innerHTML = html;
|
| 612 |
DOM.convList.querySelectorAll('.conv-item').forEach((el) => {
|
| 613 |
-
el.addEventListener('click', () => {
|
|
|
|
| 614 |
switchConversation(parseInt(el.dataset.id, 10));
|
| 615 |
closeSidebar();
|
| 616 |
});
|
| 617 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
State.unreadTotal = unreadTotal;
|
| 619 |
updateTitleUnread();
|
| 620 |
}
|
|
@@ -626,31 +662,39 @@
|
|
| 626 |
|
| 627 |
function convItemHTML(c) {
|
| 628 |
const isDm = c.type === 'dm';
|
| 629 |
-
const
|
|
|
|
| 630 |
const active = c.id === State.activeConvId ? ' active' : '';
|
| 631 |
const time = c.last_message_ts ? formatTime(c.last_message_ts) : '';
|
| 632 |
const unread = Math.min(c.unread_count || 0, 99);
|
| 633 |
const unreadHtml = unread > 0
|
| 634 |
? `<span class="conv-badge">${unread}</span>`
|
| 635 |
: '';
|
|
|
|
| 636 |
const timeCls = unread > 0 ? ' unread' : '';
|
|
|
|
| 637 |
let avatar = avatarHTML(c, 44);
|
| 638 |
-
return `<
|
| 639 |
${avatar}
|
| 640 |
<div class="conv-info">
|
| 641 |
<div class="conv-top">
|
| 642 |
<span class="conv-name">${escapeHtml(name)}</span>
|
|
|
|
|
|
|
|
|
|
| 643 |
<span class="conv-time${timeCls}">${escapeHtml(time)}</span>
|
|
|
|
| 644 |
</div>
|
| 645 |
<div class="conv-bottom">
|
| 646 |
<span class="conv-preview">${previewHTML(c)}</span>
|
| 647 |
-
${unreadHtml}
|
| 648 |
</div>
|
|
|
|
| 649 |
</div>
|
| 650 |
-
</
|
| 651 |
}
|
| 652 |
|
| 653 |
function dmLabel(c) {
|
|
|
|
| 654 |
const base = displayNameOf(c.peer);
|
| 655 |
return c.dm_number && c.dm_number > 1 ? `${base} · ${c.dm_number}` : base;
|
| 656 |
}
|
|
@@ -665,6 +709,9 @@
|
|
| 665 |
}
|
| 666 |
|
| 667 |
function avatarHTML(c, px) {
|
|
|
|
|
|
|
|
|
|
| 668 |
if (c.type === 'dm' && c.peer) {
|
| 669 |
const on = c.peer.online ? ' online' : '';
|
| 670 |
let inner = `<span class="conv-presence${on}"></span>`;
|
|
@@ -749,8 +796,9 @@
|
|
| 749 |
if (!conv) return;
|
| 750 |
const dm = conv.type === 'dm';
|
| 751 |
const peer = conv.peer;
|
|
|
|
| 752 |
|
| 753 |
-
if (dm && peer) {
|
| 754 |
DOM.convTitle.textContent = dmLabel(conv);
|
| 755 |
if (peer.avatar_path) {
|
| 756 |
DOM.convAvatar.src = fileUrl(peer.avatar_path);
|
|
@@ -769,6 +817,21 @@
|
|
| 769 |
? '<span class="status-dot online"></span> Online'
|
| 770 |
: escapeHtml(formatLastSeen(peer.last_seen));
|
| 771 |
DOM.onlineBtn.style.display = '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 772 |
} else {
|
| 773 |
DOM.convTitle.textContent = 'Global Chat';
|
| 774 |
DOM.convAvatar.classList.add('hidden');
|
|
@@ -776,10 +839,11 @@
|
|
| 776 |
DOM.convAvatarPh.className = 'conv-avatar-ph';
|
| 777 |
DOM.convAvatarPh.innerHTML = '<i class="fas fa-globe"></i>';
|
| 778 |
DOM.convPresenceDot.classList.add('hidden');
|
| 779 |
-
const meCount =
|
| 780 |
DOM.convSubtitle.innerHTML = `<span class="status-dot online"></span> ${meCount} online`;
|
| 781 |
DOM.onlineBtn.style.display = '';
|
| 782 |
}
|
|
|
|
| 783 |
}
|
| 784 |
|
| 785 |
function openSidebar() {
|
|
@@ -919,6 +983,7 @@
|
|
| 919 |
updateSidebarMe();
|
| 920 |
updateOnlineBadge();
|
| 921 |
updateAllPeerPresence();
|
|
|
|
| 922 |
if (!DOM.onlineModal.classList.contains('hidden')) renderOnlineUsers();
|
| 923 |
|
| 924 |
// fresh session: adopt server conversation list, drop old caches
|
|
@@ -955,6 +1020,48 @@
|
|
| 955 |
}
|
| 956 |
break;
|
| 957 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 958 |
case 'dm_created': {
|
| 959 |
upsertConversation(msg.conversation);
|
| 960 |
const peer = msg.conversation.peer;
|
|
@@ -1175,6 +1282,25 @@
|
|
| 1175 |
});
|
| 1176 |
}
|
| 1177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1178 |
function updatePeerPresence(userId, online) {
|
| 1179 |
let changed = false;
|
| 1180 |
State.conversations.forEach(c => {
|
|
@@ -1941,6 +2067,8 @@
|
|
| 1941 |
const content = DOM.messageInput.value.trim();
|
| 1942 |
if (!content && !State.pendingFile) return;
|
| 1943 |
if (!State.activeConvId) { showToast('No conversation selected', 'warning'); return; }
|
|
|
|
|
|
|
| 1944 |
if (!wsOpen()) {
|
| 1945 |
showToast('Not connected - message queued, sending when reconnected', 'warning');
|
| 1946 |
}
|
|
@@ -2299,27 +2427,26 @@
|
|
| 2299 |
if (wsOpen()) wsSend({ type: 'get_online_users' });
|
| 2300 |
}
|
| 2301 |
|
| 2302 |
-
function
|
| 2303 |
-
|
| 2304 |
-
return hasSelf ? Math.max(0, State.onlineUsers.size - 1) : State.onlineUsers.size;
|
| 2305 |
}
|
| 2306 |
|
| 2307 |
function updateOnlineBadge() {
|
| 2308 |
-
const count =
|
| 2309 |
DOM.onlineBadge.textContent = count;
|
| 2310 |
DOM.onlineBadge.classList.toggle('hidden', count === 0);
|
| 2311 |
}
|
| 2312 |
|
| 2313 |
function renderOnlineUsers() {
|
| 2314 |
DOM.onlineUsersList.innerHTML = '';
|
| 2315 |
-
const count =
|
| 2316 |
if (count === 0) {
|
| 2317 |
DOM.onlineUsersList.innerHTML = '<div class="loading-state"><span>No users online</span></div>';
|
| 2318 |
DOM.onlineTotal.textContent = '0 users online';
|
| 2319 |
return;
|
| 2320 |
}
|
| 2321 |
State.onlineUsers.forEach(u => {
|
| 2322 |
-
|
| 2323 |
const div = document.createElement('div');
|
| 2324 |
div.className = 'user-item';
|
| 2325 |
const av = u.avatar_path
|
|
@@ -2328,7 +2455,7 @@
|
|
| 2328 |
div.innerHTML = `
|
| 2329 |
<div class="user-avatar">${av}</div>
|
| 2330 |
<div class="user-info">
|
| 2331 |
-
<div class="user-name">${escapeHtml(displayNameOf(u))}</div>
|
| 2332 |
<div class="user-status-text"><span class="status-dot online"></span> Online</div>
|
| 2333 |
</div>`;
|
| 2334 |
DOM.onlineUsersList.appendChild(div);
|
|
@@ -2336,6 +2463,318 @@
|
|
| 2336 |
DOM.onlineTotal.textContent = `${count} user${count === 1 ? '' : 's'} online`;
|
| 2337 |
}
|
| 2338 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2339 |
// ============================================================
|
| 2340 |
// Settings
|
| 2341 |
// ============================================================
|
|
@@ -2367,6 +2806,7 @@
|
|
| 2367 |
}
|
| 2368 |
DOM.aboutVersion.textContent = APP_VERSION;
|
| 2369 |
applyPrefs();
|
|
|
|
| 2370 |
openModal(DOM.settingsModal);
|
| 2371 |
}
|
| 2372 |
|
|
@@ -2579,6 +3019,8 @@
|
|
| 2579 |
State.tombstoned.clear();
|
| 2580 |
State.pendingSends.clear();
|
| 2581 |
State.onlineUsers.clear();
|
|
|
|
|
|
|
| 2582 |
State.typingUsers.clear();
|
| 2583 |
State.activeConvId = null;
|
| 2584 |
State.cursor = null;
|
|
@@ -2586,6 +3028,7 @@
|
|
| 2586 |
State.pendingFile = null;
|
| 2587 |
State.latestUnseen = null;
|
| 2588 |
State.editingId = null;
|
|
|
|
| 2589 |
DOM.messagesList.innerHTML = '';
|
| 2590 |
DOM.convList.innerHTML = '';
|
| 2591 |
DOM.sidebar.classList.remove('open');
|
|
@@ -2601,6 +3044,8 @@
|
|
| 2601 |
DOM.sendBtn.disabled = true;
|
| 2602 |
DOM.uploadProgress.classList.add('hidden');
|
| 2603 |
DOM.onlineModal.classList.add('hidden');
|
|
|
|
|
|
|
| 2604 |
DOM.settingsModal.classList.add('hidden');
|
| 2605 |
DOM.convModal.classList.add('hidden');
|
| 2606 |
DOM.receiptsModal.classList.add('hidden');
|
|
|
|
| 53 |
typingUsers: new Map(), // convId -> {userId -> {name, timer}}
|
| 54 |
typingSentAt: 0,
|
| 55 |
onlineUsers: new Map(),
|
| 56 |
+
blockedUserIds: new Set(), // users this account has blocked
|
| 57 |
+
hiddenUserIds: new Set(), // users hiding chats in either block direction
|
| 58 |
isAtBottom: true,
|
| 59 |
latestUnseen: null,
|
| 60 |
unreadTotal: 0,
|
|
|
|
| 117 |
confirmModal: $('confirm-modal'), lightboxModal: $('lightbox-modal'),
|
| 118 |
emojiPicker: $('emoji-picker'), emojiGrid: $('emoji-grid'),
|
| 119 |
|
| 120 |
+
groupBtn: $('group-btn'), groupModal: $('group-modal'),
|
| 121 |
+
groupNameInput: $('group-name-input'), groupUserSearch: $('group-user-search'),
|
| 122 |
+
groupUserResults: $('group-user-results'), groupSelectedList: $('group-selected-list'),
|
| 123 |
+
createGroupBtn: $('create-group-btn'),
|
| 124 |
+
convOptionsBtn: $('conv-options-btn'), convOptionsModal: $('conv-options-modal'),
|
| 125 |
+
convOptionsTitle: $('conv-options-title'),
|
| 126 |
+
renameConvBtn: $('rename-conv-btn'), addGroupMembersBtn: $('add-group-members-btn'),
|
| 127 |
+
blockConvUserBtn: $('block-conv-user-btn'), leaveConvBtn: $('leave-conv-btn'),
|
| 128 |
+
refreshBlocksBtn: $('refresh-blocks-btn'), blockedUsersList: $('blocked-users-list'),
|
| 129 |
+
|
| 130 |
settingsDisplayName: $('settings-display-name'), settingsUsername: $('settings-username'),
|
| 131 |
settingsAvatarPreview: $('settings-avatar-preview'), settingsAvatarImg: $('settings-avatar-img'),
|
| 132 |
avatarInput: $('avatar-input'), avatarUploadBtn: $('avatar-upload-btn'),
|
|
|
|
| 165 |
return str.length > n ? str.substr(0, n - 1) + '…' : str;
|
| 166 |
}
|
| 167 |
|
| 168 |
+
function debounce(fn, ms) {
|
| 169 |
+
let t = null;
|
| 170 |
+
return function (...args) {
|
| 171 |
+
clearTimeout(t);
|
| 172 |
+
t = setTimeout(() => fn.apply(this, args), ms);
|
| 173 |
+
};
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
function escapeHtml(text) {
|
| 177 |
if (text === null || text === undefined) return '';
|
| 178 |
const div = document.createElement('div');
|
|
|
|
| 591 |
// ============================================================
|
| 592 |
function convById(id) { return State.conversations.get(id); }
|
| 593 |
|
| 594 |
+
function conversationTouchesBlocked(summary) {
|
| 595 |
+
if (!summary || summary.id === GLOBAL_CONV) return false;
|
| 596 |
+
if (summary.type === 'dm' && summary.peer && State.hiddenUserIds.has(summary.peer.id)) return true;
|
| 597 |
+
if (Array.isArray(summary.members) && summary.members.some(m => State.hiddenUserIds.has(m.id))) return true;
|
| 598 |
+
return false;
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
function upsertConversation(summary) {
|
| 602 |
+
if (conversationTouchesBlocked(summary)) {
|
| 603 |
+
State.conversations.delete(summary.id);
|
| 604 |
+
return;
|
| 605 |
+
}
|
| 606 |
State.conversations.set(summary.id, summary);
|
| 607 |
if (!State.convCache.has(summary.id)) {
|
| 608 |
State.convCache.set(summary.id, { ids: [], cursor: null, hasMore: true, lastTs: 0 });
|
|
|
|
| 610 |
renderSidebar();
|
| 611 |
}
|
| 612 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
function convSortKey(c) {
|
| 614 |
if (c.id === GLOBAL_CONV) return [0, 0];
|
| 615 |
return [1, -(c.last_message_ts || 0)];
|
|
|
|
| 637 |
}
|
| 638 |
DOM.convList.innerHTML = html;
|
| 639 |
DOM.convList.querySelectorAll('.conv-item').forEach((el) => {
|
| 640 |
+
el.addEventListener('click', (e) => {
|
| 641 |
+
if (e.target.closest('[data-invite]')) return;
|
| 642 |
switchConversation(parseInt(el.dataset.id, 10));
|
| 643 |
closeSidebar();
|
| 644 |
});
|
| 645 |
});
|
| 646 |
+
DOM.convList.querySelectorAll('[data-invite]').forEach(btn => {
|
| 647 |
+
btn.addEventListener('click', async () => {
|
| 648 |
+
const item = btn.closest('.conv-item');
|
| 649 |
+
const cid = parseInt(item.dataset.id, 10);
|
| 650 |
+
const action = btn.dataset.invite;
|
| 651 |
+
await respondToInvite(cid, action);
|
| 652 |
+
});
|
| 653 |
+
});
|
| 654 |
State.unreadTotal = unreadTotal;
|
| 655 |
updateTitleUnread();
|
| 656 |
}
|
|
|
|
| 662 |
|
| 663 |
function convItemHTML(c) {
|
| 664 |
const isDm = c.type === 'dm';
|
| 665 |
+
const isGroup = !!c.is_group;
|
| 666 |
+
const name = isGroup ? (c.custom_name || c.title || 'Group Chat') : (isDm ? dmLabel(c) : (c.title || 'Global Chat'));
|
| 667 |
const active = c.id === State.activeConvId ? ' active' : '';
|
| 668 |
const time = c.last_message_ts ? formatTime(c.last_message_ts) : '';
|
| 669 |
const unread = Math.min(c.unread_count || 0, 99);
|
| 670 |
const unreadHtml = unread > 0
|
| 671 |
? `<span class="conv-badge">${unread}</span>`
|
| 672 |
: '';
|
| 673 |
+
const pending = c.is_pending ? '<span class="conv-pending">Invite</span>' : '';
|
| 674 |
const timeCls = unread > 0 ? ' unread' : '';
|
| 675 |
+
const pendingCls = c.is_pending ? ' pending' : '';
|
| 676 |
let avatar = avatarHTML(c, 44);
|
| 677 |
+
return `<div class="conv-item${active}${pendingCls}" data-id="${c.id}" title="${escapeHtml(name)}" role="button" tabindex="0">
|
| 678 |
${avatar}
|
| 679 |
<div class="conv-info">
|
| 680 |
<div class="conv-top">
|
| 681 |
<span class="conv-name">${escapeHtml(name)}</span>
|
| 682 |
+
${pending}
|
| 683 |
+
</div>
|
| 684 |
+
<div class="conv-top">
|
| 685 |
<span class="conv-time${timeCls}">${escapeHtml(time)}</span>
|
| 686 |
+
${unreadHtml}
|
| 687 |
</div>
|
| 688 |
<div class="conv-bottom">
|
| 689 |
<span class="conv-preview">${previewHTML(c)}</span>
|
|
|
|
| 690 |
</div>
|
| 691 |
+
${pending ? '<div class="conv-pending-actions"><button class="btn btn-primary btn-sm" data-invite="accept">Accept</button><button class="btn btn-secondary btn-sm" data-invite="reject">Reject</button></div>' : ''}
|
| 692 |
</div>
|
| 693 |
+
</div>`;
|
| 694 |
}
|
| 695 |
|
| 696 |
function dmLabel(c) {
|
| 697 |
+
if (c.custom_name) return c.custom_name;
|
| 698 |
const base = displayNameOf(c.peer);
|
| 699 |
return c.dm_number && c.dm_number > 1 ? `${base} · ${c.dm_number}` : base;
|
| 700 |
}
|
|
|
|
| 709 |
}
|
| 710 |
|
| 711 |
function avatarHTML(c, px) {
|
| 712 |
+
if (c.is_group) {
|
| 713 |
+
return `<div class="conv-avatar avatar-c2" style="width:${px}px;height:${px}px;font-size:${Math.round(px * 0.4)}px"><i class="fas fa-users"></i></div>`;
|
| 714 |
+
}
|
| 715 |
if (c.type === 'dm' && c.peer) {
|
| 716 |
const on = c.peer.online ? ' online' : '';
|
| 717 |
let inner = `<span class="conv-presence${on}"></span>`;
|
|
|
|
| 796 |
if (!conv) return;
|
| 797 |
const dm = conv.type === 'dm';
|
| 798 |
const peer = conv.peer;
|
| 799 |
+
const isGroup = !!conv.is_group;
|
| 800 |
|
| 801 |
+
if (dm && peer && !isGroup) {
|
| 802 |
DOM.convTitle.textContent = dmLabel(conv);
|
| 803 |
if (peer.avatar_path) {
|
| 804 |
DOM.convAvatar.src = fileUrl(peer.avatar_path);
|
|
|
|
| 817 |
? '<span class="status-dot online"></span> Online'
|
| 818 |
: escapeHtml(formatLastSeen(peer.last_seen));
|
| 819 |
DOM.onlineBtn.style.display = '';
|
| 820 |
+
DOM.convAvatarPh.style.display = '';
|
| 821 |
+
DOM.convAvatar.style.display = '';
|
| 822 |
+
} else if (isGroup) {
|
| 823 |
+
DOM.convTitle.textContent = conv.custom_name || conv.title || 'Group Chat';
|
| 824 |
+
const gm = conv.members && conv.members.length ? conv.members.filter(m => m.status === 'accepted') : [];
|
| 825 |
+
const onlineCount = gm.filter(m => m.online).length;
|
| 826 |
+
DOM.convAvatar.classList.add('hidden');
|
| 827 |
+
DOM.convAvatarPh.classList.remove('hidden');
|
| 828 |
+
DOM.convAvatarPh.className = 'conv-avatar-ph avatar-c2';
|
| 829 |
+
DOM.convAvatarPh.innerHTML = '<i class="fas fa-users"></i>';
|
| 830 |
+
DOM.convPresenceDot.classList.add('hidden');
|
| 831 |
+
const pending = conv.is_pending ? ' — invite pending' : '';
|
| 832 |
+
const label = `${gm.length} member${gm.length === 1 ? '' : 's'} · ${onlineCount} online${pending}`;
|
| 833 |
+
DOM.convSubtitle.innerHTML = `<span class="status-dot ${conv.is_pending ? 'warn' : 'online'}"></span> ${escapeHtml(label)}`;
|
| 834 |
+
DOM.onlineBtn.style.display = '';
|
| 835 |
} else {
|
| 836 |
DOM.convTitle.textContent = 'Global Chat';
|
| 837 |
DOM.convAvatar.classList.add('hidden');
|
|
|
|
| 839 |
DOM.convAvatarPh.className = 'conv-avatar-ph';
|
| 840 |
DOM.convAvatarPh.innerHTML = '<i class="fas fa-globe"></i>';
|
| 841 |
DOM.convPresenceDot.classList.add('hidden');
|
| 842 |
+
const meCount = onlineCount();
|
| 843 |
DOM.convSubtitle.innerHTML = `<span class="status-dot online"></span> ${meCount} online`;
|
| 844 |
DOM.onlineBtn.style.display = '';
|
| 845 |
}
|
| 846 |
+
DOM.convOptionsBtn.style.display = convId === GLOBAL_CONV ? 'none' : '';
|
| 847 |
}
|
| 848 |
|
| 849 |
function openSidebar() {
|
|
|
|
| 983 |
updateSidebarMe();
|
| 984 |
updateOnlineBadge();
|
| 985 |
updateAllPeerPresence();
|
| 986 |
+
loadBlockedUsers();
|
| 987 |
if (!DOM.onlineModal.classList.contains('hidden')) renderOnlineUsers();
|
| 988 |
|
| 989 |
// fresh session: adopt server conversation list, drop old caches
|
|
|
|
| 1020 |
}
|
| 1021 |
break;
|
| 1022 |
|
| 1023 |
+
case 'conversation_deleted': {
|
| 1024 |
+
const cid = msg.conversation_id;
|
| 1025 |
+
State.conversations.delete(cid);
|
| 1026 |
+
State.convCache.delete(cid);
|
| 1027 |
+
State.messages.forEach(m => { if (m.conversation_id === cid) State.messages.delete(m.id); });
|
| 1028 |
+
if (State.activeConvId === cid) switchConversation(GLOBAL_CONV);
|
| 1029 |
+
renderSidebar();
|
| 1030 |
+
showToast('A group chat was deleted', 'info');
|
| 1031 |
+
break;
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
case 'conversation_left': {
|
| 1035 |
+
const cid = msg.conversation_id;
|
| 1036 |
+
if (msg.user_id === State.user?.id) {
|
| 1037 |
+
State.conversations.delete(cid);
|
| 1038 |
+
State.convCache.delete(cid);
|
| 1039 |
+
if (State.activeConvId === cid) switchConversation(GLOBAL_CONV);
|
| 1040 |
+
} else if (msg.conversation) {
|
| 1041 |
+
upsertConversation(msg.conversation);
|
| 1042 |
+
}
|
| 1043 |
+
renderSidebar();
|
| 1044 |
+
break;
|
| 1045 |
+
}
|
| 1046 |
+
|
| 1047 |
+
case 'block_changed': {
|
| 1048 |
+
// A block hides all non-global conversations with that person on
|
| 1049 |
+
// both sides (rows stay in the database). On unblock the hidden
|
| 1050 |
+
// chats reappear after a refresh.
|
| 1051 |
+
if (msg.blocked) {
|
| 1052 |
+
State.hiddenUserIds.add(msg.blocker_id);
|
| 1053 |
+
hideConversationsWithUser(msg.blocker_id);
|
| 1054 |
+
renderSidebar();
|
| 1055 |
+
showToast('Conversations with this person are now hidden', 'info');
|
| 1056 |
+
} else {
|
| 1057 |
+
// Removing the hidden mark is safe: the server still enforces
|
| 1058 |
+
// any block that remains in the other direction.
|
| 1059 |
+
State.hiddenUserIds.delete(msg.blocker_id);
|
| 1060 |
+
requestConversations();
|
| 1061 |
+
}
|
| 1062 |
+
break;
|
| 1063 |
+
}
|
| 1064 |
+
|
| 1065 |
case 'dm_created': {
|
| 1066 |
upsertConversation(msg.conversation);
|
| 1067 |
const peer = msg.conversation.peer;
|
|
|
|
| 1282 |
});
|
| 1283 |
}
|
| 1284 |
|
| 1285 |
+
function hideConversationsWithUser(userId) {
|
| 1286 |
+
if (!userId) return;
|
| 1287 |
+
const activeWasHidden = State.conversations.has(State.activeConvId) &&
|
| 1288 |
+
State.activeConvId !== GLOBAL_CONV &&
|
| 1289 |
+
(() => {
|
| 1290 |
+
const c = State.conversations.get(State.activeConvId);
|
| 1291 |
+
if (!c) return false;
|
| 1292 |
+
return (c.type === 'dm' && c.peer && c.peer.id === userId) ||
|
| 1293 |
+
(Array.isArray(c.members) && c.members.some(m => m.id === userId));
|
| 1294 |
+
})();
|
| 1295 |
+
State.conversations.forEach((c, cid) => {
|
| 1296 |
+
if (cid === GLOBAL_CONV) return;
|
| 1297 |
+
const isDm = c.type === 'dm' && c.peer && c.peer.id === userId;
|
| 1298 |
+
const isGroup = Array.isArray(c.members) && c.members.some(m => m.id === userId);
|
| 1299 |
+
if (isDm || isGroup) State.conversations.delete(cid);
|
| 1300 |
+
});
|
| 1301 |
+
if (activeWasHidden && State.activeConvId !== GLOBAL_CONV) switchConversation(GLOBAL_CONV);
|
| 1302 |
+
}
|
| 1303 |
+
|
| 1304 |
function updatePeerPresence(userId, online) {
|
| 1305 |
let changed = false;
|
| 1306 |
State.conversations.forEach(c => {
|
|
|
|
| 2067 |
const content = DOM.messageInput.value.trim();
|
| 2068 |
if (!content && !State.pendingFile) return;
|
| 2069 |
if (!State.activeConvId) { showToast('No conversation selected', 'warning'); return; }
|
| 2070 |
+
const active = convById(State.activeConvId);
|
| 2071 |
+
if (active && active.is_pending) { showToast('Accept the group invite first', 'warning'); return; }
|
| 2072 |
if (!wsOpen()) {
|
| 2073 |
showToast('Not connected - message queued, sending when reconnected', 'warning');
|
| 2074 |
}
|
|
|
|
| 2427 |
if (wsOpen()) wsSend({ type: 'get_online_users' });
|
| 2428 |
}
|
| 2429 |
|
| 2430 |
+
function onlineCount() {
|
| 2431 |
+
return State.onlineUsers.size;
|
|
|
|
| 2432 |
}
|
| 2433 |
|
| 2434 |
function updateOnlineBadge() {
|
| 2435 |
+
const count = onlineCount();
|
| 2436 |
DOM.onlineBadge.textContent = count;
|
| 2437 |
DOM.onlineBadge.classList.toggle('hidden', count === 0);
|
| 2438 |
}
|
| 2439 |
|
| 2440 |
function renderOnlineUsers() {
|
| 2441 |
DOM.onlineUsersList.innerHTML = '';
|
| 2442 |
+
const count = onlineCount();
|
| 2443 |
if (count === 0) {
|
| 2444 |
DOM.onlineUsersList.innerHTML = '<div class="loading-state"><span>No users online</span></div>';
|
| 2445 |
DOM.onlineTotal.textContent = '0 users online';
|
| 2446 |
return;
|
| 2447 |
}
|
| 2448 |
State.onlineUsers.forEach(u => {
|
| 2449 |
+
const me = u.id === State.user?.id;
|
| 2450 |
const div = document.createElement('div');
|
| 2451 |
div.className = 'user-item';
|
| 2452 |
const av = u.avatar_path
|
|
|
|
| 2455 |
div.innerHTML = `
|
| 2456 |
<div class="user-avatar">${av}</div>
|
| 2457 |
<div class="user-info">
|
| 2458 |
+
<div class="user-name">${escapeHtml(me ? 'You' : displayNameOf(u))}</div>
|
| 2459 |
<div class="user-status-text"><span class="status-dot online"></span> Online</div>
|
| 2460 |
</div>`;
|
| 2461 |
DOM.onlineUsersList.appendChild(div);
|
|
|
|
| 2463 |
DOM.onlineTotal.textContent = `${count} user${count === 1 ? '' : 's'} online`;
|
| 2464 |
}
|
| 2465 |
|
| 2466 |
+
// ============================================================
|
| 2467 |
+
// Group chats, invites, conversation options and blocking
|
| 2468 |
+
// ============================================================
|
| 2469 |
+
const groupSelected = new Set();
|
| 2470 |
+
let groupUsersCache = [];
|
| 2471 |
+
|
| 2472 |
+
async function apiGet(url) {
|
| 2473 |
+
const res = await fetch(`${API}${url}`, { headers: { 'X-Auth-Token': State.token } });
|
| 2474 |
+
const data = await res.json().catch(() => ({}));
|
| 2475 |
+
if (!res.ok) throw new Error(data.detail || `Request failed (${res.status})`);
|
| 2476 |
+
return data;
|
| 2477 |
+
}
|
| 2478 |
+
|
| 2479 |
+
async function apiPost(url, body) {
|
| 2480 |
+
const res = await fetch(`${API}${url}`, {
|
| 2481 |
+
method: 'POST',
|
| 2482 |
+
headers: { 'X-Auth-Token': State.token },
|
| 2483 |
+
body
|
| 2484 |
+
});
|
| 2485 |
+
const data = await res.json().catch(() => ({}));
|
| 2486 |
+
if (!res.ok) throw new Error(data.detail || `Request failed (${res.status})`);
|
| 2487 |
+
return data;
|
| 2488 |
+
}
|
| 2489 |
+
|
| 2490 |
+
async function apiPatch(url) {
|
| 2491 |
+
const res = await fetch(`${API}${url}`, {
|
| 2492 |
+
method: 'PATCH', headers: { 'X-Auth-Token': State.token }
|
| 2493 |
+
});
|
| 2494 |
+
const data = await res.json().catch(() => ({}));
|
| 2495 |
+
if (!res.ok) throw new Error(data.detail || `Request failed (${res.status})`);
|
| 2496 |
+
return data;
|
| 2497 |
+
}
|
| 2498 |
+
|
| 2499 |
+
async function apiDelete(url) {
|
| 2500 |
+
const res = await fetch(`${API}${url}`, {
|
| 2501 |
+
method: 'DELETE', headers: { 'X-Auth-Token': State.token }
|
| 2502 |
+
});
|
| 2503 |
+
const data = await res.json().catch(() => ({}));
|
| 2504 |
+
if (!res.ok) throw new Error(data.detail || `Request failed (${res.status})`);
|
| 2505 |
+
return data;
|
| 2506 |
+
}
|
| 2507 |
+
|
| 2508 |
+
async function respondToInvite(cid, action) {
|
| 2509 |
+
try {
|
| 2510 |
+
const path = action === 'accept' ? `accept` : `reject`;
|
| 2511 |
+
const res = await fetch(`${API}/api/conversations/${cid}/${path}`, {
|
| 2512 |
+
method: 'POST', headers: { 'X-Auth-Token': State.token }
|
| 2513 |
+
});
|
| 2514 |
+
const data = await res.json().catch(() => ({}));
|
| 2515 |
+
if (!res.ok) throw new Error(data.detail || 'Could not update invite');
|
| 2516 |
+
if (action === 'accept') {
|
| 2517 |
+
upsertConversation(data.conversation);
|
| 2518 |
+
switchConversation(cid);
|
| 2519 |
+
showToast('Group invite accepted', 'success');
|
| 2520 |
+
} else {
|
| 2521 |
+
State.conversations.delete(cid);
|
| 2522 |
+
State.convCache.delete(cid);
|
| 2523 |
+
renderSidebar();
|
| 2524 |
+
showToast('Group invite rejected', 'info');
|
| 2525 |
+
}
|
| 2526 |
+
} catch (err) {
|
| 2527 |
+
showToast(err.message, 'error');
|
| 2528 |
+
}
|
| 2529 |
+
}
|
| 2530 |
+
|
| 2531 |
+
function renderGroupUserResults(list) {
|
| 2532 |
+
if (!list || !list.length) {
|
| 2533 |
+
DOM.groupUserResults.innerHTML = '<div class="conv-modal-empty">No results</div>';
|
| 2534 |
+
return;
|
| 2535 |
+
}
|
| 2536 |
+
DOM.groupUserResults.innerHTML = '';
|
| 2537 |
+
list.forEach(u => {
|
| 2538 |
+
if (u.id === State.user?.id) return;
|
| 2539 |
+
const selected = groupSelected.has(u.id);
|
| 2540 |
+
const div = document.createElement('div');
|
| 2541 |
+
div.className = 'conv-modal-item group-user-item' + (selected ? ' selected' : '');
|
| 2542 |
+
div.dataset.uid = u.id;
|
| 2543 |
+
const av = u.avatar_path
|
| 2544 |
+
? `<img src="${fileUrl(u.avatar_path)}" alt="" style="width:36px;height:36px;border-radius:50%;object-fit:cover">`
|
| 2545 |
+
: `<span style="font-weight:600;font-size:1.1rem">${initialOf(displayNameOf(u))}</span>`;
|
| 2546 |
+
div.innerHTML = `
|
| 2547 |
+
<div class="user-avatar">${av}</div>
|
| 2548 |
+
<div class="mi-info">
|
| 2549 |
+
<div class="mi-name">${escapeHtml(displayNameOf(u))}</div>
|
| 2550 |
+
<div class="mi-preview">${escapeHtml(u.username)}${u.online ? ' · online' : ''}${u.blocked ? ' · blocked' : ''}</div>
|
| 2551 |
+
</div>
|
| 2552 |
+
<i class="fas ${selected ? 'fa-check-circle' : 'fa-plus-circle'} mi-open"></i>`;
|
| 2553 |
+
div.addEventListener('click', () => {
|
| 2554 |
+
if (u.blocked) { showToast('You have blocked this user', 'warning'); return; }
|
| 2555 |
+
if (groupSelected.has(u.id)) groupSelected.delete(u.id);
|
| 2556 |
+
else groupSelected.add(u.id);
|
| 2557 |
+
renderGroupUserResults(list);
|
| 2558 |
+
renderGroupSelected();
|
| 2559 |
+
});
|
| 2560 |
+
DOM.groupUserResults.appendChild(div);
|
| 2561 |
+
});
|
| 2562 |
+
}
|
| 2563 |
+
|
| 2564 |
+
function renderGroupSelected() {
|
| 2565 |
+
const people = groupUsersCache.filter(u => groupSelected.has(u.id));
|
| 2566 |
+
if (!people.length) {
|
| 2567 |
+
DOM.groupSelectedList.innerHTML = '';
|
| 2568 |
+
return;
|
| 2569 |
+
}
|
| 2570 |
+
DOM.groupSelectedList.innerHTML = people.map(u =>
|
| 2571 |
+
`<span class="group-chip">${escapeHtml(displayNameOf(u))}<button class="btn-icon btn-icon-sm" data-remove-group="${u.id}" title="Remove"><i class="fas fa-times"></i></button></span>`
|
| 2572 |
+
).join('');
|
| 2573 |
+
DOM.groupSelectedList.querySelectorAll('[data-remove-group]').forEach(btn => {
|
| 2574 |
+
btn.addEventListener('click', () => {
|
| 2575 |
+
groupSelected.delete(parseInt(btn.dataset.removeGroup, 10));
|
| 2576 |
+
renderGroupSelected();
|
| 2577 |
+
renderGroupUserResults(groupUsersCache);
|
| 2578 |
+
});
|
| 2579 |
+
});
|
| 2580 |
+
}
|
| 2581 |
+
|
| 2582 |
+
async function loadGroupUsers(query = '') {
|
| 2583 |
+
try {
|
| 2584 |
+
const data = await apiGet(`/api/users?query=${encodeURIComponent(query)}`);
|
| 2585 |
+
groupUsersCache = data.users || [];
|
| 2586 |
+
renderGroupUserResults(groupUsersCache);
|
| 2587 |
+
renderGroupSelected();
|
| 2588 |
+
} catch (err) {
|
| 2589 |
+
showToast(err.message, 'error');
|
| 2590 |
+
}
|
| 2591 |
+
}
|
| 2592 |
+
|
| 2593 |
+
function openGroupModal() {
|
| 2594 |
+
groupSelected.clear();
|
| 2595 |
+
DOM.groupNameInput.value = '';
|
| 2596 |
+
DOM.groupNameInput.hidden = false;
|
| 2597 |
+
DOM.groupNameInput.closest('.form-group').hidden = false;
|
| 2598 |
+
DOM.groupUserSearch.value = '';
|
| 2599 |
+
DOM.groupUserSearch.placeholder = 'Search by name…';
|
| 2600 |
+
DOM.createGroupBtn.dataset.mode = 'create';
|
| 2601 |
+
DOM.createGroupBtn.innerHTML = '<i class="fas fa-plus"></i> Create Group';
|
| 2602 |
+
loadGroupUsers();
|
| 2603 |
+
openModal(DOM.groupModal);
|
| 2604 |
+
}
|
| 2605 |
+
|
| 2606 |
+
DOM.groupBtn.addEventListener('click', openGroupModal);
|
| 2607 |
+
DOM.groupUserSearch.addEventListener('input', debounce(() => {
|
| 2608 |
+
loadGroupUsers(DOM.groupUserSearch.value.trim());
|
| 2609 |
+
}, 250));
|
| 2610 |
+
|
| 2611 |
+
DOM.createGroupBtn.addEventListener('click', async () => {
|
| 2612 |
+
const mode = DOM.createGroupBtn.dataset.mode || 'create';
|
| 2613 |
+
if (mode === 'add-members') {
|
| 2614 |
+
const cid = parseInt(DOM.groupModal.dataset.groupId, 10);
|
| 2615 |
+
const ids = [...groupSelected].join(',');
|
| 2616 |
+
if (!ids) { showToast('Select people to add', 'warning'); return; }
|
| 2617 |
+
DOM.createGroupBtn.disabled = true;
|
| 2618 |
+
try {
|
| 2619 |
+
await apiPost(`/api/conversations/${cid}/members?member_ids=${encodeURIComponent(ids)}`);
|
| 2620 |
+
closeModal(DOM.groupModal);
|
| 2621 |
+
const data = await apiGet('/api/conversations');
|
| 2622 |
+
data.conversations.forEach(c => upsertConversation(c));
|
| 2623 |
+
if (State.activeConvId === cid) renderConversationHeader(cid);
|
| 2624 |
+
showToast('Invites sent', 'success');
|
| 2625 |
+
} catch (err) {
|
| 2626 |
+
showToast(err.message, 'error');
|
| 2627 |
+
} finally {
|
| 2628 |
+
DOM.createGroupBtn.disabled = false;
|
| 2629 |
+
}
|
| 2630 |
+
return;
|
| 2631 |
+
}
|
| 2632 |
+
|
| 2633 |
+
const name = DOM.groupNameInput.value.trim();
|
| 2634 |
+
if (!name) { showToast('Enter a group name', 'warning'); return; }
|
| 2635 |
+
if (!groupSelected.size) { showToast('Select at least one person', 'warning'); return; }
|
| 2636 |
+
DOM.createGroupBtn.disabled = true;
|
| 2637 |
+
DOM.createGroupBtn.innerHTML = '<div class="spinner spinner-sm"></div> Creating…';
|
| 2638 |
+
try {
|
| 2639 |
+
const ids = [...groupSelected].join(',');
|
| 2640 |
+
const data = await apiPost(`/api/conversations/group?name=${encodeURIComponent(name)}&member_ids=${encodeURIComponent(ids)}`);
|
| 2641 |
+
upsertConversation(data.conversation);
|
| 2642 |
+
closeModal(DOM.groupModal);
|
| 2643 |
+
switchConversation(data.conversation.id);
|
| 2644 |
+
showToast('Group created and people invited', 'success');
|
| 2645 |
+
} catch (err) {
|
| 2646 |
+
showToast(err.message, 'error');
|
| 2647 |
+
} finally {
|
| 2648 |
+
DOM.createGroupBtn.disabled = false;
|
| 2649 |
+
DOM.createGroupBtn.innerHTML = '<i class="fas fa-plus"></i> Create Group';
|
| 2650 |
+
}
|
| 2651 |
+
});
|
| 2652 |
+
|
| 2653 |
+
function openConvOptions() {
|
| 2654 |
+
const conv = convById(State.activeConvId);
|
| 2655 |
+
if (!conv || State.activeConvId === GLOBAL_CONV) return;
|
| 2656 |
+
const pending = !!conv.is_pending;
|
| 2657 |
+
DOM.renameConvBtn.style.display = pending ? 'none' : '';
|
| 2658 |
+
DOM.addGroupMembersBtn.style.display = conv.is_group && !pending ? '' : 'none';
|
| 2659 |
+
DOM.blockConvUserBtn.style.display = (!conv.is_group && conv.peer) ? '' : 'none';
|
| 2660 |
+
DOM.leaveConvBtn.style.display = (conv.is_group && !pending) ? '' : 'none';
|
| 2661 |
+
DOM.convOptionsTitle.innerHTML = `<i class="fas fa-ellipsis-h"></i> ${escapeHtml(conv.custom_name || conv.title || 'Chat Options')}`;
|
| 2662 |
+
openModal(DOM.convOptionsModal);
|
| 2663 |
+
}
|
| 2664 |
+
|
| 2665 |
+
DOM.convOptionsBtn.addEventListener('click', openConvOptions);
|
| 2666 |
+
|
| 2667 |
+
DOM.renameConvBtn.addEventListener('click', async () => {
|
| 2668 |
+
const conv = convById(State.activeConvId);
|
| 2669 |
+
if (!conv) return;
|
| 2670 |
+
const name = prompt('Rename this chat (everyone in the chat sees it):', conv.custom_name || conv.title || '');
|
| 2671 |
+
if (!name || !name.trim()) return;
|
| 2672 |
+
closeModal(DOM.convOptionsModal);
|
| 2673 |
+
try {
|
| 2674 |
+
const data = await apiPatch(`/api/conversations/${conv.id}?name=${encodeURIComponent(name.trim())}`);
|
| 2675 |
+
upsertConversation(data.conversation);
|
| 2676 |
+
renderConversationHeader(State.activeConvId);
|
| 2677 |
+
showToast('Chat renamed', 'success');
|
| 2678 |
+
} catch (err) { showToast(err.message, 'error'); }
|
| 2679 |
+
});
|
| 2680 |
+
|
| 2681 |
+
DOM.addGroupMembersBtn.addEventListener('click', async () => {
|
| 2682 |
+
const conv = convById(State.activeConvId);
|
| 2683 |
+
closeModal(DOM.convOptionsModal);
|
| 2684 |
+
if (!conv || !conv.is_group) return;
|
| 2685 |
+
groupSelected.clear();
|
| 2686 |
+
DOM.groupNameInput.value = '';
|
| 2687 |
+
DOM.groupNameInput.hidden = true;
|
| 2688 |
+
DOM.groupNameInput.closest('.form-group').hidden = true;
|
| 2689 |
+
DOM.groupUserSearch.value = '';
|
| 2690 |
+
DOM.groupUserSearch.placeholder = 'Add more people…';
|
| 2691 |
+
DOM.createGroupBtn.innerHTML = '<i class="fas fa-user-plus"></i> Add Members';
|
| 2692 |
+
DOM.createGroupBtn.dataset.mode = 'add-members';
|
| 2693 |
+
DOM.groupModal.dataset.groupId = conv.id;
|
| 2694 |
+
loadGroupUsers();
|
| 2695 |
+
openModal(DOM.groupModal);
|
| 2696 |
+
});
|
| 2697 |
+
|
| 2698 |
+
DOM.blockConvUserBtn.addEventListener('click', async () => {
|
| 2699 |
+
const conv = convById(State.activeConvId);
|
| 2700 |
+
closeModal(DOM.convOptionsModal);
|
| 2701 |
+
if (!conv || !conv.peer) return;
|
| 2702 |
+
if (!await showConfirm({
|
| 2703 |
+
title: 'Block person',
|
| 2704 |
+
message: `Block ${displayNameOf(conv.peer)}? They cannot message or add you to private/group chats, and all chats with them will be hidden until you unblock them. You can still talk in Global Chat.`,
|
| 2705 |
+
okText: 'Block'
|
| 2706 |
+
})) return;
|
| 2707 |
+
try {
|
| 2708 |
+
await apiPost(`/api/users/${conv.peer.id}/block`);
|
| 2709 |
+
State.blockedUserIds.add(conv.peer.id);
|
| 2710 |
+
State.hiddenUserIds.add(conv.peer.id);
|
| 2711 |
+
hideConversationsWithUser(conv.peer.id);
|
| 2712 |
+
renderSidebar();
|
| 2713 |
+
showToast('User blocked', 'success');
|
| 2714 |
+
requestConversations();
|
| 2715 |
+
} catch (err) { showToast(err.message, 'error'); }
|
| 2716 |
+
});
|
| 2717 |
+
|
| 2718 |
+
DOM.leaveConvBtn.addEventListener('click', async () => {
|
| 2719 |
+
const conv = convById(State.activeConvId);
|
| 2720 |
+
closeModal(DOM.convOptionsModal);
|
| 2721 |
+
if (!conv || !conv.is_group) return;
|
| 2722 |
+
const isOwner = conv.is_owner;
|
| 2723 |
+
const ok = await showConfirm({
|
| 2724 |
+
title: isOwner ? 'Delete group' : 'Leave group',
|
| 2725 |
+
message: isOwner
|
| 2726 |
+
? 'This permanently deletes the group and its messages for everyone. This cannot be undone.'
|
| 2727 |
+
: 'Leave this group? You can be added again later if a member invites you.',
|
| 2728 |
+
okText: isOwner ? 'Delete Group' : 'Leave Group'
|
| 2729 |
+
});
|
| 2730 |
+
if (!ok) return;
|
| 2731 |
+
try {
|
| 2732 |
+
await apiDelete(`/api/conversations/${conv.id}`);
|
| 2733 |
+
State.conversations.delete(conv.id);
|
| 2734 |
+
State.convCache.delete(conv.id);
|
| 2735 |
+
if (State.activeConvId === conv.id) switchConversation(GLOBAL_CONV);
|
| 2736 |
+
renderSidebar();
|
| 2737 |
+
showToast(isOwner ? 'Group deleted' : 'Left group', 'success');
|
| 2738 |
+
} catch (err) { showToast(err.message, 'error'); }
|
| 2739 |
+
});
|
| 2740 |
+
|
| 2741 |
+
DOM.refreshBlocksBtn.addEventListener('click', loadBlockedUsers);
|
| 2742 |
+
|
| 2743 |
+
async function loadBlockedUsers() {
|
| 2744 |
+
try {
|
| 2745 |
+
const data = await apiGet('/api/users/blocked');
|
| 2746 |
+
const users = data.users || [];
|
| 2747 |
+
State.blockedUserIds.clear();
|
| 2748 |
+
users.forEach(u => State.blockedUserIds.add(u.id));
|
| 2749 |
+
State.hiddenUserIds = new Set(State.blockedUserIds);
|
| 2750 |
+
if (!users.length) {
|
| 2751 |
+
DOM.blockedUsersList.innerHTML = '<div class="text-secondary">Nobody is blocked.</div>';
|
| 2752 |
+
return;
|
| 2753 |
+
}
|
| 2754 |
+
DOM.blockedUsersList.innerHTML = users.map(u =>
|
| 2755 |
+
`<div class="blocked-user-row">
|
| 2756 |
+
<div class="user-avatar">${u.avatar_path ? `<img src="${fileUrl(u.avatar_path)}" alt="">` : `<span>${initialOf(displayNameOf(u))}</span>`}</div>
|
| 2757 |
+
<div class="user-info"><div class="user-name">${escapeHtml(displayNameOf(u))}</div><div class="user-status-text">${escapeHtml(u.username)}</div></div>
|
| 2758 |
+
<button class="btn btn-secondary btn-sm" data-unblock="${u.id}">Unblock</button>
|
| 2759 |
+
</div>`).join('');
|
| 2760 |
+
DOM.blockedUsersList.querySelectorAll('[data-unblock]').forEach(btn => {
|
| 2761 |
+
btn.addEventListener('click', async () => {
|
| 2762 |
+
try {
|
| 2763 |
+
await apiDelete(`/api/users/${btn.dataset.unblock}/block`);
|
| 2764 |
+
const unblockedId = parseInt(btn.dataset.unblock, 10);
|
| 2765 |
+
State.blockedUserIds.delete(unblockedId);
|
| 2766 |
+
State.hiddenUserIds.delete(unblockedId);
|
| 2767 |
+
showToast('User unblocked', 'success');
|
| 2768 |
+
loadBlockedUsers();
|
| 2769 |
+
requestConversations();
|
| 2770 |
+
} catch (err) { showToast(err.message, 'error'); }
|
| 2771 |
+
});
|
| 2772 |
+
});
|
| 2773 |
+
} catch (err) {
|
| 2774 |
+
DOM.blockedUsersList.innerHTML = `<div class="text-secondary">${escapeHtml(err.message)}</div>`;
|
| 2775 |
+
}
|
| 2776 |
+
}
|
| 2777 |
+
|
| 2778 |
// ============================================================
|
| 2779 |
// Settings
|
| 2780 |
// ============================================================
|
|
|
|
| 2806 |
}
|
| 2807 |
DOM.aboutVersion.textContent = APP_VERSION;
|
| 2808 |
applyPrefs();
|
| 2809 |
+
loadBlockedUsers();
|
| 2810 |
openModal(DOM.settingsModal);
|
| 2811 |
}
|
| 2812 |
|
|
|
|
| 3019 |
State.tombstoned.clear();
|
| 3020 |
State.pendingSends.clear();
|
| 3021 |
State.onlineUsers.clear();
|
| 3022 |
+
State.blockedUserIds.clear();
|
| 3023 |
+
State.hiddenUserIds.clear();
|
| 3024 |
State.typingUsers.clear();
|
| 3025 |
State.activeConvId = null;
|
| 3026 |
State.cursor = null;
|
|
|
|
| 3028 |
State.pendingFile = null;
|
| 3029 |
State.latestUnseen = null;
|
| 3030 |
State.editingId = null;
|
| 3031 |
+
groupSelected.clear();
|
| 3032 |
DOM.messagesList.innerHTML = '';
|
| 3033 |
DOM.convList.innerHTML = '';
|
| 3034 |
DOM.sidebar.classList.remove('open');
|
|
|
|
| 3044 |
DOM.sendBtn.disabled = true;
|
| 3045 |
DOM.uploadProgress.classList.add('hidden');
|
| 3046 |
DOM.onlineModal.classList.add('hidden');
|
| 3047 |
+
DOM.groupModal.classList.add('hidden');
|
| 3048 |
+
DOM.convOptionsModal.classList.add('hidden');
|
| 3049 |
DOM.settingsModal.classList.add('hidden');
|
| 3050 |
DOM.convModal.classList.add('hidden');
|
| 3051 |
DOM.receiptsModal.classList.add('hidden');
|