smodusermc commited on
Commit
cb10952
·
verified ·
1 Parent(s): 5902ffa

Update static/script.js

Browse files
Files changed (1) hide show
  1. 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 name = isDm ? dmLabel(c) : (c.title || 'Global Chat');
 
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 `<button class="conv-item${active}" data-id="${c.id}" title="${escapeHtml(name)}">
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
- </button>`;
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 = onlineCountExcludingSelf();
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 onlineCountExcludingSelf() {
2303
- const hasSelf = State.onlineUsers.has(State.user?.id);
2304
- return hasSelf ? Math.max(0, State.onlineUsers.size - 1) : State.onlineUsers.size;
2305
  }
2306
 
2307
  function updateOnlineBadge() {
2308
- const count = onlineCountExcludingSelf();
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 = onlineCountExcludingSelf();
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
- if (u.id === State.user?.id) return; // don't list yourself
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');