Changeset 0.23.2 (#155)
This commit is contained in:
204
src/js/chat.js
204
src/js/chat.js
@@ -83,6 +83,8 @@ const ChatInput = {
|
||||
updateInputTokens();
|
||||
// @mention autocomplete (v0.20.0)
|
||||
if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(ChatInput);
|
||||
// v0.23.2: Typing indicator for multi-user channels
|
||||
_emitTyping();
|
||||
},
|
||||
maxHeight: 200,
|
||||
});
|
||||
@@ -108,15 +110,30 @@ const ChatInput = {
|
||||
updateInputTokens();
|
||||
// @mention autocomplete (v0.20.0)
|
||||
if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(this);
|
||||
// v0.23.2: Typing indicator
|
||||
_emitTyping();
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// v0.23.2: Debounced typing indicator for multi-user channels
|
||||
let _typingTimer = null;
|
||||
function _emitTyping() {
|
||||
if (!App.activeId) return;
|
||||
// Only emit for multi-user conversation types
|
||||
const type = App.activeConversation?.type;
|
||||
if (type !== 'dm' && type !== 'channel' && type !== 'group') return;
|
||||
// Debounce: emit at most once every 3s
|
||||
if (_typingTimer) return;
|
||||
_typingTimer = setTimeout(() => { _typingTimer = null; }, 3000);
|
||||
API._post(`/api/v1/channels/${App.activeId}/typing`, {}).catch(() => {});
|
||||
}
|
||||
|
||||
// ── Summarize & Continue ──────────────────
|
||||
|
||||
async function summarizeAndContinue() {
|
||||
const channelId = App.currentChatId;
|
||||
const channelId = App.activeId;
|
||||
if (!channelId) return;
|
||||
|
||||
const btn = document.getElementById('summarizeBtn');
|
||||
@@ -145,6 +162,10 @@ async function summarizeAndContinue() {
|
||||
metadata: m.metadata || null,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
participant_type: m.participant_type || null,
|
||||
participant_id: m.participant_id || null,
|
||||
sender_name: m.sender_name || null,
|
||||
sender_avatar: m.sender_avatar || null,
|
||||
}));
|
||||
UI.renderMessages(chat.messages);
|
||||
updateContextWarning(); updateChatTokenCount();
|
||||
@@ -164,7 +185,7 @@ async function summarizeAndContinue() {
|
||||
// ── Per-Channel Auto-Compaction Toggle ──────
|
||||
|
||||
async function toggleChannelAutoCompact(enabled) {
|
||||
const chatId = App.currentChatId;
|
||||
const chatId = App.activeId;
|
||||
if (!chatId) return;
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
if (!chat) return;
|
||||
@@ -193,6 +214,7 @@ async function loadChats() {
|
||||
model: c.model || '',
|
||||
messageCount: c.message_count || 0,
|
||||
projectId: c.project_id || null,
|
||||
folderId: c.folder || null,
|
||||
workspace_id: c.workspace_id || null,
|
||||
messages: [],
|
||||
updatedAt: c.updated_at,
|
||||
@@ -206,16 +228,17 @@ async function loadChats() {
|
||||
|
||||
async function selectChat(chatId) {
|
||||
clearStaged(); // Discard any staged files from previous chat
|
||||
App.currentChatId = chatId;
|
||||
try { sessionStorage.setItem('cs-active-chat', chatId); } catch (_) {}
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
App.setActive(chatId, chat?.type || 'direct');
|
||||
try { sessionStorage.setItem('cs-active-conversation', JSON.stringify(App.activeConversation)); } catch (_) {}
|
||||
UI.renderChatList();
|
||||
UI.renderChannelsSection();
|
||||
if (window.innerWidth <= 768) {
|
||||
document.getElementById('sidebar').classList.add('collapsed');
|
||||
const ov = document.getElementById('sidebarOverlay');
|
||||
if (ov) ov.style.display = 'none';
|
||||
}
|
||||
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
if (!chat) return;
|
||||
|
||||
if (chat.messages.length === 0 && chat.messageCount > 0) {
|
||||
@@ -233,6 +256,10 @@ async function selectChat(chatId) {
|
||||
metadata: m.metadata || null,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
participant_type: m.participant_type || null,
|
||||
participant_id: m.participant_id || null,
|
||||
sender_name: m.sender_name || null,
|
||||
sender_avatar: m.sender_avatar || null,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to load messages:', e.message);
|
||||
@@ -250,6 +277,7 @@ async function selectChat(chatId) {
|
||||
await loadChannelFiles(chatId);
|
||||
|
||||
UI.renderMessages(chat.messages);
|
||||
UI.updateContextBanner();
|
||||
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
||||
Tokens._warningDismissed = false;
|
||||
updateContextWarning(); updateChatTokenCount();
|
||||
@@ -264,6 +292,9 @@ async function selectChat(chatId) {
|
||||
|
||||
// Update browser URL to reflect selected chat
|
||||
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chatId}`);
|
||||
|
||||
// v0.23.2: Mark as read (non-blocking)
|
||||
API.markRead(chatId).catch(() => {});
|
||||
}
|
||||
|
||||
// ── Chat Header Token Count ──────────────────
|
||||
@@ -271,7 +302,7 @@ async function selectChat(chatId) {
|
||||
function updateChatTokenCount() {
|
||||
const el = document.getElementById('chatTokenCount');
|
||||
if (!el) return;
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.getActiveChat();
|
||||
if (!chat || !chat.messages.length) { el.textContent = ''; return; }
|
||||
|
||||
const tokens = Tokens.estimateConversation(chat.messages, App.settings?.systemPrompt);
|
||||
@@ -379,8 +410,9 @@ function _cleanStaleChatModel(chatId) {
|
||||
|
||||
async function newChat() {
|
||||
clearStaged(); // Discard any staged files
|
||||
App.currentChatId = null;
|
||||
App.setActive(null);
|
||||
UI.renderChatList();
|
||||
UI.renderChannelsSection();
|
||||
UI.showEmptyState();
|
||||
UI.showRegenerate(false);
|
||||
Tokens._warningDismissed = false;
|
||||
@@ -492,7 +524,7 @@ async function newGroupChat() {
|
||||
}
|
||||
|
||||
App.chats.unshift(chat);
|
||||
App.currentChatId = chat.id;
|
||||
App.setActive(chat.id, 'group');
|
||||
UI.renderChatList();
|
||||
Events.emit('chat.created', { chatId: chat.id, projectId: null }, { localOnly: true });
|
||||
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
|
||||
@@ -526,7 +558,7 @@ async function deleteChat(chatId) {
|
||||
delete map[chatId];
|
||||
localStorage.setItem('cs-chat-models', JSON.stringify(map));
|
||||
} catch (e) { /* */ }
|
||||
if (App.currentChatId === chatId) { clearPreview(); newChat(); }
|
||||
if (App.activeId === chatId) { clearPreview(); newChat(); }
|
||||
UI.renderChatList();
|
||||
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
|
||||
}
|
||||
@@ -634,7 +666,7 @@ async function sendMessage() {
|
||||
const model = modelInfo?.baseModelId || selectedId;
|
||||
const personaId = modelInfo?.isPersona ? modelInfo.personaId : null;
|
||||
|
||||
if (!App.currentChatId) {
|
||||
if (!App.activeId) {
|
||||
try {
|
||||
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
|
||||
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
|
||||
@@ -647,7 +679,7 @@ async function sendMessage() {
|
||||
} catch (_) { /* best effort — chat still works without project */ }
|
||||
}
|
||||
App.chats.unshift(chat);
|
||||
App.currentChatId = chat.id;
|
||||
App.setActive(chat.id, 'direct');
|
||||
UI.renderChatList();
|
||||
// Notify surfaces about new chat creation (v0.21.6)
|
||||
Events.emit('chat.created', { chatId: chat.id, projectId: chat.projectId || null }, { localOnly: true });
|
||||
@@ -655,7 +687,7 @@ async function sendMessage() {
|
||||
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
|
||||
}
|
||||
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.getActiveChat();
|
||||
if (!chat) return;
|
||||
|
||||
// Optimistic: show user message immediately
|
||||
@@ -671,8 +703,20 @@ async function sendMessage() {
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, personaId, fileIds,
|
||||
const resp = await API.streamCompletion(App.activeId, text, model, App.abortController.signal, modelInfo?.configId, personaId, fileIds,
|
||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []);
|
||||
|
||||
// v0.23.2: Non-streaming response (DM with mention_only, no @mention)
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (ct.includes('application/json')) {
|
||||
const data = await resp.json();
|
||||
if (data.ai_skipped) {
|
||||
// Message was persisted server-side; reload to get proper IDs
|
||||
await reloadActivePath();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
||||
|
||||
// Reload active path from server to get proper IDs and sibling metadata
|
||||
@@ -680,11 +724,11 @@ async function sendMessage() {
|
||||
UI.showRegenerate(true);
|
||||
|
||||
// Persist the model/persona selection for this chat
|
||||
_saveChatModel(App.currentChatId);
|
||||
_saveChatModel(App.activeId);
|
||||
|
||||
// Auto-name: title the chat after first assistant response (v0.17.0)
|
||||
if (chat.messages.length <= 3) {
|
||||
autoNameChat(App.currentChatId, chat);
|
||||
autoNameChat(App.activeId, chat);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
@@ -717,12 +761,12 @@ function stopGeneration() { if (App.abortController) App.abortController.abort()
|
||||
// ── Reload Active Path from Server ──────────
|
||||
|
||||
async function reloadActivePath() {
|
||||
if (!App.currentChatId) return;
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!App.activeId) return;
|
||||
const chat = App.getActiveChat();
|
||||
if (!chat) return;
|
||||
|
||||
try {
|
||||
const resp = await API.getActivePath(App.currentChatId);
|
||||
const resp = await API.getActivePath(App.activeId);
|
||||
chat.messages = (resp.path || []).map(m => ({
|
||||
id: m.id,
|
||||
parent_id: m.parent_id || null,
|
||||
@@ -735,9 +779,13 @@ async function reloadActivePath() {
|
||||
metadata: m.metadata || null,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
participant_type: m.participant_type || null,
|
||||
participant_id: m.participant_id || null,
|
||||
sender_name: m.sender_name || null,
|
||||
sender_avatar: m.sender_avatar || null,
|
||||
}));
|
||||
chat.messageCount = chat.messages.length;
|
||||
await loadChannelFiles(App.currentChatId);
|
||||
await loadChannelFiles(App.activeId);
|
||||
UI.renderMessages(chat.messages);
|
||||
updateContextWarning(); updateChatTokenCount();
|
||||
} catch (e) {
|
||||
@@ -748,7 +796,7 @@ async function reloadActivePath() {
|
||||
// ── Regenerate (tree-aware: creates sibling) ─
|
||||
|
||||
async function regenerateMessage(messageId) {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
if (App.isGenerating || !App.activeId) return;
|
||||
|
||||
const selectedId = UI.getModelValue();
|
||||
const modelInfo = App.findModel(selectedId);
|
||||
@@ -758,7 +806,7 @@ async function regenerateMessage(messageId) {
|
||||
// Truncate display to the parent of the message being regenerated.
|
||||
// This makes the stream appear in the correct position — as a fresh
|
||||
// response to the parent user message, not appended at the bottom.
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.getActiveChat();
|
||||
if (chat) {
|
||||
const msgIdx = chat.messages.findIndex(m => m.id === messageId);
|
||||
if (msgIdx > 0) {
|
||||
@@ -773,7 +821,7 @@ async function regenerateMessage(messageId) {
|
||||
|
||||
try {
|
||||
const resp = await API.streamRegenerate(
|
||||
App.currentChatId, messageId, App.abortController.signal,
|
||||
App.activeId, messageId, App.abortController.signal,
|
||||
model, personaId, modelInfo?.configId,
|
||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
|
||||
);
|
||||
@@ -803,8 +851,8 @@ async function regenerateMessage(messageId) {
|
||||
|
||||
// Legacy: bottom-bar regenerate button targets the last assistant message
|
||||
async function regenerate() {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (App.isGenerating || !App.activeId) return;
|
||||
const chat = App.getActiveChat();
|
||||
if (!chat || chat.messages.length === 0) return;
|
||||
|
||||
// Find the last assistant message with an ID
|
||||
@@ -819,9 +867,9 @@ async function regenerate() {
|
||||
// ── Edit Message (creates sibling, triggers completion) ──
|
||||
|
||||
async function editMessage(messageId) {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
if (App.isGenerating || !App.activeId) return;
|
||||
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.getActiveChat();
|
||||
if (!chat) return;
|
||||
|
||||
const msg = chat.messages.find(m => m.id === messageId);
|
||||
@@ -832,7 +880,7 @@ async function editMessage(messageId) {
|
||||
}
|
||||
|
||||
async function submitEdit(messageId, newContent) {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
if (App.isGenerating || !App.activeId) return;
|
||||
if (!newContent.trim()) return;
|
||||
|
||||
const selectedId = UI.getModelValue();
|
||||
@@ -845,10 +893,10 @@ async function submitEdit(messageId, newContent) {
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.getActiveChat();
|
||||
|
||||
// Step 1: Create sibling user message via edit endpoint
|
||||
const editResp = await API.editMessage(App.currentChatId, messageId, newContent.trim());
|
||||
const editResp = await API.editMessage(App.activeId, messageId, newContent.trim());
|
||||
|
||||
// Step 2: Reload path to show the new edited message
|
||||
await reloadActivePath();
|
||||
@@ -858,7 +906,7 @@ async function submitEdit(messageId, newContent) {
|
||||
// a child response, not a sibling). This avoids the duplicate user
|
||||
// message that streamCompletion would create.
|
||||
const resp = await API.streamRegenerate(
|
||||
App.currentChatId, editResp.id, App.abortController.signal,
|
||||
App.activeId, editResp.id, App.abortController.signal,
|
||||
model, personaId, modelInfo?.configId,
|
||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
|
||||
);
|
||||
@@ -885,18 +933,18 @@ async function submitEdit(messageId, newContent) {
|
||||
|
||||
function cancelEdit() {
|
||||
// Re-render to remove the edit form
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.getActiveChat();
|
||||
if (chat) UI.renderMessages(chat.messages);
|
||||
}
|
||||
|
||||
// ── Switch Branch (sibling navigation) ──────
|
||||
|
||||
async function switchSibling(messageId, direction) {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
if (App.isGenerating || !App.activeId) return;
|
||||
|
||||
try {
|
||||
// Get siblings for this message
|
||||
const data = await API.listSiblings(App.currentChatId, messageId);
|
||||
const data = await API.listSiblings(App.activeId, messageId);
|
||||
const siblings = data.siblings || [];
|
||||
const currentIdx = data.current_index ?? 0;
|
||||
|
||||
@@ -906,10 +954,10 @@ async function switchSibling(messageId, direction) {
|
||||
const targetSibling = siblings[targetIdx];
|
||||
|
||||
// Update cursor to the target sibling (backend walks to leaf)
|
||||
const resp = await API.updateCursor(App.currentChatId, targetSibling.id);
|
||||
const resp = await API.updateCursor(App.activeId, targetSibling.id);
|
||||
|
||||
// Update local state with the new path
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.getActiveChat();
|
||||
if (chat && resp.path) {
|
||||
chat.messages = resp.path.map(m => ({
|
||||
id: m.id,
|
||||
@@ -923,6 +971,10 @@ async function switchSibling(messageId, direction) {
|
||||
metadata: m.metadata || null,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
participant_type: m.participant_type || null,
|
||||
participant_id: m.participant_id || null,
|
||||
sender_name: m.sender_name || null,
|
||||
sender_avatar: m.sender_avatar || null,
|
||||
}));
|
||||
chat.messageCount = chat.messages.length;
|
||||
UI.renderMessages(chat.messages);
|
||||
@@ -997,7 +1049,26 @@ function _initChatListeners() {
|
||||
// runs server-side and delivers the result via WS, not SSE.
|
||||
Events.on('message.created', (payload) => {
|
||||
if (!payload || !payload.channel_id) return;
|
||||
if (payload.channel_id !== App.currentChatId) return;
|
||||
|
||||
// v0.23.2: If this channel isn't in our sidebar yet, fetch and add it
|
||||
const knownChannel = (App.channels || []).some(c => c.id === payload.channel_id);
|
||||
const knownChat = App.chats.some(c => c.id === payload.channel_id);
|
||||
if (!knownChannel && !knownChat) {
|
||||
// New channel we're a participant in — reload channels sidebar
|
||||
if (typeof loadChannels === 'function') loadChannels();
|
||||
UI.toast(`New message from ${payload.display_name || payload.user_id?.slice(0, 8) || 'someone'}`, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a channel/DM we know but it's not the active one, bump unread
|
||||
if (payload.channel_id !== App.activeId) {
|
||||
const ch = (App.channels || []).find(c => c.id === payload.channel_id);
|
||||
if (ch) {
|
||||
ch.unread = (ch.unread || 0) + 1;
|
||||
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const chat = App.chats.find(c => c.id === payload.channel_id);
|
||||
if (!chat) return;
|
||||
@@ -1021,16 +1092,18 @@ function _initChatListeners() {
|
||||
|
||||
// Remove typing indicator and re-render
|
||||
document.getElementById('typingIndicator')?.remove();
|
||||
document.getElementById('userTypingIndicator')?.remove();
|
||||
UI.renderMessages(chat.messages);
|
||||
});
|
||||
|
||||
// Typing indicators from persona chain activity
|
||||
Events.on('typing.start', (payload) => {
|
||||
if (!payload || payload.channel_id !== App.currentChatId) return;
|
||||
if (!payload || payload.channel_id !== App.activeId) return;
|
||||
if (payload.participant_type !== 'persona') return;
|
||||
|
||||
// Show typing indicator with persona name
|
||||
document.getElementById('typingIndicator')?.remove();
|
||||
document.getElementById('userTypingIndicator')?.remove();
|
||||
const container = document.getElementById('chatMessages');
|
||||
if (!container) return;
|
||||
const typing = document.createElement('div');
|
||||
@@ -1047,8 +1120,63 @@ function _initChatListeners() {
|
||||
});
|
||||
|
||||
Events.on('typing.stop', (payload) => {
|
||||
if (!payload || payload.channel_id !== App.currentChatId) return;
|
||||
if (!payload || payload.channel_id !== App.activeId) return;
|
||||
document.getElementById('typingIndicator')?.remove();
|
||||
document.getElementById('userTypingIndicator')?.remove();
|
||||
});
|
||||
|
||||
// v0.23.2: Human typing indicator from other participants
|
||||
Events.on('typing.user', (payload) => {
|
||||
if (!payload || payload.channel_id !== App.activeId) return;
|
||||
if (payload.user_id === API.user?.id) return; // ignore self
|
||||
|
||||
// Show or refresh typing indicator
|
||||
let typing = document.getElementById('userTypingIndicator');
|
||||
if (!typing) {
|
||||
const container = document.getElementById('chatMessages');
|
||||
if (!container) return;
|
||||
typing = document.createElement('div');
|
||||
typing.id = 'userTypingIndicator';
|
||||
typing.className = 'message other-user';
|
||||
container.appendChild(typing);
|
||||
}
|
||||
const name = payload.display_name || 'Someone';
|
||||
typing.innerHTML = `
|
||||
<div class="msg-inner">
|
||||
<div class="msg-avatar" style="background:rgba(45,212,191,0.12);color:#2dd4bf;">👤</div>
|
||||
<div class="msg-body" style="background:var(--bg-surface);border:1px solid var(--border);border-radius:16px 16px 16px 4px;padding:8px 12px;">
|
||||
<span style="color:#2dd4bf;font-size:12px;font-weight:600;">${esc(name)}</span>
|
||||
<div class="typing-dots"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>`;
|
||||
const container = document.getElementById('chatMessages');
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
|
||||
// Auto-remove after 4s if no new event
|
||||
clearTimeout(typing._timeout);
|
||||
typing._timeout = setTimeout(() => typing.remove(), 4000);
|
||||
});
|
||||
|
||||
// v0.23.2: User @mention notification
|
||||
Events.on('user.mentioned', (payload) => {
|
||||
if (!payload) return;
|
||||
const channelId = payload.channel_id;
|
||||
// Increment unread badge on the channel sidebar item
|
||||
const ch = (App.channels || []).find(c => c.id === channelId);
|
||||
if (ch) {
|
||||
ch.unread = (ch.unread || 0) + 1;
|
||||
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||
}
|
||||
// Toast with deep-link
|
||||
const preview = payload.content || 'You were mentioned';
|
||||
UI.toast(`@mention: ${preview}`, 'info');
|
||||
});
|
||||
|
||||
// v0.23.2: Live presence updates for sidebar online dots
|
||||
Events.on('user.presence', (payload) => {
|
||||
if (!payload || !payload.user_id) return;
|
||||
App.presence[payload.user_id] = payload.status || 'offline';
|
||||
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||
});
|
||||
|
||||
// Close modals on overlay click
|
||||
|
||||
Reference in New Issue
Block a user