mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Client UI: - Design system: Colors, Typography, Controls resource dictionaries - Message actions: reply compose bar, hover edit/delete/reply buttons - Rich content: code blocks, attachments, system messages, content parser - Server strip: 72px sidebar with server icons, home button, add server - Status picker: popup for changing online/idle/dnd/invisible status - ConnectPage: server health check dots with auto-refresh - User popup: profile card with banner, avatar, roles, member since - Emoji picker: 6 categories, search, grid of Unicode emojis - Settings overlay: full-screen with sidebar navigation - Friends/DM view: sidebar + friends list with tabs (online/all/pending) - Toast notifications: auto-dismiss after 3s with fade animation Models & services: - Attachment model added to Message, ApiMessage, ChatMessagePayload - EditMessageAsync, DeleteMessageAsync, SendStatusChangeAsync APIs - MessageContentParser (code blocks, inline code, bold, italic) - EmojiData, ToastService, HealthStatusToBrushConverter Server (from prior session): - Voice room management, SFU, speaker detection - ACME/TLS support, config improvements - Protocol and schema updates Tests: 331 passing (61 converter + 24 voice service + 34 voice VM + 41 parser + 9 edit/delete + existing)
2248 lines
100 KiB
HTML
2248 lines
100 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>OwnCord — Interactive Prototype</title>
|
||
<style>
|
||
/* ═══════════════════════════════════════════
|
||
DESIGN TOKENS (mirrors WPF palette)
|
||
═══════════════════════════════════════════ */
|
||
:root {
|
||
--bg-tertiary: #1e1f22;
|
||
--bg-secondary: #2b2d31;
|
||
--bg-primary: #313338;
|
||
--bg-input: #383a40;
|
||
--bg-hover: #35373c;
|
||
--bg-active: #404249;
|
||
--bg-overlay: rgba(0,0,0,.7);
|
||
--accent: #5865f2;
|
||
--accent-hover: #4752c4;
|
||
--accent-active: #3c45a5;
|
||
--text-normal: #dbdee1;
|
||
--text-muted: #949ba4;
|
||
--text-faint: #80848e;
|
||
--text-micro: #6d6f78;
|
||
--text-link: #00a8fc;
|
||
--green: #23a55a;
|
||
--yellow: #f0b232;
|
||
--red: #f23f43;
|
||
--border: #3f4147;
|
||
--border-strong: #4e5058;
|
||
--role-owner: #e74c3c;
|
||
--role-admin: #f39c12;
|
||
--role-mod: #2ecc71;
|
||
--role-member: #949ba4;
|
||
--font-display: "Segoe UI Variable Display", "Segoe UI", system-ui, sans-serif;
|
||
--font-body: "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
|
||
--font-mono: "Cascadia Code", "Consolas", monospace;
|
||
--radius-sm: 4px; --radius-md: 8px; --radius-lg: 16px;
|
||
--radius-pill: 24px; --radius-circle: 50%;
|
||
}
|
||
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
html, body { height: 100%; overflow: hidden; }
|
||
body {
|
||
font-family: var(--font-body); font-size: 14px;
|
||
color: var(--text-normal); background: var(--bg-tertiary);
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
button { font-family: inherit; border: none; cursor: pointer; outline: none; }
|
||
input, textarea, select { font-family: inherit; border: none; outline: none; }
|
||
::-webkit-scrollbar { width: 8px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 4px; }
|
||
::-webkit-scrollbar-thumb:hover { background: var(--bg-hover); }
|
||
|
||
/* ═══ Layout ═══ */
|
||
.app { display: flex; height: 100vh; }
|
||
|
||
/* ── Server Strip ── */
|
||
.server-strip {
|
||
width: 72px; background: var(--bg-tertiary);
|
||
display: flex; flex-direction: column; align-items: center;
|
||
padding: 12px 0; gap: 8px; flex-shrink: 0; overflow-y: auto;
|
||
}
|
||
.server-strip::-webkit-scrollbar { width: 4px; }
|
||
.server-icon {
|
||
width: 48px; height: 48px; border-radius: var(--radius-pill);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-weight: 700; font-size: 18px; color: white;
|
||
cursor: pointer; transition: all .2s ease;
|
||
position: relative; flex-shrink: 0;
|
||
}
|
||
.server-icon:hover, .server-icon.active { border-radius: var(--radius-lg); }
|
||
.server-icon::before {
|
||
content: ''; position: absolute; left: -16px;
|
||
width: 4px; border-radius: 0 4px 4px 0;
|
||
background: white; transition: all .2s;
|
||
height: 0; opacity: 0;
|
||
}
|
||
.server-icon:hover::before { height: 20px; opacity: 1; }
|
||
.server-icon.active::before { height: 36px; opacity: 1; }
|
||
.server-icon .badge {
|
||
position: absolute; bottom: -2px; right: -2px;
|
||
background: var(--red); color: white;
|
||
font-size: 10px; font-weight: 700;
|
||
min-width: 18px; height: 18px; border-radius: 9px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
padding: 0 4px; border: 3px solid var(--bg-tertiary);
|
||
}
|
||
.server-icon .badge:empty { display: none; }
|
||
.server-separator { width: 32px; height: 2px; background: var(--border); border-radius: 1px; flex-shrink: 0; }
|
||
.server-icon.add { background: transparent; color: var(--green); border: 2px dashed var(--border-strong); font-size: 24px; }
|
||
.server-icon.add:hover { border-color: var(--green); background: rgba(35,165,90,.1); }
|
||
|
||
/* ── Channel Sidebar ── */
|
||
.channel-sidebar {
|
||
width: 240px; background: var(--bg-secondary);
|
||
display: flex; flex-direction: column; flex-shrink: 0;
|
||
}
|
||
.channel-sidebar-header {
|
||
height: 48px; padding: 0 16px;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
border-bottom: 1px solid var(--bg-tertiary); cursor: pointer; flex-shrink: 0;
|
||
}
|
||
.channel-sidebar-header h2 { font-size: 15px; font-weight: 700; color: white; }
|
||
.channel-list { flex: 1; overflow-y: auto; padding: 8px 0; }
|
||
|
||
.category {
|
||
padding: 16px 8px 4px 16px;
|
||
display: flex; align-items: center; gap: 4px;
|
||
cursor: pointer; user-select: none;
|
||
}
|
||
.category-arrow {
|
||
font-size: 8px; color: var(--text-muted);
|
||
transition: transform .2s; display: inline-block;
|
||
}
|
||
.category.collapsed .category-arrow { transform: rotate(-90deg); }
|
||
.category-name {
|
||
font-size: 11px; font-weight: 700;
|
||
color: var(--text-faint); letter-spacing: .5px; text-transform: uppercase;
|
||
}
|
||
.category-channels { overflow: hidden; transition: max-height .2s; }
|
||
.category.collapsed + .category-channels { max-height: 0 !important; overflow: hidden; }
|
||
|
||
.channel-item {
|
||
display: flex; align-items: center; gap: 6px;
|
||
padding: 6px 8px; margin: 1px 8px;
|
||
border-radius: var(--radius-sm); cursor: pointer;
|
||
color: var(--text-muted); transition: all .1s;
|
||
}
|
||
.channel-item:hover { background: var(--bg-hover); color: var(--text-normal); }
|
||
.channel-item.active { background: var(--bg-active); color: white; }
|
||
.channel-item .ch-icon { font-size: 18px; opacity: .6; flex-shrink: 0; width: 20px; text-align: center; }
|
||
.channel-item.active .ch-icon { opacity: 1; }
|
||
.channel-item .ch-name { font-size: 14px; flex: 1; }
|
||
.channel-item .unread-badge {
|
||
background: var(--accent); color: white;
|
||
font-size: 10px; font-weight: 700;
|
||
min-width: 18px; height: 18px; border-radius: 9px;
|
||
display: flex; align-items: center; justify-content: center; padding: 0 4px;
|
||
}
|
||
.channel-item .unread-badge:empty { display: none; }
|
||
.channel-item.unread .ch-name { color: white; font-weight: 600; }
|
||
|
||
/* Voice users nested in sidebar */
|
||
.voice-users-list { padding: 2px 0 4px 36px; }
|
||
.voice-user-item {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 3px 8px; border-radius: var(--radius-sm);
|
||
cursor: pointer; font-size: 13px; color: var(--text-muted);
|
||
}
|
||
.voice-user-item:hover { background: var(--bg-hover); color: var(--text-normal); }
|
||
.voice-user-item .vu-avatar {
|
||
width: 20px; height: 20px; border-radius: var(--radius-circle);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 9px; font-weight: 700; color: white; flex-shrink: 0;
|
||
}
|
||
.voice-user-item.speaking .vu-avatar { box-shadow: 0 0 0 2px var(--green); }
|
||
.voice-user-item .vu-muted { color: var(--red); font-size: 12px; margin-left: auto; }
|
||
|
||
/* Voice widget (above user bar, when connected) */
|
||
.voice-widget {
|
||
background: var(--bg-secondary); border-top: 1px solid var(--border);
|
||
padding: 8px; flex-shrink: 0; display: none;
|
||
}
|
||
.voice-widget.visible { display: block; }
|
||
.vw-header { display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 12px; }
|
||
.vw-connected { color: var(--green); font-weight: 700; }
|
||
.vw-channel { color: var(--text-muted); }
|
||
.vw-controls { display: flex; gap: 4px; padding: 4px 4px 0; }
|
||
.vw-controls button {
|
||
flex: 1; height: 32px; border-radius: var(--radius-sm);
|
||
background: var(--bg-active); color: var(--text-muted);
|
||
font-size: 14px; transition: all .1s;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.vw-controls button:hover { background: var(--bg-hover); color: var(--text-normal); }
|
||
.vw-controls button.active-ctrl { background: rgba(242,63,67,.2); color: var(--red); }
|
||
.vw-controls button.disconnect { background: transparent; color: var(--red); }
|
||
.vw-controls button.disconnect:hover { background: rgba(242,63,67,.15); }
|
||
|
||
/* User bar */
|
||
.user-bar {
|
||
height: 52px; background: rgba(17,18,20,.6);
|
||
display: flex; align-items: center;
|
||
padding: 0 8px; gap: 8px; flex-shrink: 0;
|
||
}
|
||
.user-bar .ub-avatar {
|
||
width: 32px; height: 32px; border-radius: var(--radius-circle);
|
||
flex-shrink: 0; position: relative;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-weight: 700; font-size: 14px; color: white; cursor: pointer;
|
||
}
|
||
.user-bar .status-dot {
|
||
position: absolute; bottom: -1px; right: -1px;
|
||
width: 12px; height: 12px; border-radius: var(--radius-circle);
|
||
border: 3px solid rgba(17,18,20,.6);
|
||
}
|
||
.user-bar .ub-info { flex: 1; min-width: 0; }
|
||
.user-bar .ub-name { font-size: 13px; font-weight: 600; color: white; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.user-bar .ub-status { font-size: 11px; color: var(--text-muted); }
|
||
.user-bar .ub-controls { display: flex; gap: 2px; }
|
||
.user-bar .ub-controls button {
|
||
width: 32px; height: 32px; border-radius: var(--radius-sm);
|
||
background: transparent; color: var(--text-muted);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 16px; transition: all .1s;
|
||
}
|
||
.user-bar .ub-controls button:hover { background: var(--bg-hover); color: var(--text-normal); }
|
||
.user-bar .ub-controls button.active-ctrl { color: var(--red); }
|
||
|
||
/* Status picker popup */
|
||
.status-picker {
|
||
position: absolute; bottom: 60px; left: 8px;
|
||
background: var(--bg-primary); border: 1px solid var(--border);
|
||
border-radius: var(--radius-md); padding: 6px;
|
||
box-shadow: 0 8px 24px rgba(0,0,0,.5);
|
||
z-index: 100; display: none; min-width: 160px;
|
||
}
|
||
.status-picker.open { display: block; }
|
||
.status-option {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 8px 10px; border-radius: var(--radius-sm);
|
||
cursor: pointer; font-size: 13px; color: var(--text-normal);
|
||
background: transparent; width: 100%; text-align: left;
|
||
}
|
||
.status-option:hover { background: var(--bg-hover); }
|
||
.status-option .so-dot {
|
||
width: 10px; height: 10px; border-radius: var(--radius-circle); flex-shrink: 0;
|
||
}
|
||
|
||
/* ── Chat Area ── */
|
||
.chat-area { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg-primary); }
|
||
|
||
.chat-header {
|
||
height: 48px; padding: 0 16px;
|
||
display: flex; align-items: center; gap: 12px;
|
||
border-bottom: 1px solid var(--bg-tertiary); flex-shrink: 0;
|
||
}
|
||
.chat-header .ch-hash { color: var(--text-micro); font-size: 22px; font-weight: 600; }
|
||
.chat-header .ch-name { font-size: 15px; font-weight: 700; color: white; }
|
||
.chat-header .ch-divider { width: 1px; height: 24px; background: var(--border); }
|
||
.chat-header .ch-topic { font-size: 13px; color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.chat-header .ch-tools { display: flex; gap: 4px; flex-shrink: 0; }
|
||
.chat-header .ch-tools button {
|
||
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
||
background: transparent; color: var(--text-muted);
|
||
font-size: 16px; transition: all .1s;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.chat-header .ch-tools button:hover { background: var(--bg-hover); color: var(--text-normal); }
|
||
.chat-header .ch-tools button.active { color: white; }
|
||
.chat-header .search-input {
|
||
width: 160px; height: 28px; border-radius: var(--radius-sm);
|
||
background: var(--bg-tertiary); color: var(--text-muted); font-size: 12px;
|
||
padding: 0 8px; transition: width .3s;
|
||
}
|
||
.chat-header .search-input::placeholder { color: var(--text-micro); }
|
||
.chat-header .search-input:focus { width: 240px; color: var(--text-normal); }
|
||
|
||
/* Messages */
|
||
.messages-container { flex: 1; overflow-y: auto; padding: 16px 0; }
|
||
.msg-day-divider {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 8px 16px 16px; margin-bottom: 8px;
|
||
}
|
||
.msg-day-divider .line { flex: 1; height: 1px; background: var(--border); }
|
||
.msg-day-divider .date { font-size: 11px; font-weight: 700; color: var(--text-muted); }
|
||
|
||
.message {
|
||
padding: 2px 48px 2px 72px; position: relative;
|
||
min-height: 28px;
|
||
}
|
||
.message:hover { background: rgba(0,0,0,.06); }
|
||
.message.grouped { min-height: 20px; }
|
||
.message .msg-avatar {
|
||
position: absolute; left: 16px; top: 4px;
|
||
width: 40px; height: 40px; border-radius: var(--radius-circle);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-weight: 700; font-size: 16px; color: white;
|
||
cursor: pointer;
|
||
}
|
||
.message.grouped .msg-avatar { display: none; }
|
||
.message .msg-hover-time {
|
||
position: absolute; left: 16px; top: 4px;
|
||
width: 40px; text-align: center;
|
||
font-size: 10px; color: var(--text-micro);
|
||
display: none;
|
||
}
|
||
.message.grouped:hover .msg-hover-time { display: block; }
|
||
.message .msg-header { display: flex; align-items: baseline; gap: 8px; }
|
||
.message.grouped .msg-header { display: none; }
|
||
.message .msg-author { font-weight: 600; cursor: pointer; }
|
||
.message .msg-author:hover { text-decoration: underline; }
|
||
.message .msg-time { font-size: 11px; color: var(--text-micro); }
|
||
.message .msg-edited { font-size: 10px; color: var(--text-micro); }
|
||
.message .msg-text { line-height: 1.45; word-break: break-word; }
|
||
.message .msg-text code {
|
||
font-family: var(--font-mono); background: var(--bg-tertiary);
|
||
padding: 2px 5px; border-radius: 3px; font-size: 13px;
|
||
}
|
||
|
||
/* Reply */
|
||
.msg-reply-ref {
|
||
display: flex; align-items: center; gap: 6px;
|
||
font-size: 12px; color: var(--text-muted);
|
||
margin-bottom: 4px; margin-left: -36px; padding-left: 36px;
|
||
position: relative; cursor: pointer;
|
||
}
|
||
.msg-reply-ref::before {
|
||
content: ''; position: absolute; left: 20px; top: 50%;
|
||
width: 24px; height: 12px;
|
||
border-left: 2px solid var(--text-micro);
|
||
border-top: 2px solid var(--text-micro);
|
||
border-radius: 6px 0 0 0;
|
||
}
|
||
.msg-reply-ref .rr-author { color: var(--text-normal); font-weight: 600; }
|
||
.msg-reply-ref .rr-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.msg-reply-ref:hover .rr-text { color: var(--text-normal); }
|
||
|
||
/* Reactions */
|
||
.msg-reactions { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }
|
||
.reaction-chip {
|
||
display: flex; align-items: center; gap: 4px;
|
||
padding: 2px 8px; border-radius: var(--radius-md);
|
||
background: rgba(88,101,242,.15); border: 1px solid rgba(88,101,242,.3);
|
||
font-size: 14px; cursor: pointer; transition: all .1s;
|
||
}
|
||
.reaction-chip:hover { background: rgba(88,101,242,.3); }
|
||
.reaction-chip.me { border-color: var(--accent); }
|
||
.reaction-chip .rc-count { font-size: 12px; color: var(--text-normal); }
|
||
.reaction-chip.add-reaction {
|
||
background: transparent; border: 1px dashed var(--border);
|
||
color: var(--text-micro); font-size: 14px; padding: 2px 6px;
|
||
}
|
||
.reaction-chip.add-reaction:hover { border-color: var(--text-muted); color: var(--text-muted); }
|
||
|
||
/* Code block */
|
||
.msg-codeblock {
|
||
background: var(--bg-tertiary); border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm); padding: 12px; margin-top: 4px;
|
||
font-family: var(--font-mono); font-size: 13px;
|
||
line-height: 1.5; overflow-x: auto; white-space: pre;
|
||
}
|
||
|
||
/* Image attachment */
|
||
.msg-image {
|
||
margin-top: 4px; max-width: 400px; border-radius: var(--radius-md);
|
||
overflow: hidden; cursor: pointer;
|
||
}
|
||
.msg-image .placeholder-img {
|
||
width: 100%; height: 200px;
|
||
background: linear-gradient(135deg, #1a1a2e, #16213e, #0f3460);
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: var(--text-micro); font-size: 12px;
|
||
}
|
||
|
||
/* File attachment */
|
||
.msg-file {
|
||
margin-top: 4px; border: 1px solid var(--border);
|
||
border-radius: var(--radius-md); overflow: hidden; max-width: 400px;
|
||
}
|
||
.msg-file-inner {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 12px; background: var(--bg-secondary);
|
||
}
|
||
.msg-file-icon {
|
||
width: 36px; height: 36px; border-radius: var(--radius-sm);
|
||
background: var(--accent); color: white;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 16px; font-weight: 700; flex-shrink: 0;
|
||
}
|
||
.msg-file-name { font-size: 13px; color: var(--text-link); cursor: pointer; }
|
||
.msg-file-name:hover { text-decoration: underline; }
|
||
.msg-file-size { font-size: 11px; color: var(--text-muted); }
|
||
|
||
/* System message */
|
||
.system-msg {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 4px 16px; margin-top: 4px;
|
||
}
|
||
.system-msg .sm-icon { color: var(--green); font-size: 16px; }
|
||
.system-msg .sm-text { font-size: 13px; color: var(--text-muted); }
|
||
.system-msg .sm-text strong { color: var(--text-normal); }
|
||
.system-msg .sm-time { font-size: 11px; color: var(--text-micro); margin-left: auto; }
|
||
|
||
/* Message hover actions */
|
||
.msg-actions-bar {
|
||
position: absolute; top: -14px; right: 16px;
|
||
display: flex; gap: 2px;
|
||
background: var(--bg-secondary); border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm); padding: 2px;
|
||
opacity: 0; transition: opacity .1s;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,.3); z-index: 5;
|
||
}
|
||
.message:hover .msg-actions-bar { opacity: 1; }
|
||
.msg-actions-bar button {
|
||
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
||
background: transparent; color: var(--text-muted);
|
||
font-size: 14px; transition: all .1s;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.msg-actions-bar button:hover { background: var(--bg-hover); color: var(--text-normal); }
|
||
|
||
/* Reply compose bar */
|
||
.reply-bar {
|
||
display: none; padding: 8px 16px 0;
|
||
flex-shrink: 0;
|
||
}
|
||
.reply-bar.visible { display: flex; align-items: center; gap: 8px; }
|
||
.reply-bar-inner {
|
||
flex: 1; background: var(--bg-input);
|
||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||
padding: 8px 12px; font-size: 13px; color: var(--text-muted);
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.reply-bar-inner strong { color: var(--text-normal); }
|
||
.reply-bar .reply-close {
|
||
width: 24px; height: 24px; border-radius: var(--radius-sm);
|
||
background: transparent; color: var(--text-muted); font-size: 16px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
margin-left: auto;
|
||
}
|
||
.reply-bar .reply-close:hover { color: var(--text-normal); }
|
||
|
||
/* Typing indicator */
|
||
.typing-bar {
|
||
height: 24px; padding: 0 16px;
|
||
display: flex; align-items: center; gap: 8px;
|
||
font-size: 12px; color: var(--text-muted); flex-shrink: 0;
|
||
}
|
||
.typing-bar:empty { height: 0; }
|
||
.typing-dots { display: inline-flex; gap: 3px; }
|
||
.typing-dots span {
|
||
width: 4px; height: 4px; border-radius: 50%;
|
||
background: var(--text-muted); animation: typingAnim 1.4s infinite;
|
||
}
|
||
.typing-dots span:nth-child(2) { animation-delay: .2s; }
|
||
.typing-dots span:nth-child(3) { animation-delay: .4s; }
|
||
@keyframes typingAnim {
|
||
0%, 60%, 100% { opacity: .3; transform: translateY(0); }
|
||
30% { opacity: 1; transform: translateY(-3px); }
|
||
}
|
||
|
||
/* Message input */
|
||
.message-input-wrap { padding: 0 16px 20px; flex-shrink: 0; }
|
||
.message-input-wrap.reply-active { padding-top: 0; }
|
||
.message-input-box {
|
||
background: var(--bg-input); border-radius: var(--radius-md);
|
||
display: flex; align-items: flex-end; padding: 4px;
|
||
}
|
||
.message-input-box.reply-mode { border-radius: 0 0 var(--radius-md) var(--radius-md); }
|
||
.input-btn {
|
||
width: 36px; height: 36px; border-radius: var(--radius-sm);
|
||
background: transparent; color: var(--text-muted);
|
||
font-size: 18px; transition: all .1s; flex-shrink: 0;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.input-btn:hover { color: var(--text-normal); }
|
||
.msg-textarea {
|
||
flex: 1; background: transparent; color: var(--text-normal);
|
||
font-size: 14px; resize: none; padding: 8px 4px;
|
||
min-height: 36px; max-height: 200px; line-height: 1.4;
|
||
}
|
||
.msg-textarea::placeholder { color: var(--text-micro); }
|
||
|
||
/* ── Member List ── */
|
||
.member-list {
|
||
width: 240px; background: var(--bg-secondary);
|
||
flex-shrink: 0; overflow-y: auto; padding: 8px 0;
|
||
transition: width .2s;
|
||
}
|
||
.member-list.hidden { width: 0; padding: 0; overflow: hidden; }
|
||
.member-role-group {
|
||
padding: 16px 16px 4px;
|
||
font-size: 11px; font-weight: 700;
|
||
color: var(--text-faint); text-transform: uppercase; letter-spacing: .5px;
|
||
}
|
||
.member-item {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 4px 8px; margin: 1px 8px;
|
||
border-radius: var(--radius-sm); cursor: pointer; transition: background .1s;
|
||
}
|
||
.member-item:hover { background: var(--bg-hover); }
|
||
.member-item .mi-avatar {
|
||
width: 32px; height: 32px; border-radius: var(--radius-circle);
|
||
flex-shrink: 0; position: relative;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-weight: 700; font-size: 13px; color: white;
|
||
}
|
||
.member-item .mi-status {
|
||
position: absolute; bottom: -1px; right: -1px;
|
||
width: 12px; height: 12px; border-radius: var(--radius-circle);
|
||
border: 3px solid var(--bg-secondary);
|
||
}
|
||
.member-item .mi-name { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.member-item.offline { opacity: .4; }
|
||
|
||
/* ── User profile popup ── */
|
||
.user-popup {
|
||
position: fixed; z-index: 200;
|
||
background: var(--bg-primary); border: 1px solid var(--border);
|
||
border-radius: var(--radius-md); width: 300px;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,.6);
|
||
display: none; overflow: hidden;
|
||
}
|
||
.user-popup.open { display: block; }
|
||
.up-banner { height: 60px; }
|
||
.up-body { padding: 36px 16px 16px; position: relative; }
|
||
.up-avatar {
|
||
width: 64px; height: 64px; border-radius: var(--radius-circle);
|
||
position: absolute; top: -32px; left: 16px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-weight: 700; font-size: 24px; color: white;
|
||
border: 4px solid var(--bg-primary);
|
||
}
|
||
.up-name { font-size: 18px; font-weight: 700; color: white; }
|
||
.up-role { font-size: 12px; margin-top: 2px; }
|
||
.up-section { margin-top: 12px; background: var(--bg-secondary); border-radius: var(--radius-md); padding: 12px; }
|
||
.up-section-title { font-size: 11px; font-weight: 700; color: var(--text-faint); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 6px; }
|
||
.up-section-text { font-size: 13px; color: var(--text-normal); }
|
||
.up-roles { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 8px; }
|
||
.up-role-tag {
|
||
display: flex; align-items: center; gap: 4px;
|
||
padding: 2px 8px; border-radius: var(--radius-pill);
|
||
background: var(--bg-hover); font-size: 11px; color: var(--text-normal);
|
||
}
|
||
.up-role-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||
|
||
/* ── Emoji Picker ── */
|
||
.emoji-picker {
|
||
position: fixed; z-index: 200;
|
||
background: var(--bg-primary); border: 1px solid var(--border);
|
||
border-radius: var(--radius-md); width: 320px;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,.6);
|
||
display: none; overflow: hidden;
|
||
}
|
||
.emoji-picker.open { display: block; }
|
||
.ep-header {
|
||
padding: 12px; border-bottom: 1px solid var(--border);
|
||
}
|
||
.ep-search {
|
||
width: 100%; background: var(--bg-tertiary);
|
||
border: none; border-radius: var(--radius-sm);
|
||
padding: 8px 10px; color: var(--text-normal); font-size: 13px;
|
||
}
|
||
.ep-search::placeholder { color: var(--text-micro); }
|
||
.ep-category-label {
|
||
padding: 8px 12px 4px; font-size: 11px; font-weight: 700;
|
||
color: var(--text-faint); text-transform: uppercase; letter-spacing: .5px;
|
||
}
|
||
.ep-grid {
|
||
display: grid; grid-template-columns: repeat(8, 1fr);
|
||
gap: 2px; padding: 4px 8px 8px;
|
||
}
|
||
.ep-emoji {
|
||
width: 100%; aspect-ratio: 1;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 22px; cursor: pointer; border-radius: var(--radius-sm);
|
||
background: transparent; transition: background .1s;
|
||
}
|
||
.ep-emoji:hover { background: var(--bg-hover); }
|
||
|
||
/* ── Settings Overlay ── */
|
||
.settings-overlay {
|
||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||
background: var(--bg-primary); z-index: 500;
|
||
display: none;
|
||
}
|
||
.settings-overlay.open { display: flex; }
|
||
.settings-sidebar {
|
||
width: 220px; background: var(--bg-secondary);
|
||
padding: 48px 8px 16px; overflow-y: auto; flex-shrink: 0;
|
||
}
|
||
.settings-cat {
|
||
padding: 8px 12px 4px; font-size: 11px; font-weight: 700;
|
||
color: var(--text-faint); text-transform: uppercase; letter-spacing: .5px;
|
||
}
|
||
.settings-nav-item {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 6px 12px; margin: 1px 0;
|
||
border-radius: var(--radius-sm); cursor: pointer;
|
||
font-size: 14px; color: var(--text-muted);
|
||
transition: all .1s; background: transparent; width: 100%; text-align: left;
|
||
}
|
||
.settings-nav-item:hover { background: var(--bg-hover); color: var(--text-normal); }
|
||
.settings-nav-item.active { background: var(--bg-active); color: white; }
|
||
.settings-sep { height: 1px; background: var(--border); margin: 8px 12px; }
|
||
.settings-nav-item.danger { color: var(--red); }
|
||
.settings-nav-item.danger:hover { background: rgba(242,63,67,.1); }
|
||
|
||
.settings-content {
|
||
flex: 1; overflow-y: auto; padding: 48px 40px; max-width: 720px;
|
||
}
|
||
.settings-content h1 { font-size: 20px; font-weight: 700; color: white; margin-bottom: 24px; }
|
||
.settings-content h3 {
|
||
font-size: 11px; font-weight: 700;
|
||
color: var(--text-faint); text-transform: uppercase;
|
||
letter-spacing: .5px; margin: 24px 0 8px;
|
||
}
|
||
.settings-close-btn {
|
||
position: absolute; top: 16px; right: 24px;
|
||
width: 36px; height: 36px; border-radius: var(--radius-circle);
|
||
background: transparent; border: 2px solid var(--border-strong);
|
||
color: var(--text-muted); font-size: 18px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
cursor: pointer; transition: all .15s; z-index: 10;
|
||
}
|
||
.settings-close-btn:hover { border-color: var(--text-normal); color: var(--text-normal); }
|
||
|
||
/* Settings panes */
|
||
.settings-pane { display: none; }
|
||
.settings-pane.active { display: block; }
|
||
.setting-row {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 16px 0; border-bottom: 1px solid var(--border);
|
||
}
|
||
.setting-row:last-child { border-bottom: none; }
|
||
.setting-label { font-size: 14px; color: var(--text-normal); }
|
||
.setting-desc { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
|
||
.toggle {
|
||
width: 40px; height: 24px; border-radius: 12px;
|
||
background: var(--text-faint); cursor: pointer;
|
||
position: relative; transition: background .2s; flex-shrink: 0;
|
||
}
|
||
.toggle.on { background: var(--green); }
|
||
.toggle::after {
|
||
content: ''; position: absolute;
|
||
width: 18px; height: 18px; border-radius: 50%;
|
||
background: white; top: 3px; left: 3px; transition: transform .2s;
|
||
}
|
||
.toggle.on::after { transform: translateX(16px); }
|
||
.settings-select {
|
||
background: var(--bg-tertiary); color: var(--text-normal);
|
||
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||
padding: 8px 12px; font-size: 14px; width: 200px; cursor: pointer;
|
||
}
|
||
.slider-row { display: flex; align-items: center; gap: 12px; }
|
||
.settings-slider {
|
||
flex: 1; -webkit-appearance: none; appearance: none;
|
||
height: 6px; border-radius: 3px;
|
||
background: var(--bg-tertiary); outline: none;
|
||
}
|
||
.settings-slider::-webkit-slider-thumb {
|
||
-webkit-appearance: none; width: 16px; height: 16px;
|
||
border-radius: 50%; background: var(--accent); cursor: pointer;
|
||
}
|
||
.slider-val { font-size: 13px; color: var(--text-muted); min-width: 40px; text-align: right; }
|
||
.account-card {
|
||
background: var(--bg-secondary); border-radius: var(--radius-md);
|
||
padding: 16px; display: flex; align-items: center; gap: 16px;
|
||
}
|
||
.ac-avatar {
|
||
width: 64px; height: 64px; border-radius: var(--radius-circle);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-weight: 700; font-size: 24px; color: white; flex-shrink: 0;
|
||
}
|
||
.ac-name { font-size: 18px; font-weight: 700; color: white; }
|
||
.ac-id { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||
.ac-btn {
|
||
padding: 8px 16px; border-radius: var(--radius-sm);
|
||
background: var(--accent); color: white;
|
||
font-size: 13px; font-weight: 600; transition: background .15s; margin-left: auto;
|
||
}
|
||
.ac-btn:hover { background: var(--accent-hover); }
|
||
.theme-options { display: flex; gap: 12px; margin-top: 8px; }
|
||
.theme-opt {
|
||
width: 80px; height: 56px; border-radius: var(--radius-md);
|
||
cursor: pointer; border: 2px solid transparent;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 12px; font-weight: 600; transition: all .15s;
|
||
}
|
||
.theme-opt:hover { border-color: var(--border-strong); }
|
||
.theme-opt.active { border-color: var(--accent); }
|
||
.theme-opt.dark { background: #313338; color: white; }
|
||
.theme-opt.light { background: #f2f3f5; color: #313338; }
|
||
.theme-opt.midnight { background: #0d0d0d; color: #b5bac1; }
|
||
.keybind-row {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 12px 0; border-bottom: 1px solid var(--border);
|
||
}
|
||
.kbd {
|
||
background: var(--bg-tertiary); border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm); padding: 4px 8px;
|
||
font-family: var(--font-mono); font-size: 12px; color: var(--text-normal);
|
||
}
|
||
|
||
/* ═══ Animations ═══ */
|
||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||
@keyframes slideIn { from { opacity: 0; transform: scale(.95); } to { opacity: 1; transform: scale(1); } }
|
||
.user-popup.open, .emoji-picker.open { animation: slideIn .15s ease; }
|
||
.settings-overlay.open { animation: fadeIn .2s ease; }
|
||
|
||
/* Toast notification */
|
||
.toast {
|
||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||
background: var(--bg-secondary); border: 1px solid var(--border);
|
||
border-radius: var(--radius-md); padding: 12px 20px;
|
||
font-size: 13px; color: var(--text-normal);
|
||
box-shadow: 0 8px 24px rgba(0,0,0,.5);
|
||
z-index: 9999; opacity: 0; transition: opacity .3s;
|
||
pointer-events: none;
|
||
}
|
||
.toast.show { opacity: 1; }
|
||
|
||
/* ═══ Friends / DM View ═══ */
|
||
.dm-sidebar-header {
|
||
padding: 8px; flex-shrink: 0;
|
||
}
|
||
.dm-search {
|
||
width: 100%; background: var(--bg-tertiary);
|
||
border: none; border-radius: var(--radius-sm);
|
||
padding: 8px 10px; color: var(--text-normal); font-size: 13px;
|
||
}
|
||
.dm-search::placeholder { color: var(--text-micro); }
|
||
|
||
.dm-nav-item {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 8px 12px; margin: 1px 8px;
|
||
border-radius: var(--radius-sm); cursor: pointer;
|
||
color: var(--text-muted); transition: all .1s; font-size: 14px;
|
||
}
|
||
.dm-nav-item:hover { background: var(--bg-hover); color: var(--text-normal); }
|
||
.dm-nav-item.active { background: var(--bg-active); color: white; }
|
||
.dm-nav-item .dm-nav-icon { font-size: 18px; width: 20px; text-align: center; }
|
||
|
||
.dm-section-label {
|
||
padding: 16px 16px 4px;
|
||
font-size: 11px; font-weight: 700;
|
||
color: var(--text-faint); letter-spacing: .5px; text-transform: uppercase;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
}
|
||
.dm-section-label .dm-add {
|
||
width: 18px; height: 18px; border-radius: var(--radius-sm);
|
||
background: transparent; color: var(--text-muted);
|
||
font-size: 16px; display: flex; align-items: center; justify-content: center;
|
||
cursor: pointer; transition: color .1s;
|
||
}
|
||
.dm-section-label .dm-add:hover { color: var(--text-normal); }
|
||
|
||
.dm-item {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 6px 8px; margin: 1px 8px;
|
||
border-radius: var(--radius-sm); cursor: pointer;
|
||
color: var(--text-muted); transition: all .1s;
|
||
}
|
||
.dm-item:hover { background: var(--bg-hover); color: var(--text-normal); }
|
||
.dm-item.active { background: var(--bg-active); color: white; }
|
||
.dm-item .dm-avatar {
|
||
width: 32px; height: 32px; border-radius: var(--radius-circle);
|
||
flex-shrink: 0; position: relative;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-weight: 700; font-size: 13px; color: white;
|
||
}
|
||
.dm-item .dm-avatar .dm-status {
|
||
position: absolute; bottom: -1px; right: -1px;
|
||
width: 12px; height: 12px; border-radius: var(--radius-circle);
|
||
border: 3px solid var(--bg-secondary);
|
||
}
|
||
.dm-item.active .dm-avatar .dm-status { border-color: var(--bg-active); }
|
||
.dm-item .dm-name { font-size: 14px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.dm-item .dm-close {
|
||
width: 20px; height: 20px; border-radius: var(--radius-sm);
|
||
background: transparent; color: var(--text-micro);
|
||
font-size: 14px; display: none; align-items: center; justify-content: center;
|
||
cursor: pointer;
|
||
}
|
||
.dm-item:hover .dm-close { display: flex; }
|
||
.dm-item .dm-close:hover { color: var(--text-normal); }
|
||
.dm-item .dm-unread {
|
||
width: 8px; height: 8px; border-radius: 50%;
|
||
background: white; flex-shrink: 0;
|
||
}
|
||
|
||
/* Friends list (main area when Home > Friends is active) */
|
||
.friends-view {
|
||
flex: 1; display: flex; flex-direction: column;
|
||
background: var(--bg-primary);
|
||
}
|
||
.friends-header {
|
||
height: 48px; padding: 0 16px;
|
||
display: flex; align-items: center; gap: 16px;
|
||
border-bottom: 1px solid var(--bg-tertiary); flex-shrink: 0;
|
||
}
|
||
.friends-header .fh-title {
|
||
font-size: 15px; font-weight: 700; color: white;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.friends-header .fh-title .fh-icon { font-size: 20px; }
|
||
.friends-header .fh-divider { width: 1px; height: 24px; background: var(--border); }
|
||
.friends-tab {
|
||
padding: 4px 10px; border-radius: var(--radius-sm);
|
||
font-size: 13px; font-weight: 500; color: var(--text-muted);
|
||
cursor: pointer; transition: all .1s; background: transparent;
|
||
}
|
||
.friends-tab:hover { background: var(--bg-hover); color: var(--text-normal); }
|
||
.friends-tab.active { background: var(--bg-active); color: white; }
|
||
.friends-tab.add-friend {
|
||
background: var(--green); color: white; font-weight: 600;
|
||
}
|
||
.friends-tab.add-friend:hover { background: #1e8e4c; }
|
||
|
||
.friends-list { flex: 1; overflow-y: auto; padding: 8px 16px; }
|
||
.friends-search-bar {
|
||
padding: 8px 0 16px;
|
||
}
|
||
.friends-search {
|
||
width: 100%; background: var(--bg-tertiary);
|
||
border: none; border-radius: var(--radius-sm);
|
||
padding: 10px 12px; color: var(--text-normal); font-size: 13px;
|
||
}
|
||
.friends-search::placeholder { color: var(--text-micro); }
|
||
.friends-count {
|
||
font-size: 11px; font-weight: 700;
|
||
color: var(--text-faint); text-transform: uppercase;
|
||
letter-spacing: .5px; padding: 8px 0;
|
||
}
|
||
|
||
.friend-item {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 10px 8px;
|
||
border-top: 1px solid var(--border);
|
||
cursor: pointer; transition: background .1s;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.friend-item:hover { background: var(--bg-hover); }
|
||
.friend-item .fi-avatar {
|
||
width: 40px; height: 40px; border-radius: var(--radius-circle);
|
||
flex-shrink: 0; position: relative;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-weight: 700; font-size: 16px; color: white;
|
||
}
|
||
.friend-item .fi-status-dot {
|
||
position: absolute; bottom: 0; right: 0;
|
||
width: 14px; height: 14px; border-radius: var(--radius-circle);
|
||
border: 3px solid var(--bg-primary);
|
||
}
|
||
.friend-item:hover .fi-status-dot { border-color: var(--bg-hover); }
|
||
.friend-item .fi-info { flex: 1; }
|
||
.friend-item .fi-name { font-size: 14px; font-weight: 600; color: white; }
|
||
.friend-item .fi-status-text { font-size: 12px; color: var(--text-muted); }
|
||
.friend-item .fi-actions {
|
||
display: flex; gap: 6px; opacity: 0; transition: opacity .1s;
|
||
}
|
||
.friend-item:hover .fi-actions { opacity: 1; }
|
||
.fi-action-btn {
|
||
width: 36px; height: 36px; border-radius: var(--radius-circle);
|
||
background: var(--bg-secondary); color: var(--text-muted);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 16px; transition: all .1s; cursor: pointer; border: none;
|
||
}
|
||
.fi-action-btn:hover { background: var(--bg-active); color: var(--text-normal); }
|
||
|
||
/* Add friend form */
|
||
.add-friend-form {
|
||
padding: 24px;
|
||
}
|
||
.add-friend-form h2 {
|
||
font-size: 16px; font-weight: 700; color: white; margin-bottom: 4px;
|
||
}
|
||
.add-friend-form p {
|
||
font-size: 13px; color: var(--text-muted); margin-bottom: 16px;
|
||
}
|
||
.add-friend-input-row {
|
||
display: flex; gap: 8px;
|
||
}
|
||
.add-friend-input {
|
||
flex: 1; background: var(--bg-tertiary);
|
||
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||
padding: 12px; color: var(--text-normal); font-size: 14px;
|
||
}
|
||
.add-friend-input:focus { border-color: var(--accent); }
|
||
.add-friend-input::placeholder { color: var(--text-micro); }
|
||
.add-friend-submit {
|
||
padding: 12px 20px; border-radius: var(--radius-sm);
|
||
background: var(--accent); color: white;
|
||
font-size: 14px; font-weight: 600; transition: background .15s;
|
||
white-space: nowrap;
|
||
}
|
||
.add-friend-submit:hover { background: var(--accent-hover); }
|
||
|
||
/* DM chat header (slightly different from channel header) */
|
||
.dm-chat-header {
|
||
height: 48px; padding: 0 16px;
|
||
display: flex; align-items: center; gap: 10px;
|
||
border-bottom: 1px solid var(--bg-tertiary); flex-shrink: 0;
|
||
}
|
||
.dm-chat-header .dch-avatar {
|
||
width: 24px; height: 24px; border-radius: var(--radius-circle);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-weight: 700; font-size: 10px; color: white;
|
||
}
|
||
.dm-chat-header .dch-name { font-size: 15px; font-weight: 700; color: white; }
|
||
.dm-chat-header .dch-status { font-size: 12px; color: var(--text-muted); }
|
||
|
||
/* DM profile sidebar (replaces member list in DM view) */
|
||
.dm-profile {
|
||
width: 340px; background: var(--bg-secondary);
|
||
flex-shrink: 0; overflow-y: auto;
|
||
transition: width .2s;
|
||
}
|
||
.dm-profile.hidden { width: 0; overflow: hidden; }
|
||
.dm-profile-banner { height: 120px; }
|
||
.dm-profile-body { padding: 48px 16px 16px; position: relative; }
|
||
.dm-profile-avatar {
|
||
width: 80px; height: 80px; border-radius: var(--radius-circle);
|
||
position: absolute; top: -40px; left: 16px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-weight: 700; font-size: 28px; color: white;
|
||
border: 6px solid var(--bg-secondary);
|
||
}
|
||
.dm-profile-name { font-size: 20px; font-weight: 700; color: white; }
|
||
.dm-profile-role { font-size: 12px; margin-top: 2px; }
|
||
.dm-profile-section {
|
||
margin-top: 16px; background: var(--bg-primary);
|
||
border-radius: var(--radius-md); padding: 12px;
|
||
}
|
||
.dm-profile-section-title {
|
||
font-size: 11px; font-weight: 700; color: var(--text-faint);
|
||
text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px;
|
||
}
|
||
.dm-profile-section-text { font-size: 13px; color: var(--text-normal); line-height: 1.5; }
|
||
.dm-profile-mutual {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 6px 0;
|
||
}
|
||
.dm-profile-mutual .dpm-avatar {
|
||
width: 24px; height: 24px; border-radius: var(--radius-circle);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-weight: 700; font-size: 9px; color: white; flex-shrink: 0;
|
||
}
|
||
.dm-profile-mutual .dpm-name { font-size: 13px; color: var(--text-muted); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="app" id="app">
|
||
<!-- Server Strip -->
|
||
<div class="server-strip" id="serverStrip"></div>
|
||
|
||
<!-- Channel Sidebar -->
|
||
<div class="channel-sidebar" id="channelSidebar">
|
||
<div class="channel-sidebar-header" id="sidebarHeader"><h2 id="serverName"></h2></div>
|
||
<div class="channel-list" id="channelList"></div>
|
||
<div class="voice-widget" id="voiceWidget">
|
||
<div class="vw-header">
|
||
<span class="vw-connected">Voice Connected</span>
|
||
<span class="vw-channel" id="vwChannel"></span>
|
||
</div>
|
||
<div class="vw-controls">
|
||
<button id="vwMute" title="Mute" onclick="toggleMute()">🎤</button>
|
||
<button id="vwDeafen" title="Deafen" onclick="toggleDeafen()">🎧</button>
|
||
<button class="disconnect" title="Disconnect" onclick="leaveVoice()">☒</button>
|
||
</div>
|
||
</div>
|
||
<div class="user-bar" id="userBar">
|
||
<div class="ub-avatar" id="ubAvatar" onclick="toggleStatusPicker()">
|
||
<div class="status-dot" id="ubStatusDot"></div>
|
||
</div>
|
||
<div class="ub-info">
|
||
<div class="ub-name" id="ubName"></div>
|
||
<div class="ub-status" id="ubStatus"></div>
|
||
</div>
|
||
<div class="ub-controls">
|
||
<button id="ubMuteBtn" title="Mute" onclick="toggleMute()">🎤</button>
|
||
<button id="ubDeafenBtn" title="Deafen" onclick="toggleDeafen()">🎧</button>
|
||
<button title="Settings" onclick="openSettings()">⚙</button>
|
||
</div>
|
||
</div>
|
||
<div class="status-picker" id="statusPicker"></div>
|
||
</div>
|
||
|
||
<!-- Chat Area -->
|
||
<div class="chat-area" id="chatArea">
|
||
<div class="chat-header">
|
||
<span class="ch-hash">#</span>
|
||
<span class="ch-name" id="chatChannelName"></span>
|
||
<span class="ch-divider"></span>
|
||
<span class="ch-topic" id="chatChannelTopic"></span>
|
||
<div class="ch-tools">
|
||
<input class="search-input" type="text" placeholder="Search...">
|
||
<button title="Pinned">📌</button>
|
||
<button id="toggleMembersBtn" title="Member List" onclick="toggleMembers()">👥</button>
|
||
</div>
|
||
</div>
|
||
<div class="messages-container" id="messagesContainer"></div>
|
||
<div class="typing-bar" id="typingBar"></div>
|
||
<div class="reply-bar" id="replyBar">
|
||
<div class="reply-bar-inner">
|
||
Replying to <strong id="replyToName"></strong>
|
||
<button class="reply-close" onclick="cancelReply()">✕</button>
|
||
</div>
|
||
</div>
|
||
<div class="message-input-wrap" id="inputWrap">
|
||
<div class="message-input-box" id="inputBox">
|
||
<button class="input-btn" title="Attach File">📎</button>
|
||
<textarea class="msg-textarea" id="msgInput" rows="1" placeholder=""></textarea>
|
||
<button class="input-btn" title="Emoji" onclick="toggleEmojiPicker(event)">🙂</button>
|
||
<button class="input-btn" title="Send" style="color:var(--accent);" onclick="sendMessage()">➤</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Member List (server channels) -->
|
||
<div class="member-list" id="memberList"></div>
|
||
|
||
<!-- Friends View (Home > Friends tab) -->
|
||
<div class="friends-view" id="friendsView" style="display:none;"></div>
|
||
|
||
<!-- DM Profile sidebar (replaces member list in DM view) -->
|
||
<div class="dm-profile hidden" id="dmProfile"></div>
|
||
</div>
|
||
|
||
<!-- User Profile Popup -->
|
||
<div class="user-popup" id="userPopup">
|
||
<div class="up-banner" id="upBanner"></div>
|
||
<div class="up-body">
|
||
<div class="up-avatar" id="upAvatar"></div>
|
||
<div class="up-name" id="upName"></div>
|
||
<div class="up-role" id="upRole"></div>
|
||
<div class="up-section">
|
||
<div class="up-section-title">Member Since</div>
|
||
<div class="up-section-text" id="upJoined"></div>
|
||
</div>
|
||
<div class="up-roles" id="upRoles"></div>
|
||
<button class="ac-btn" style="width:100%;margin-top:12px;text-align:center;" id="upMessageBtn" onclick="dmFromPopup()">Message</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Emoji Picker -->
|
||
<div class="emoji-picker" id="emojiPicker">
|
||
<div class="ep-header">
|
||
<input class="ep-search" id="emojiSearch" type="text" placeholder="Search emoji..." oninput="filterEmoji()">
|
||
</div>
|
||
<div id="emojiGrid"></div>
|
||
</div>
|
||
|
||
<!-- Settings Overlay -->
|
||
<div class="settings-overlay" id="settingsOverlay">
|
||
<button class="settings-close-btn" onclick="closeSettings()">✕</button>
|
||
<div class="settings-sidebar" id="settingsSidebar"></div>
|
||
<div class="settings-content" id="settingsContent"></div>
|
||
</div>
|
||
|
||
<!-- Toast -->
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
// ═══════════════════════════════════════════
|
||
// MOCK DATA — simulates server "ready" payload
|
||
// ═══════════════════════════════════════════
|
||
|
||
const SELF = { id: 0, username: 'YourName', avatar: '#5865f2', status: 'online', role: 'admin' };
|
||
|
||
const SERVERS = [
|
||
{
|
||
id: 's0', name: 'OwnCord', icon: 'O', color: '#5865f2', isHome: true,
|
||
channels: [
|
||
{ cat: 'Text Channels', items: [
|
||
{ id: 'c1', name: 'general', type: 'text', topic: 'Welcome to OwnCord! Be nice.', unread: 0 },
|
||
{ id: 'c2', name: 'announcements', type: 'text', topic: 'Important updates', unread: 5 },
|
||
{ id: 'c3', name: 'memes', type: 'text', topic: 'Only the dankest', unread: 0 },
|
||
{ id: 'c4', name: 'dev-talk', type: 'text', topic: 'Code discussion', unread: 0 },
|
||
]},
|
||
{ cat: 'Voice Channels', items: [
|
||
{ id: 'v1', name: 'Lounge', type: 'voice' },
|
||
{ id: 'v2', name: 'Gaming', type: 'voice' },
|
||
]},
|
||
],
|
||
members: [
|
||
{ id: 1, username: 'ServerOwner', avatar: '#e74c3c', role: 'owner', roleColor: '#e74c3c', status: 'online' },
|
||
{ id: 2, username: 'alex', avatar: '#5865f2', role: 'admin', roleColor: '#f39c12', status: 'online' },
|
||
{ id: 3, username: 'jordan', avatar: '#2ecc71', role: 'moderator', roleColor: '#2ecc71', status: 'idle' },
|
||
{ id: 4, username: 'maya', avatar: '#e67e22', role: 'member', roleColor: '#949ba4', status: 'online' },
|
||
{ id: 5, username: 'ryan', avatar: '#1abc9c', role: 'member', roleColor: '#949ba4', status: 'dnd' },
|
||
{ id: 6, username: 'taylor', avatar: '#6d6f78', role: 'member', roleColor: '#949ba4', status: 'offline' },
|
||
],
|
||
voiceStates: [
|
||
{ channelId: 'v1', userId: 2, muted: false, speaking: true },
|
||
{ channelId: 'v1', userId: 3, muted: true, speaking: false },
|
||
],
|
||
},
|
||
{
|
||
id: 's1', name: 'Gaming Crew', icon: 'G', color: '#e74c3c',
|
||
channels: [
|
||
{ cat: 'Chat', items: [
|
||
{ id: 'gc1', name: 'lobby', type: 'text', topic: 'Find a squad', unread: 3 },
|
||
{ id: 'gc2', name: 'clips', type: 'text', topic: 'Post your highlights', unread: 0 },
|
||
]},
|
||
{ cat: 'Voice', items: [
|
||
{ id: 'gv1', name: 'Squad 1', type: 'voice' },
|
||
{ id: 'gv2', name: 'Squad 2', type: 'voice' },
|
||
]},
|
||
],
|
||
members: [
|
||
{ id: 10, username: 'FragMaster', avatar: '#e74c3c', role: 'owner', roleColor: '#e74c3c', status: 'online' },
|
||
{ id: 11, username: 'NoScope420', avatar: '#f39c12', role: 'member', roleColor: '#949ba4', status: 'online' },
|
||
{ id: 12, username: 'CampQueen', avatar: '#9b59b6', role: 'member', roleColor: '#949ba4', status: 'idle' },
|
||
],
|
||
voiceStates: [],
|
||
},
|
||
{
|
||
id: 's2', name: 'Dev Team', icon: 'D', color: '#2ecc71',
|
||
channels: [
|
||
{ cat: 'Engineering', items: [
|
||
{ id: 'dc1', name: 'backend', type: 'text', topic: 'Go, APIs, DB', unread: 0 },
|
||
{ id: 'dc2', name: 'frontend', type: 'text', topic: 'WPF, XAML, UI', unread: 2 },
|
||
{ id: 'dc3', name: 'ci-cd', type: 'text', topic: 'Builds and deploys', unread: 0 },
|
||
]},
|
||
{ cat: 'Voice', items: [
|
||
{ id: 'dv1', name: 'Standup', type: 'voice' },
|
||
]},
|
||
],
|
||
members: [
|
||
{ id: 20, username: 'TechLead', avatar: '#2ecc71', role: 'owner', roleColor: '#e74c3c', status: 'online' },
|
||
{ id: 21, username: 'BackendBob', avatar: '#3498db', role: 'admin', roleColor: '#f39c12', status: 'online' },
|
||
],
|
||
voiceStates: [],
|
||
},
|
||
];
|
||
|
||
// Messages per channel
|
||
const MESSAGES = {
|
||
c1: [
|
||
{ id: 'm1', userId: 1, text: 'Hey everyone! Just pushed a server update. Voice quality should be much better now.', time: '10:15 AM', reactions: [{emoji:'👍',count:3,me:true},{emoji:'🎉',count:2,me:false}] },
|
||
{ id: 'm2', userId: 2, text: 'Awesome! Noticed the latency drop right away. What changed?', time: '10:18 AM', replyTo: 'm1' },
|
||
{ id: 'm3', userId: 1, text: 'Switched from full forwarding to top-N speaker selection. Here\'s the gist:', time: '10:20 AM', code: '// SFU only forwards top 3 speakers\nfunc (s *SFU) selectSpeakers(room *Room) []Peer {\n speakers := room.RankedBySpeakLevel()\n return speakers[:min(3, len(speakers))]\n}', reactions: [{emoji:'🔥',count:1,me:false}] },
|
||
{ id: 'm4', userId: 1, text: 'Bitrate stays at 64kbps Opus but we\'re only forwarding relevant streams now', time: '10:20 AM', grouped: true },
|
||
{ id: 'm5', userId: 3, text: 'Here\'s the benchmark results from last night:', time: '10:25 AM', edited: true, file: { name: 'benchmark-results-2026-03.pdf', size: '2.4 MB' } },
|
||
{ id: 'm6', userId: 4, text: 'Check out this setup 😍', time: '10:30 AM', image: true, reactions: [{emoji:'😍',count:4,me:true},{emoji:'👍',count:2,me:false},{emoji:'🏆',count:1,me:false}] },
|
||
{ id: 'm7', userId: 0, text: '', time: '10:32 AM', system: true, systemText: '<strong>newuser</strong> just joined the server. Welcome!' },
|
||
],
|
||
c2: [
|
||
{ id: 'a1', userId: 1, text: '📢 Server will be going down for maintenance tonight at 11 PM EST.', time: '9:00 AM' },
|
||
{ id: 'a2', userId: 1, text: '📢 New voice optimization is live! Try it out in any voice channel.', time: '10:00 AM', reactions: [{emoji:'🎉',count:5,me:true}] },
|
||
],
|
||
c3: [
|
||
{ id: 'me1', userId: 4, text: 'When the code compiles on the first try 🎉', time: '9:30 AM', reactions: [{emoji:'😂',count:3,me:false}] },
|
||
{ id: 'me2', userId: 5, text: 'Impossible. That never happens.', time: '9:31 AM' },
|
||
],
|
||
c4: [
|
||
{ id: 'd1', userId: 2, text: 'Anyone looked at the new Pion v4 API?', time: '8:00 AM' },
|
||
{ id: 'd2', userId: 3, text: 'Yeah, the `MediaEngine` config is much cleaner now.', time: '8:05 AM' },
|
||
{ id: 'd3', userId: 1, text: 'I updated our SFU to use it. Check the PR.', time: '8:10 AM' },
|
||
],
|
||
gc1: [
|
||
{ id: 'g1', userId: 10, text: 'Who\'s up for ranked tonight?', time: '7:00 PM' },
|
||
{ id: 'g2', userId: 11, text: 'I\'m in! Let me finish this match first.', time: '7:02 PM' },
|
||
{ id: 'g3', userId: 12, text: 'Count me in after dinner', time: '7:05 PM' },
|
||
],
|
||
gc2: [
|
||
{ id: 'gc2m1', userId: 11, text: 'Check this headshot 🎯', time: '6:00 PM', image: true },
|
||
],
|
||
dc1: [
|
||
{ id: 'b1', userId: 20, text: 'Sprint planning at 10 AM tomorrow. Please update your tickets.', time: '3:00 PM' },
|
||
{ id: 'b2', userId: 21, text: 'Working on the voice state migration. Should be done by EOD.', time: '3:15 PM' },
|
||
],
|
||
dc2: [
|
||
{ id: 'f1', userId: 20, text: 'The new channel sidebar design looks great!', time: '2:00 PM' },
|
||
{ id: 'f2', userId: 21, text: 'Thanks! Need to add the voice user indicators still.', time: '2:05 PM' },
|
||
],
|
||
dc3: [
|
||
{ id: 'ci1', userId: 20, text: 'Build pipeline updated. Now runs tests in parallel.', time: '1:00 PM' },
|
||
],
|
||
};
|
||
|
||
const EMOJI_DATA = {
|
||
'Smileys': ['😀','😂','😍','🤣','😎','🤔','😢','😡','🥳','😴','🤯','🥺','😏','🙃','😬','🤗','😇','🤩','😤','🫠'],
|
||
'Gestures': ['👍','👎','👏','🙌','🤝','✌️','🤞','🫡','💪','🫶','👋','🤙','👊','🖐️','☝️','👆'],
|
||
'Objects': ['🔥','❤️','⭐','💯','🎉','🏆','💡','🎯','🚀','💎','🎮','🎵','📌','🔗','⚡','🛠️'],
|
||
'Nature': ['🌈','🌊','🌸','🍕','🍺','☕','🌙','⛈️','🐱','🐶','🦊','🐸','🌿','🍀'],
|
||
};
|
||
|
||
// ═══ Friends & DM Data ═══
|
||
|
||
const FRIENDS = [
|
||
{ id: 101, username: 'alex', avatar: '#5865f2', status: 'online', about: 'Full-stack dev. Always coding something.', mutualServers: ['OwnCord','Dev Team'] },
|
||
{ id: 102, username: 'jordan', avatar: '#2ecc71', status: 'idle', about: 'Gaming enthusiast and moderator.', mutualServers: ['OwnCord'] },
|
||
{ id: 103, username: 'maya', avatar: '#e67e22', status: 'online', about: 'UI/UX designer. Coffee addict ☕', mutualServers: ['OwnCord'] },
|
||
{ id: 104, username: 'ryan', avatar: '#1abc9c', status: 'dnd', about: 'Backend engineer. Do not disturb = actually coding.', mutualServers: ['OwnCord'] },
|
||
{ id: 105, username: 'taylor', avatar: '#6d6f78', status: 'offline', about: 'Casual gamer. Will be back soon.', mutualServers: ['OwnCord'] },
|
||
{ id: 106, username: 'FragMaster', avatar: '#e74c3c', status: 'online', about: 'Competitive FPS player. Always down for a match.', mutualServers: ['Gaming Crew'] },
|
||
{ id: 107, username: 'TechLead', avatar: '#2ecc71', status: 'online', about: 'Engineering manager. Ask me about Go.', mutualServers: ['Dev Team'] },
|
||
];
|
||
|
||
// DM conversations — open DMs in sidebar
|
||
const DM_CONVERSATIONS = [
|
||
{ friendId: 101, unread: 2, lastMsg: 'Check out the new SFU code!' },
|
||
{ friendId: 103, unread: 0, lastMsg: 'The mockup looks great 👍' },
|
||
{ friendId: 106, unread: 1, lastMsg: 'Ranked tonight?' },
|
||
];
|
||
|
||
// DM messages keyed by friend id
|
||
const DM_MESSAGES = {
|
||
101: [
|
||
{ id: 'dm1', userId: 101, text: 'Hey, did you see the voice optimization PR?', time: '9:00 AM' },
|
||
{ id: 'dm2', userId: 0, text: 'Yeah! The speaker detection is really clean.', time: '9:05 AM' },
|
||
{ id: 'dm3', userId: 101, text: 'Thanks! Spent a while on the ring buffer averaging.', time: '9:06 AM' },
|
||
{ id: 'dm4', userId: 101, text: 'Check out the new SFU code!', time: '9:10 AM', grouped: true },
|
||
],
|
||
103: [
|
||
{ id: 'dm5', userId: 0, text: 'What do you think of the channel sidebar design?', time: '2:00 PM' },
|
||
{ id: 'dm6', userId: 103, text: 'I love it! The voice users inline is a nice touch.', time: '2:05 PM' },
|
||
{ id: 'dm7', userId: 103, text: 'The mockup looks great 👍', time: '2:06 PM', grouped: true },
|
||
],
|
||
106: [
|
||
{ id: 'dm8', userId: 106, text: 'You free tonight for some ranked?', time: '6:30 PM' },
|
||
{ id: 'dm9', userId: 0, text: 'Maybe after 9, finishing some work first.', time: '6:35 PM' },
|
||
{ id: 'dm10', userId: 106, text: 'Ranked tonight?', time: '7:00 PM' },
|
||
],
|
||
};
|
||
|
||
const PENDING_REQUESTS = [
|
||
{ id: 108, username: 'NewPlayer42', avatar: '#9b59b6', status: 'online', incoming: true },
|
||
];
|
||
|
||
// ═══════════════════════════════════════════
|
||
// STATE
|
||
// ═══════════════════════════════════════════
|
||
|
||
let state = {
|
||
activeServer: 's0',
|
||
activeChannel: 'c1',
|
||
isMuted: false,
|
||
isDeafened: false,
|
||
voiceChannel: null,
|
||
memberListVisible: true,
|
||
replyingTo: null,
|
||
selfStatus: 'online',
|
||
emojiPickerOpen: false,
|
||
statusPickerOpen: false,
|
||
settingsOpen: false,
|
||
settingsPage: 'account',
|
||
// DM/Friends state
|
||
homeView: 'friends', // 'friends' or 'dm'
|
||
friendsTab: 'online', // 'online','all','pending','add'
|
||
activeDmFriend: null, // friend id when in a DM conversation
|
||
dmProfileVisible: true,
|
||
};
|
||
|
||
// ═══════════════════════════════════════════
|
||
// RENDER FUNCTIONS
|
||
// ═══════════════════════════════════════════
|
||
|
||
function getServer() { return SERVERS.find(s => s.id === state.activeServer); }
|
||
function getChannel() {
|
||
const srv = getServer();
|
||
for (const cat of srv.channels) for (const ch of cat.items) if (ch.id === state.activeChannel) return ch;
|
||
return null;
|
||
}
|
||
function getMember(userId, srv) {
|
||
if (userId === 0) return SELF;
|
||
// Check friends list first (for DMs)
|
||
const friend = FRIENDS.find(f => f.id === userId);
|
||
if (friend) return friend;
|
||
srv = srv || getServer();
|
||
return srv.members.find(m => m.id === userId) || SELF;
|
||
}
|
||
function isHomeServer() { return state.activeServer === 'home'; }
|
||
function getFriend(id) { return FRIENDS.find(f => f.id === id); }
|
||
function statusColor(s) { return {online:'var(--green)',idle:'var(--yellow)',dnd:'var(--red)',offline:'var(--text-micro)'}[s]||'var(--text-micro)'; }
|
||
function statusLabel(s) { return {online:'Online',idle:'Idle',dnd:'Do Not Disturb',offline:'Offline'}[s]||'Online'; }
|
||
|
||
function renderServerStrip() {
|
||
const el = document.getElementById('serverStrip');
|
||
const totalDmUnread = DM_CONVERSATIONS.reduce((s,d) => s + (d.unread||0), 0);
|
||
let html = '';
|
||
// Home icon (Friends/DMs)
|
||
html += `<div class="server-icon ${state.activeServer==='home'?'active':''}" style="background:${state.activeServer==='home'?'var(--accent)':'var(--bg-primary)'};color:${state.activeServer==='home'?'white':'var(--accent)'};font-size:24px;" onclick="switchServer('home')" title="Direct Messages">O<span class="badge">${totalDmUnread||''}</span></div>`;
|
||
html += '<div class="server-separator"></div>';
|
||
// Servers
|
||
SERVERS.forEach(s => {
|
||
const totalUnread = s.channels.reduce((sum,cat) => sum + cat.items.reduce((s2,ch) => s2 + (ch.unread||0), 0), 0);
|
||
html += `<div class="server-icon ${s.id===state.activeServer?'active':''}" style="background:${s.color};" onclick="switchServer('${s.id}')" title="${s.name}">${s.icon}<span class="badge">${totalUnread||''}</span></div>`;
|
||
});
|
||
html += '<div class="server-separator"></div>';
|
||
html += '<div class="server-icon add" onclick="showToast(\'Add server dialog would open here\')" title="Add Server">+</div>';
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function renderChannelSidebar() {
|
||
if (isHomeServer()) { renderHomeSidebar(); return; }
|
||
const srv = getServer();
|
||
document.getElementById('serverName').textContent = srv.name;
|
||
const el = document.getElementById('channelList');
|
||
let html = '';
|
||
srv.channels.forEach(cat => {
|
||
html += `<div class="category" onclick="this.classList.toggle('collapsed')"><span class="category-arrow">▼</span><span class="category-name">${cat.cat}</span></div>`;
|
||
html += '<div class="category-channels">';
|
||
cat.items.forEach(ch => {
|
||
const isActive = ch.id === state.activeChannel;
|
||
const isUnread = ch.unread > 0;
|
||
const icon = ch.type === 'voice' ? '🔊' : '#';
|
||
const iconStyle = ch.type === 'voice' ? 'font-size:14px;' : 'font-size:18px;';
|
||
html += `<div class="channel-item ${isActive?'active':''} ${isUnread?'unread':''}" onclick="${ch.type==='text'?`switchChannel('${ch.id}')`:`joinVoice('${ch.id}')`}">`;
|
||
html += `<span class="ch-icon" style="${iconStyle}">${icon}</span>`;
|
||
html += `<span class="ch-name">${ch.name}</span>`;
|
||
if (isUnread) html += `<span class="unread-badge">${ch.unread}</span>`;
|
||
html += '</div>';
|
||
// Voice users
|
||
if (ch.type === 'voice') {
|
||
const voiceUsers = srv.voiceStates.filter(vs => vs.channelId === ch.id);
|
||
if (state.voiceChannel === ch.id) voiceUsers.push({ channelId: ch.id, userId: 0, muted: state.isMuted, speaking: false });
|
||
if (voiceUsers.length > 0) {
|
||
html += '<div class="voice-users-list">';
|
||
voiceUsers.forEach(vs => {
|
||
const m = getMember(vs.userId);
|
||
html += `<div class="voice-user-item ${vs.speaking?'speaking':''}" onclick="showUserPopup(event, ${vs.userId})">`;
|
||
html += `<div class="vu-avatar" style="background:${m.avatar};">${m.username[0]}</div>`;
|
||
html += `<span>${m.username}</span>`;
|
||
if (vs.muted) html += '<span class="vu-muted">🔇</span>';
|
||
html += '</div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
}
|
||
});
|
||
html += '</div>';
|
||
});
|
||
el.innerHTML = html;
|
||
|
||
// Voice widget
|
||
const vw = document.getElementById('voiceWidget');
|
||
if (state.voiceChannel) {
|
||
vw.classList.add('visible');
|
||
const vCh = srv.channels.flatMap(c => c.items).find(c => c.id === state.voiceChannel);
|
||
document.getElementById('vwChannel').textContent = '/ ' + (vCh ? vCh.name : '');
|
||
document.getElementById('vwMute').classList.toggle('active-ctrl', state.isMuted);
|
||
document.getElementById('vwDeafen').classList.toggle('active-ctrl', state.isDeafened);
|
||
} else {
|
||
vw.classList.remove('visible');
|
||
}
|
||
|
||
// User bar
|
||
document.getElementById('ubAvatar').style.background = SELF.avatar;
|
||
document.getElementById('ubAvatar').innerHTML = SELF.username[0] + `<div class="status-dot" style="background:${statusColor(state.selfStatus)};"></div>`;
|
||
document.getElementById('ubName').textContent = SELF.username;
|
||
document.getElementById('ubStatus').textContent = statusLabel(state.selfStatus);
|
||
document.getElementById('ubMuteBtn').classList.toggle('active-ctrl', state.isMuted);
|
||
document.getElementById('ubDeafenBtn').classList.toggle('active-ctrl', state.isDeafened);
|
||
}
|
||
|
||
function renderHomeSidebar() {
|
||
document.getElementById('serverName').textContent = 'Direct Messages';
|
||
const el = document.getElementById('channelList');
|
||
let html = '';
|
||
// Search
|
||
html += '<div class="dm-sidebar-header"><input class="dm-search" type="text" placeholder="Find or start a conversation"></div>';
|
||
// Nav items
|
||
html += `<div class="dm-nav-item ${!state.activeDmFriend?'active':''}" onclick="showFriends()"><span class="dm-nav-icon">👥</span>Friends</div>`;
|
||
// DM section label
|
||
html += '<div class="dm-section-label">Direct Messages<button class="dm-add" title="New DM" onclick="showToast(\'New DM dialog\')">+</button></div>';
|
||
// DM conversations
|
||
DM_CONVERSATIONS.forEach(dm => {
|
||
const f = getFriend(dm.friendId);
|
||
if (!f) return;
|
||
const isActive = state.activeDmFriend === dm.friendId;
|
||
html += `<div class="dm-item ${isActive?'active':''}" onclick="openDm(${dm.friendId})">`;
|
||
html += `<div class="dm-avatar" style="background:${f.avatar};">${f.username[0]}<div class="dm-status" style="background:${statusColor(f.status)};"></div></div>`;
|
||
html += `<span class="dm-name">${f.username}</span>`;
|
||
if (dm.unread > 0) html += '<div class="dm-unread"></div>';
|
||
html += `<button class="dm-close" onclick="event.stopPropagation();closeDm(${dm.friendId})">×</button>`;
|
||
html += '</div>';
|
||
});
|
||
el.innerHTML = html;
|
||
// Hide voice widget when on Home
|
||
document.getElementById('voiceWidget').classList.remove('visible');
|
||
// User bar still renders normally
|
||
document.getElementById('ubAvatar').style.background = SELF.avatar;
|
||
document.getElementById('ubAvatar').innerHTML = SELF.username[0] + `<div class="status-dot" style="background:${statusColor(state.selfStatus)};"></div>`;
|
||
document.getElementById('ubName').textContent = SELF.username;
|
||
document.getElementById('ubStatus').textContent = statusLabel(state.selfStatus);
|
||
document.getElementById('ubMuteBtn').classList.toggle('active-ctrl', state.isMuted);
|
||
document.getElementById('ubDeafenBtn').classList.toggle('active-ctrl', state.isDeafened);
|
||
}
|
||
|
||
function renderMessages() {
|
||
if (isHomeServer() && state.activeDmFriend) { renderDmMessages(); return; }
|
||
if (isHomeServer()) { renderFriendsView(); return; }
|
||
const ch = getChannel();
|
||
document.getElementById('chatChannelName').textContent = ch ? ch.name : '';
|
||
document.getElementById('chatChannelTopic').textContent = ch ? (ch.topic||'') : '';
|
||
document.getElementById('msgInput').placeholder = ch ? `Message #${ch.name}` : '';
|
||
|
||
const msgs = MESSAGES[state.activeChannel] || [];
|
||
const srv = getServer();
|
||
const container = document.getElementById('messagesContainer');
|
||
let html = '<div class="msg-day-divider"><div class="line"></div><span class="date">March 15, 2026</span><div class="line"></div></div>';
|
||
|
||
msgs.forEach((msg, i) => {
|
||
if (msg.system) {
|
||
html += `<div class="system-msg"><span class="sm-icon">→</span><span class="sm-text">${msg.systemText}</span><span class="sm-time">Today at ${msg.time}</span></div>`;
|
||
return;
|
||
}
|
||
const m = getMember(msg.userId, srv);
|
||
const isGrouped = msg.grouped;
|
||
|
||
// Reply reference
|
||
if (msg.replyTo) {
|
||
const orig = msgs.find(x => x.id === msg.replyTo);
|
||
if (orig) {
|
||
const origUser = getMember(orig.userId, srv);
|
||
html += `<div class="msg-reply-ref" style="margin-left:36px;"><span class="rr-author" style="color:${origUser.roleColor||'var(--text-normal)'};">${origUser.username}</span><span class="rr-text">${orig.text.substring(0,60)}${orig.text.length>60?'...':''}</span></div>`;
|
||
}
|
||
}
|
||
|
||
html += `<div class="message ${isGrouped?'grouped':''}" data-msg-id="${msg.id}">`;
|
||
if (!isGrouped) {
|
||
html += `<div class="msg-avatar" style="background:${m.avatar};" onclick="showUserPopup(event, ${msg.userId})">${m.username[0]}</div>`;
|
||
html += '<div class="msg-header">';
|
||
html += `<span class="msg-author" style="color:${m.roleColor||'var(--text-normal)'};" onclick="showUserPopup(event, ${msg.userId})">${m.username}</span>`;
|
||
html += `<span class="msg-time">Today at ${msg.time}</span>`;
|
||
if (msg.edited) html += '<span class="msg-edited">(edited)</span>';
|
||
html += '</div>';
|
||
} else {
|
||
html += `<span class="msg-hover-time">${msg.time.replace(/ (AM|PM)/,'')}</span>`;
|
||
}
|
||
if (msg.text) html += `<div class="msg-text">${escapeHtml(msg.text)}</div>`;
|
||
if (msg.code) html += `<div class="msg-codeblock">${escapeHtml(msg.code)}</div>`;
|
||
if (msg.file) {
|
||
html += `<div class="msg-file"><div class="msg-file-inner"><div class="msg-file-icon">📄</div><div><div class="msg-file-name">${msg.file.name}</div><div class="msg-file-size">${msg.file.size}</div></div></div></div>`;
|
||
}
|
||
if (msg.image) {
|
||
html += '<div class="msg-image"><div class="placeholder-img">[image preview]</div></div>';
|
||
}
|
||
if (msg.reactions && msg.reactions.length) {
|
||
html += '<div class="msg-reactions">';
|
||
msg.reactions.forEach(r => {
|
||
html += `<div class="reaction-chip ${r.me?'me':''}" onclick="toggleReaction('${msg.id}','${r.emoji}')">${r.emoji}<span class="rc-count">${r.count}</span></div>`;
|
||
});
|
||
html += `<div class="reaction-chip add-reaction" onclick="showEmojiForReaction(event,'${msg.id}')">+</div>`;
|
||
html += '</div>';
|
||
}
|
||
// Hover actions
|
||
html += '<div class="msg-actions-bar">';
|
||
html += `<button title="React" onclick="showEmojiForReaction(event,'${msg.id}')">😊</button>`;
|
||
html += `<button title="Reply" onclick="startReply('${msg.id}')">↩</button>`;
|
||
if (msg.userId === 0) html += '<button title="Edit">✎</button>';
|
||
html += '<button title="More">⋯</button>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
});
|
||
|
||
container.innerHTML = html;
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
function renderMemberList() {
|
||
const srv = getServer();
|
||
const el = document.getElementById('memberList');
|
||
if (!state.memberListVisible) { el.classList.add('hidden'); return; }
|
||
el.classList.remove('hidden');
|
||
|
||
const roleOrder = ['owner','admin','moderator','member'];
|
||
const roleLabels = { owner:'Owner', admin:'Admin', moderator:'Moderator', member:'Members' };
|
||
let html = '';
|
||
roleOrder.forEach(role => {
|
||
const group = srv.members.filter(m => m.role === role);
|
||
if (!group.length) return;
|
||
const online = group.filter(m => m.status !== 'offline');
|
||
const offline = group.filter(m => m.status === 'offline');
|
||
html += `<div class="member-role-group">${roleLabels[role]} — ${group.length}</div>`;
|
||
[...online, ...offline].forEach(m => {
|
||
html += `<div class="member-item ${m.status==='offline'?'offline':''}" onclick="showUserPopup(event, ${m.id})">`;
|
||
html += `<div class="mi-avatar" style="background:${m.avatar};">${m.username[0]}<div class="mi-status" style="background:${statusColor(m.status)};"></div></div>`;
|
||
html += `<span class="mi-name" style="color:${m.roleColor};">${m.username}</span>`;
|
||
html += '</div>';
|
||
});
|
||
});
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
// ═══ Friends & DM Rendering ═══
|
||
|
||
function renderFriendsView() {
|
||
// Hide normal chat area, show friends view
|
||
document.getElementById('chatArea').style.display = 'none';
|
||
document.getElementById('memberList').style.display = 'none';
|
||
document.getElementById('dmProfile').classList.add('hidden');
|
||
const fv = document.getElementById('friendsView');
|
||
fv.style.display = 'flex';
|
||
|
||
let html = '';
|
||
// Header with tabs
|
||
html += '<div class="friends-header">';
|
||
html += '<div class="fh-title"><span class="fh-icon">👥</span>Friends</div>';
|
||
html += '<div class="fh-divider"></div>';
|
||
['online','all','pending'].forEach(tab => {
|
||
const label = tab.charAt(0).toUpperCase() + tab.slice(1);
|
||
const count = tab === 'pending' && PENDING_REQUESTS.length ? ` (${PENDING_REQUESTS.length})` : '';
|
||
html += `<button class="friends-tab ${state.friendsTab===tab?'active':''}" onclick="switchFriendsTab('${tab}')">${label}${count}</button>`;
|
||
});
|
||
html += '<button class="friends-tab add-friend" onclick="switchFriendsTab(\'add\')">Add Friend</button>';
|
||
html += '</div>';
|
||
|
||
if (state.friendsTab === 'add') {
|
||
html += '<div class="add-friend-form"><h2>Add Friend</h2><p>You can add a friend with their username.</p>';
|
||
html += '<div class="add-friend-input-row"><input class="add-friend-input" placeholder="Enter a username" id="addFriendInput"><button class="add-friend-submit" onclick="sendFriendRequest()">Send Friend Request</button></div></div>';
|
||
html += '<div class="friends-list"></div>';
|
||
} else {
|
||
html += '<div class="friends-list">';
|
||
html += '<div class="friends-search-bar"><input class="friends-search" placeholder="Search" id="friendsSearchInput" oninput="renderFriendsView()"></div>';
|
||
|
||
let filtered = FRIENDS;
|
||
const searchQ = document.getElementById('friendsSearchInput')?.value?.toLowerCase() || '';
|
||
if (searchQ) filtered = filtered.filter(f => f.username.toLowerCase().includes(searchQ));
|
||
|
||
if (state.friendsTab === 'online') filtered = filtered.filter(f => f.status !== 'offline');
|
||
if (state.friendsTab === 'pending') {
|
||
html += `<div class="friends-count">Pending — ${PENDING_REQUESTS.length}</div>`;
|
||
PENDING_REQUESTS.forEach(r => {
|
||
html += `<div class="friend-item">`;
|
||
html += `<div class="fi-avatar" style="background:${r.avatar};">${r.username[0]}<div class="fi-status-dot" style="background:${statusColor(r.status)};"></div></div>`;
|
||
html += `<div class="fi-info"><div class="fi-name">${r.username}</div><div class="fi-status-text">Incoming Friend Request</div></div>`;
|
||
html += '<div class="fi-actions" style="opacity:1;">';
|
||
html += `<button class="fi-action-btn" title="Accept" onclick="acceptFriendRequest(${r.id})" style="color:var(--green);">✓</button>`;
|
||
html += `<button class="fi-action-btn" title="Decline" onclick="declineFriendRequest(${r.id})" style="color:var(--red);">✕</button>`;
|
||
html += '</div></div>';
|
||
});
|
||
filtered = []; // don't show regular friends in pending tab
|
||
}
|
||
|
||
const label = state.friendsTab === 'online' ? 'Online' : state.friendsTab === 'all' ? 'All Friends' : '';
|
||
if (label) html += `<div class="friends-count">${label} — ${filtered.length}</div>`;
|
||
|
||
filtered.forEach(f => {
|
||
html += `<div class="friend-item" onclick="openDm(${f.id})">`;
|
||
html += `<div class="fi-avatar" style="background:${f.avatar};">${f.username[0]}<div class="fi-status-dot" style="background:${statusColor(f.status)};"></div></div>`;
|
||
html += `<div class="fi-info"><div class="fi-name">${f.username}</div><div class="fi-status-text">${statusLabel(f.status)}</div></div>`;
|
||
html += '<div class="fi-actions">';
|
||
html += `<button class="fi-action-btn" title="Message" onclick="event.stopPropagation();openDm(${f.id})">💬</button>`;
|
||
html += `<button class="fi-action-btn" title="More" onclick="event.stopPropagation();showToast('Context menu')">⋯</button>`;
|
||
html += '</div></div>';
|
||
});
|
||
|
||
html += '</div>';
|
||
}
|
||
|
||
fv.innerHTML = html;
|
||
}
|
||
|
||
function renderDmMessages() {
|
||
// Show normal chat area elements but with DM content
|
||
document.getElementById('chatArea').style.display = 'flex';
|
||
document.getElementById('friendsView').style.display = 'none';
|
||
document.getElementById('memberList').style.display = 'none';
|
||
|
||
const f = getFriend(state.activeDmFriend);
|
||
if (!f) return;
|
||
|
||
// Update header to DM style
|
||
document.getElementById('chatChannelName').textContent = f.username;
|
||
document.getElementById('chatChannelTopic').textContent = '';
|
||
document.querySelector('.ch-hash').textContent = '@';
|
||
document.getElementById('msgInput').placeholder = `Message @${f.username}`;
|
||
|
||
// Render DM profile sidebar
|
||
renderDmProfile(f);
|
||
|
||
// Render messages
|
||
const msgs = DM_MESSAGES[state.activeDmFriend] || [];
|
||
const container = document.getElementById('messagesContainer');
|
||
let html = '';
|
||
|
||
// Welcome banner
|
||
html += '<div style="padding:16px 16px 24px;text-align:center;">';
|
||
html += `<div style="width:64px;height:64px;border-radius:50%;background:${f.avatar};display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:24px;color:white;margin-bottom:8px;">${f.username[0]}</div>`;
|
||
html += `<div style="font-size:20px;font-weight:700;color:white;">${f.username}</div>`;
|
||
html += `<div style="font-size:13px;color:var(--text-muted);margin-top:4px;">This is the beginning of your direct message history with <strong style="color:white;">${f.username}</strong>.</div>`;
|
||
html += '</div>';
|
||
|
||
html += '<div class="msg-day-divider"><div class="line"></div><span class="date">March 15, 2026</span><div class="line"></div></div>';
|
||
|
||
msgs.forEach(msg => {
|
||
if (msg.system) return;
|
||
const m = msg.userId === 0 ? SELF : f;
|
||
const isGrouped = msg.grouped;
|
||
|
||
html += `<div class="message ${isGrouped?'grouped':''}" data-msg-id="${msg.id}">`;
|
||
if (!isGrouped) {
|
||
html += `<div class="msg-avatar" style="background:${m.avatar};">${m.username[0]}</div>`;
|
||
html += '<div class="msg-header">';
|
||
html += `<span class="msg-author" style="color:var(--text-normal);">${m.username}</span>`;
|
||
html += `<span class="msg-time">Today at ${msg.time}</span>`;
|
||
html += '</div>';
|
||
} else {
|
||
html += `<span class="msg-hover-time">${msg.time.replace(/ (AM|PM)/,'')}</span>`;
|
||
}
|
||
if (msg.text) html += `<div class="msg-text">${escapeHtml(msg.text)}</div>`;
|
||
if (msg.reactions && msg.reactions.length) {
|
||
html += '<div class="msg-reactions">';
|
||
msg.reactions.forEach(r => {
|
||
html += `<div class="reaction-chip ${r.me?'me':''}" onclick="toggleDmReaction('${msg.id}','${r.emoji}')">${r.emoji}<span class="rc-count">${r.count}</span></div>`;
|
||
});
|
||
html += '</div>';
|
||
}
|
||
html += '<div class="msg-actions-bar">';
|
||
html += `<button title="React" onclick="showEmojiForReaction(event,'${msg.id}')">😊</button>`;
|
||
html += `<button title="Reply" onclick="startReply('${msg.id}')">↩</button>`;
|
||
html += '<button title="More">⋯</button>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
});
|
||
|
||
container.innerHTML = html;
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
function renderDmProfile(f) {
|
||
const dp = document.getElementById('dmProfile');
|
||
if (!state.dmProfileVisible) { dp.classList.add('hidden'); return; }
|
||
dp.classList.remove('hidden');
|
||
dp.style.width = '340px';
|
||
|
||
let html = '';
|
||
html += `<div class="dm-profile-banner" style="background:${f.avatar};"></div>`;
|
||
html += '<div class="dm-profile-body">';
|
||
html += `<div class="dm-profile-avatar" style="background:${f.avatar};">${f.username[0]}</div>`;
|
||
html += `<div class="dm-profile-name">${f.username}</div>`;
|
||
html += `<div class="dm-profile-role" style="color:${statusColor(f.status)};">${statusLabel(f.status)}</div>`;
|
||
|
||
if (f.about) {
|
||
html += '<div class="dm-profile-section">';
|
||
html += '<div class="dm-profile-section-title">About Me</div>';
|
||
html += `<div class="dm-profile-section-text">${f.about}</div>`;
|
||
html += '</div>';
|
||
}
|
||
|
||
html += '<div class="dm-profile-section">';
|
||
html += '<div class="dm-profile-section-title">Member Since</div>';
|
||
html += '<div class="dm-profile-section-text">March 1, 2026</div>';
|
||
html += '</div>';
|
||
|
||
if (f.mutualServers && f.mutualServers.length) {
|
||
html += '<div class="dm-profile-section">';
|
||
html += `<div class="dm-profile-section-title">Mutual Servers — ${f.mutualServers.length}</div>`;
|
||
f.mutualServers.forEach(sname => {
|
||
const srv = SERVERS.find(s => s.name === sname);
|
||
html += '<div class="dm-profile-mutual">';
|
||
html += `<div class="dpm-avatar" style="background:${srv?srv.color:'var(--accent)'};">${sname[0]}</div>`;
|
||
html += `<span class="dpm-name">${sname}</span>`;
|
||
html += '</div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
dp.innerHTML = html;
|
||
}
|
||
|
||
function renderStatusPicker() {
|
||
const el = document.getElementById('statusPicker');
|
||
const statuses = ['online','idle','dnd','offline'];
|
||
let html = '';
|
||
statuses.forEach(s => {
|
||
html += `<button class="status-option" onclick="setStatus('${s}')"><div class="so-dot" style="background:${statusColor(s)};"></div>${statusLabel(s)}</button>`;
|
||
});
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function renderEmojiPicker() {
|
||
const el = document.getElementById('emojiGrid');
|
||
let html = '';
|
||
Object.entries(EMOJI_DATA).forEach(([cat, emojis]) => {
|
||
html += `<div class="ep-category-label">${cat}</div>`;
|
||
html += '<div class="ep-grid">';
|
||
emojis.forEach(e => {
|
||
html += `<button class="ep-emoji" data-emoji="${e}" onclick="pickEmoji('${e}')">${e}</button>`;
|
||
});
|
||
html += '</div>';
|
||
});
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function renderAll() {
|
||
renderServerStrip();
|
||
renderChannelSidebar();
|
||
if (isHomeServer()) {
|
||
if (state.activeDmFriend) {
|
||
renderDmMessages();
|
||
} else {
|
||
renderFriendsView();
|
||
}
|
||
} else {
|
||
// Restore normal server view
|
||
document.getElementById('chatArea').style.display = 'flex';
|
||
document.getElementById('friendsView').style.display = 'none';
|
||
document.getElementById('dmProfile').classList.add('hidden');
|
||
document.getElementById('memberList').style.display = '';
|
||
document.querySelector('.ch-hash').textContent = '#';
|
||
renderMessages();
|
||
renderMemberList();
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// ACTIONS
|
||
// ═══════════════════════════════════════════
|
||
|
||
function switchServer(id) {
|
||
state.activeServer = id;
|
||
if (id === 'home') {
|
||
state.activeDmFriend = null;
|
||
state.homeView = 'friends';
|
||
} else {
|
||
state.activeDmFriend = null;
|
||
const srv = getServer();
|
||
const firstText = srv.channels.flatMap(c => c.items).find(ch => ch.type === 'text');
|
||
state.activeChannel = firstText ? firstText.id : '';
|
||
}
|
||
closePopups();
|
||
renderAll();
|
||
}
|
||
|
||
function switchChannel(id) {
|
||
// Clear unread on the channel we're switching to
|
||
const srv = getServer();
|
||
for (const cat of srv.channels) for (const ch of cat.items) if (ch.id === id) ch.unread = 0;
|
||
state.activeChannel = id;
|
||
state.replyingTo = null;
|
||
document.getElementById('replyBar').classList.remove('visible');
|
||
document.getElementById('inputBox').classList.remove('reply-mode');
|
||
closePopups();
|
||
renderAll();
|
||
}
|
||
|
||
function sendMessage() {
|
||
const input = document.getElementById('msgInput');
|
||
const text = input.value.trim();
|
||
if (!text) return;
|
||
|
||
const isDm = isHomeServer() && state.activeDmFriend;
|
||
|
||
const newMsg = {
|
||
id: 'msg_' + Date.now(),
|
||
userId: 0,
|
||
text: text,
|
||
time: new Date().toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }),
|
||
reactions: [],
|
||
};
|
||
|
||
if (isDm) {
|
||
if (!DM_MESSAGES[state.activeDmFriend]) DM_MESSAGES[state.activeDmFriend] = [];
|
||
const msgs = DM_MESSAGES[state.activeDmFriend];
|
||
const last = msgs[msgs.length - 1];
|
||
if (last && last.userId === 0) newMsg.grouped = true;
|
||
msgs.push(newMsg);
|
||
} else {
|
||
if (state.replyingTo) {
|
||
newMsg.replyTo = state.replyingTo;
|
||
cancelReply();
|
||
}
|
||
const msgs = MESSAGES[state.activeChannel] || [];
|
||
const last = msgs[msgs.length - 1];
|
||
if (last && last.userId === 0 && !last.system) newMsg.grouped = true;
|
||
if (!MESSAGES[state.activeChannel]) MESSAGES[state.activeChannel] = [];
|
||
MESSAGES[state.activeChannel].push(newMsg);
|
||
}
|
||
|
||
input.value = '';
|
||
renderMessages();
|
||
|
||
// Simulate typing response
|
||
setTimeout(() => simulateTyping(), 800);
|
||
}
|
||
|
||
function simulateTyping() {
|
||
const isDm = isHomeServer() && state.activeDmFriend;
|
||
let responder;
|
||
|
||
if (isDm) {
|
||
responder = FRIENDS.find(f => f.id === state.activeDmFriend);
|
||
} else {
|
||
const srv = getServer();
|
||
if (!srv) return;
|
||
responder = srv.members.find(m => m.status === 'online' && m.id !== 0);
|
||
}
|
||
if (!responder) return;
|
||
|
||
const bar = document.getElementById('typingBar');
|
||
bar.innerHTML = `<div class="typing-dots"><span></span><span></span><span></span></div><span><strong>${responder.username}</strong> is typing...</span>`;
|
||
|
||
setTimeout(() => {
|
||
bar.innerHTML = '';
|
||
const replies = [
|
||
'Nice!', 'Interesting, tell me more.', 'Got it 👍', 'Makes sense!',
|
||
'I was thinking the same thing.', 'Let me look into that.',
|
||
'Good point!', 'Agreed.', 'That\'s a great idea!',
|
||
];
|
||
const replyMsg = {
|
||
id: 'msg_' + Date.now(),
|
||
userId: responder.id,
|
||
text: replies[Math.floor(Math.random() * replies.length)],
|
||
time: new Date().toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }),
|
||
reactions: [],
|
||
};
|
||
|
||
if (isDm) {
|
||
if (!DM_MESSAGES[state.activeDmFriend]) DM_MESSAGES[state.activeDmFriend] = [];
|
||
DM_MESSAGES[state.activeDmFriend].push(replyMsg);
|
||
} else {
|
||
if (!MESSAGES[state.activeChannel]) MESSAGES[state.activeChannel] = [];
|
||
MESSAGES[state.activeChannel].push(replyMsg);
|
||
}
|
||
renderMessages();
|
||
}, 1500 + Math.random() * 1500);
|
||
}
|
||
|
||
function startReply(msgId) {
|
||
state.replyingTo = msgId;
|
||
const msgs = MESSAGES[state.activeChannel] || [];
|
||
const msg = msgs.find(m => m.id === msgId);
|
||
const srv = getServer();
|
||
const author = getMember(msg.userId, srv);
|
||
document.getElementById('replyToName').textContent = author.username;
|
||
document.getElementById('replyBar').classList.add('visible');
|
||
document.getElementById('inputBox').classList.add('reply-mode');
|
||
document.getElementById('msgInput').focus();
|
||
}
|
||
|
||
function cancelReply() {
|
||
state.replyingTo = null;
|
||
document.getElementById('replyBar').classList.remove('visible');
|
||
document.getElementById('inputBox').classList.remove('reply-mode');
|
||
}
|
||
|
||
function toggleReaction(msgId, emoji) {
|
||
const msgs = MESSAGES[state.activeChannel] || [];
|
||
const msg = msgs.find(m => m.id === msgId);
|
||
if (!msg) return;
|
||
if (!msg.reactions) msg.reactions = [];
|
||
const existing = msg.reactions.find(r => r.emoji === emoji);
|
||
if (existing) {
|
||
if (existing.me) { existing.count--; existing.me = false; if (existing.count <= 0) msg.reactions = msg.reactions.filter(r => r !== existing); }
|
||
else { existing.count++; existing.me = true; }
|
||
} else {
|
||
msg.reactions.push({ emoji, count: 1, me: true });
|
||
}
|
||
renderMessages();
|
||
}
|
||
|
||
function joinVoice(channelId) {
|
||
if (state.voiceChannel === channelId) return;
|
||
const srv = getServer();
|
||
// Remove self from old voice state
|
||
srv.voiceStates = srv.voiceStates.filter(vs => vs.userId !== 0);
|
||
state.voiceChannel = channelId;
|
||
const ch = srv.channels.flatMap(c => c.items).find(c => c.id === channelId);
|
||
showToast(`Joined voice: ${ch ? ch.name : channelId}`);
|
||
renderChannelSidebar();
|
||
}
|
||
|
||
function leaveVoice() {
|
||
const srv = getServer();
|
||
srv.voiceStates = srv.voiceStates.filter(vs => vs.userId !== 0);
|
||
state.voiceChannel = null;
|
||
state.isMuted = false;
|
||
state.isDeafened = false;
|
||
showToast('Disconnected from voice');
|
||
renderChannelSidebar();
|
||
}
|
||
|
||
function toggleMute() {
|
||
state.isMuted = !state.isMuted;
|
||
if (state.isMuted) showToast('Muted'); else showToast('Unmuted');
|
||
renderChannelSidebar();
|
||
}
|
||
|
||
function toggleDeafen() {
|
||
state.isDeafened = !state.isDeafened;
|
||
if (state.isDeafened) { state.isMuted = true; showToast('Deafened'); }
|
||
else { showToast('Undeafened'); }
|
||
renderChannelSidebar();
|
||
}
|
||
|
||
function toggleMembers() {
|
||
if (isHomeServer() && state.activeDmFriend) {
|
||
state.dmProfileVisible = !state.dmProfileVisible;
|
||
document.getElementById('toggleMembersBtn').classList.toggle('active', state.dmProfileVisible);
|
||
renderDmProfile(getFriend(state.activeDmFriend));
|
||
return;
|
||
}
|
||
state.memberListVisible = !state.memberListVisible;
|
||
document.getElementById('toggleMembersBtn').classList.toggle('active', state.memberListVisible);
|
||
renderMemberList();
|
||
}
|
||
|
||
// ═══ DM / Friends Actions ═══
|
||
|
||
function showFriends() {
|
||
state.activeDmFriend = null;
|
||
state.homeView = 'friends';
|
||
renderAll();
|
||
}
|
||
|
||
function openDm(friendId) {
|
||
state.activeServer = 'home';
|
||
state.activeDmFriend = friendId;
|
||
state.homeView = 'dm';
|
||
// Ensure DM conversation exists
|
||
if (!DM_CONVERSATIONS.find(d => d.friendId === friendId)) {
|
||
DM_CONVERSATIONS.push({ friendId, unread: 0, lastMsg: '' });
|
||
}
|
||
// Clear unread
|
||
const dm = DM_CONVERSATIONS.find(d => d.friendId === friendId);
|
||
if (dm) dm.unread = 0;
|
||
// Ensure messages array exists
|
||
if (!DM_MESSAGES[friendId]) DM_MESSAGES[friendId] = [];
|
||
closePopups();
|
||
renderAll();
|
||
}
|
||
|
||
function closeDm(friendId) {
|
||
const idx = DM_CONVERSATIONS.findIndex(d => d.friendId === friendId);
|
||
if (idx !== -1) DM_CONVERSATIONS.splice(idx, 1);
|
||
if (state.activeDmFriend === friendId) {
|
||
state.activeDmFriend = null;
|
||
state.homeView = 'friends';
|
||
}
|
||
renderAll();
|
||
}
|
||
|
||
function switchFriendsTab(tab) {
|
||
state.friendsTab = tab;
|
||
renderFriendsView();
|
||
}
|
||
|
||
function sendFriendRequest() {
|
||
const input = document.getElementById('addFriendInput');
|
||
if (input && input.value.trim()) {
|
||
showToast(`Friend request sent to ${input.value.trim()}`);
|
||
input.value = '';
|
||
}
|
||
}
|
||
|
||
function acceptFriendRequest(id) {
|
||
const req = PENDING_REQUESTS.find(r => r.id === id);
|
||
if (req) {
|
||
FRIENDS.push({ ...req, about: 'New friend!', mutualServers: [] });
|
||
PENDING_REQUESTS.splice(PENDING_REQUESTS.indexOf(req), 1);
|
||
showToast(`${req.username} is now your friend!`);
|
||
renderFriendsView();
|
||
}
|
||
}
|
||
|
||
function declineFriendRequest(id) {
|
||
const idx = PENDING_REQUESTS.findIndex(r => r.id === id);
|
||
if (idx !== -1) {
|
||
const name = PENDING_REQUESTS[idx].username;
|
||
PENDING_REQUESTS.splice(idx, 1);
|
||
showToast(`Declined request from ${name}`);
|
||
renderFriendsView();
|
||
}
|
||
}
|
||
|
||
function dmFromPopup() {
|
||
const name = document.getElementById('upName').textContent;
|
||
// Find the friend or user by name
|
||
const friend = FRIENDS.find(f => f.username === name);
|
||
if (friend) {
|
||
openDm(friend.id);
|
||
} else {
|
||
// Not a friend yet, find in server members and add as DM-able
|
||
const srv = getServer();
|
||
const member = srv ? srv.members.find(m => m.username === name) : null;
|
||
if (member) {
|
||
// Create a temporary friend entry for DMing
|
||
if (!FRIENDS.find(f => f.username === name)) {
|
||
FRIENDS.push({ id: member.id + 1000, username: member.username, avatar: member.avatar, status: member.status, about: '', mutualServers: [srv.name] });
|
||
}
|
||
const f = FRIENDS.find(f2 => f2.username === name);
|
||
openDm(f.id);
|
||
} else {
|
||
showToast('Cannot message this user');
|
||
}
|
||
}
|
||
document.getElementById('userPopup').classList.remove('open');
|
||
}
|
||
|
||
function setStatus(s) {
|
||
state.selfStatus = s;
|
||
SELF.status = s;
|
||
toggleStatusPicker();
|
||
renderChannelSidebar();
|
||
showToast(`Status: ${statusLabel(s)}`);
|
||
}
|
||
|
||
function toggleStatusPicker() {
|
||
state.statusPickerOpen = !state.statusPickerOpen;
|
||
document.getElementById('statusPicker').classList.toggle('open', state.statusPickerOpen);
|
||
}
|
||
|
||
function showUserPopup(event, userId) {
|
||
event.stopPropagation();
|
||
const srv = getServer();
|
||
const m = getMember(userId, srv);
|
||
const popup = document.getElementById('userPopup');
|
||
document.getElementById('upBanner').style.background = m.avatar;
|
||
document.getElementById('upAvatar').style.background = m.avatar;
|
||
document.getElementById('upAvatar').textContent = m.username[0];
|
||
document.getElementById('upName').textContent = m.username;
|
||
document.getElementById('upRole').style.color = m.roleColor || 'var(--text-muted)';
|
||
document.getElementById('upRole').textContent = (m.role||'member').charAt(0).toUpperCase() + (m.role||'member').slice(1);
|
||
document.getElementById('upJoined').textContent = 'March 1, 2026';
|
||
document.getElementById('upRoles').innerHTML = `<div class="up-role-tag"><div class="up-role-dot" style="background:${m.roleColor||'var(--text-muted)'};"></div>${(m.role||'member').charAt(0).toUpperCase()+(m.role||'member').slice(1)}</div>`;
|
||
|
||
// Position near click
|
||
const rect = event.target.getBoundingClientRect();
|
||
let left = rect.right + 8;
|
||
let top = rect.top;
|
||
if (left + 300 > window.innerWidth) left = rect.left - 308;
|
||
if (top + 300 > window.innerHeight) top = window.innerHeight - 310;
|
||
popup.style.left = left + 'px';
|
||
popup.style.top = Math.max(8, top) + 'px';
|
||
popup.classList.add('open');
|
||
}
|
||
|
||
function toggleEmojiPicker(event) {
|
||
event && event.stopPropagation();
|
||
state.emojiPickerOpen = !state.emojiPickerOpen;
|
||
const picker = document.getElementById('emojiPicker');
|
||
picker.classList.toggle('open', state.emojiPickerOpen);
|
||
if (state.emojiPickerOpen) {
|
||
state._emojiTarget = null; // inserting into message input
|
||
const inputArea = document.getElementById('inputWrap');
|
||
const rect = inputArea.getBoundingClientRect();
|
||
picker.style.left = (rect.right - 330) + 'px';
|
||
picker.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
|
||
picker.style.top = 'auto';
|
||
document.getElementById('emojiSearch').value = '';
|
||
filterEmoji();
|
||
}
|
||
}
|
||
|
||
function showEmojiForReaction(event, msgId) {
|
||
event.stopPropagation();
|
||
state._emojiTarget = msgId;
|
||
state.emojiPickerOpen = true;
|
||
const picker = document.getElementById('emojiPicker');
|
||
picker.classList.add('open');
|
||
const rect = event.target.getBoundingClientRect();
|
||
picker.style.left = Math.min(rect.left, window.innerWidth - 330) + 'px';
|
||
picker.style.top = (rect.bottom + 8) + 'px';
|
||
picker.style.bottom = 'auto';
|
||
document.getElementById('emojiSearch').value = '';
|
||
filterEmoji();
|
||
}
|
||
|
||
function pickEmoji(emoji) {
|
||
if (state._emojiTarget) {
|
||
toggleReaction(state._emojiTarget, emoji);
|
||
} else {
|
||
const input = document.getElementById('msgInput');
|
||
input.value += emoji;
|
||
input.focus();
|
||
}
|
||
state.emojiPickerOpen = false;
|
||
document.getElementById('emojiPicker').classList.remove('open');
|
||
}
|
||
|
||
function filterEmoji() {
|
||
const q = document.getElementById('emojiSearch').value.toLowerCase();
|
||
document.querySelectorAll('.ep-emoji').forEach(el => {
|
||
el.style.display = q ? (el.dataset.emoji.includes(q) ? '' : 'none') : '';
|
||
});
|
||
}
|
||
|
||
// ═══ Settings ═══
|
||
|
||
const SETTINGS_NAV = [
|
||
{ cat: 'User Settings', items: [
|
||
{ id: 'account', icon: '👤', label: 'My Account' },
|
||
{ id: 'appearance', icon: '🎨', label: 'Appearance' },
|
||
{ id: 'notifications', icon: '🔔', label: 'Notifications' },
|
||
]},
|
||
{ cat: 'App Settings', items: [
|
||
{ id: 'voice', icon: '🎤', label: 'Voice & Audio' },
|
||
{ id: 'keybinds', icon: '⌨', label: 'Keybinds' },
|
||
]},
|
||
{ cat: 'About', items: [
|
||
{ id: 'info', icon: '🔰', label: 'Info' },
|
||
]},
|
||
];
|
||
|
||
function openSettings() {
|
||
state.settingsOpen = true;
|
||
state.settingsPage = 'account';
|
||
renderSettings();
|
||
document.getElementById('settingsOverlay').classList.add('open');
|
||
}
|
||
|
||
function closeSettings() {
|
||
state.settingsOpen = false;
|
||
document.getElementById('settingsOverlay').classList.remove('open');
|
||
}
|
||
|
||
function renderSettings() {
|
||
// Sidebar
|
||
let nav = '';
|
||
SETTINGS_NAV.forEach(cat => {
|
||
nav += `<div class="settings-cat">${cat.cat}</div>`;
|
||
cat.items.forEach(item => {
|
||
nav += `<button class="settings-nav-item ${state.settingsPage===item.id?'active':''}" onclick="switchSettingsPage('${item.id}')">${item.icon} ${item.label}</button>`;
|
||
});
|
||
nav += '<div class="settings-sep"></div>';
|
||
});
|
||
nav += '<button class="settings-nav-item danger" onclick="showToast(\'Logged out (simulated)\');closeSettings();">🚪 Log Out</button>';
|
||
document.getElementById('settingsSidebar').innerHTML = nav;
|
||
|
||
// Content
|
||
const content = document.getElementById('settingsContent');
|
||
let html = '';
|
||
|
||
if (state.settingsPage === 'account') {
|
||
html = `<h1>My Account</h1>
|
||
<div class="account-card"><div class="ac-avatar" style="background:${SELF.avatar};">${SELF.username[0]}</div><div><div class="ac-name">${SELF.username}</div><div class="ac-id">User ID: 1</div></div><button class="ac-btn" onclick="showToast('Edit profile dialog would open')">Edit Profile</button></div>
|
||
<h3>Password</h3><div class="setting-row"><div><div class="setting-label">Change Password</div><div class="setting-desc">You'll need your current password to set a new one.</div></div><button class="ac-btn" style="font-size:12px;padding:6px 12px;" onclick="showToast('Change password dialog would open')">Change</button></div>
|
||
<h3>Two-Factor Authentication</h3><div class="setting-row"><div><div class="setting-label">Enable 2FA</div><div class="setting-desc">Add an extra layer of security with TOTP.</div></div><div class="toggle" onclick="this.classList.toggle('on');showToast(this.classList.contains('on')?'2FA enabled (simulated)':'2FA disabled (simulated)')"></div></div>
|
||
<h3>Sessions</h3><div class="setting-row"><div><div class="setting-label">Active Sessions</div><div class="setting-desc">1 active session (this device).</div></div><button class="ac-btn" style="font-size:12px;padding:6px 12px;background:var(--red);" onclick="showToast('Would revoke all other sessions')">Revoke All</button></div>`;
|
||
}
|
||
else if (state.settingsPage === 'appearance') {
|
||
html = `<h1>Appearance</h1>
|
||
<h3>Theme</h3><div class="setting-row" style="flex-direction:column;align-items:flex-start;"><div class="setting-label">Color Theme</div><div class="theme-options"><div class="theme-opt dark active" onclick="selectTheme(this)">Dark</div><div class="theme-opt midnight" onclick="selectTheme(this)">Midnight</div><div class="theme-opt light" onclick="selectTheme(this)">Light</div></div></div>
|
||
<h3>Chat</h3>
|
||
<div class="setting-row"><div><div class="setting-label">Compact Mode</div><div class="setting-desc">Reduce spacing between messages.</div></div><div class="toggle" onclick="this.classList.toggle('on')"></div></div>
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;"><div class="setting-label">Font Size</div><div class="slider-row" style="margin-top:8px;"><input type="range" class="settings-slider" min="12" max="20" value="14" oninput="this.nextElementSibling.textContent=this.value+'px'"><span class="slider-val">14px</span></div></div>`;
|
||
}
|
||
else if (state.settingsPage === 'notifications') {
|
||
html = `<h1>Notifications</h1>
|
||
<h3>Desktop Notifications</h3>
|
||
<div class="setting-row"><div><div class="setting-label">Enable Desktop Notifications</div><div class="setting-desc">Show Windows toast notifications for new messages.</div></div><div class="toggle on" onclick="this.classList.toggle('on')"></div></div>
|
||
<div class="setting-row"><div><div class="setting-label">Notification Sounds</div><div class="setting-desc">Play a sound when you receive a notification.</div></div><div class="toggle on" onclick="this.classList.toggle('on')"></div></div>
|
||
<div class="setting-row"><div><div class="setting-label">Flash Taskbar</div><div class="setting-desc">Flash the taskbar icon for unread messages.</div></div><div class="toggle on" onclick="this.classList.toggle('on')"></div></div>
|
||
<h3>Muting</h3>
|
||
<div class="setting-row"><div><div class="setting-label">Suppress @everyone</div><div class="setting-desc">Don't notify for @everyone mentions.</div></div><div class="toggle" onclick="this.classList.toggle('on')"></div></div>`;
|
||
}
|
||
else if (state.settingsPage === 'voice') {
|
||
html = `<h1>Voice & Audio</h1>
|
||
<h3>Input</h3>
|
||
<div class="setting-row"><div><div class="setting-label">Input Device</div><div class="setting-desc">Microphone for voice chat.</div></div><select class="settings-select"><option>Default - Microphone (Realtek)</option><option>Headset Microphone (HyperX)</option></select></div>
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;"><div class="setting-label">Input Volume</div><div class="slider-row" style="margin-top:8px;"><input type="range" class="settings-slider" min="0" max="100" value="80" oninput="this.nextElementSibling.textContent=this.value+'%'"><span class="slider-val">80%</span></div></div>
|
||
<h3>Output</h3>
|
||
<div class="setting-row"><div><div class="setting-label">Output Device</div><div class="setting-desc">Speakers or headphones.</div></div><select class="settings-select"><option>Default - Speakers (Realtek)</option><option>Headphones (HyperX Cloud II)</option></select></div>
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;"><div class="setting-label">Output Volume</div><div class="slider-row" style="margin-top:8px;"><input type="range" class="settings-slider" min="0" max="100" value="100" oninput="this.nextElementSibling.textContent=this.value+'%'"><span class="slider-val">100%</span></div></div>
|
||
<h3>Processing</h3>
|
||
<div class="setting-row"><div><div class="setting-label">Noise Suppression</div><div class="setting-desc">Reduce background noise (RNNoise).</div></div><div class="toggle on" onclick="this.classList.toggle('on')"></div></div>
|
||
<div class="setting-row"><div><div class="setting-label">Voice Activity Detection</div><div class="setting-desc">Automatically detect when you're speaking.</div></div><div class="toggle on" onclick="this.classList.toggle('on')"></div></div>
|
||
<div class="setting-row" style="flex-direction:column;align-items:stretch;"><div class="setting-label">VAD Sensitivity</div><div class="slider-row" style="margin-top:8px;"><input type="range" class="settings-slider" min="0" max="100" value="60" oninput="this.nextElementSibling.textContent=this.value+'%'"><span class="slider-val">60%</span></div></div>
|
||
<div class="setting-row"><div><div class="setting-label">Echo Cancellation</div><div class="setting-desc">Prevent feedback loops from speakers.</div></div><div class="toggle on" onclick="this.classList.toggle('on')"></div></div>`;
|
||
}
|
||
else if (state.settingsPage === 'keybinds') {
|
||
html = `<h1>Keybinds</h1>
|
||
<div class="setting-desc" style="margin-bottom:16px;">Click a keybind to change it. Press Escape to cancel.</div>
|
||
<div class="keybind-row"><div class="setting-label">Push to Talk</div><div><span class="kbd">V</span></div></div>
|
||
<div class="keybind-row"><div class="setting-label">Toggle Mute</div><div><span class="kbd">Ctrl</span> <span style="color:var(--text-micro)">+</span> <span class="kbd">Shift</span> <span style="color:var(--text-micro)">+</span> <span class="kbd">M</span></div></div>
|
||
<div class="keybind-row"><div class="setting-label">Toggle Deafen</div><div><span class="kbd">Ctrl</span> <span style="color:var(--text-micro)">+</span> <span class="kbd">Shift</span> <span style="color:var(--text-micro)">+</span> <span class="kbd">D</span></div></div>
|
||
<div class="keybind-row"><div class="setting-label">Quick Switcher</div><div><span class="kbd">Ctrl</span> <span style="color:var(--text-micro)">+</span> <span class="kbd">K</span></div></div>
|
||
<div class="keybind-row"><div class="setting-label">Edit Last Message</div><div><span class="kbd">↑</span></div></div>
|
||
<div class="keybind-row"><div class="setting-label">Close Panel</div><div><span class="kbd">Esc</span></div></div>`;
|
||
}
|
||
else if (state.settingsPage === 'info') {
|
||
html = `<h1>About OwnCord</h1>
|
||
<div class="account-card" style="flex-direction:column;align-items:flex-start;gap:8px;">
|
||
<div style="font-size:24px;font-weight:700;color:white;">OwnCord</div>
|
||
<div style="color:var(--text-muted);">Version 1.0.0</div>
|
||
<div style="color:var(--text-muted);margin-top:8px;line-height:1.6;">
|
||
Self-hosted chat platform for friends.<br>
|
||
Server: Go + SQLite + Pion WebRTC<br>
|
||
Client: Native Windows (WPF)<br><br>
|
||
<span style="color:var(--text-link);cursor:pointer;">github.com/J3vb/OwnCord</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
content.innerHTML = html;
|
||
}
|
||
|
||
function switchSettingsPage(id) {
|
||
state.settingsPage = id;
|
||
renderSettings();
|
||
}
|
||
|
||
function selectTheme(el) {
|
||
el.parentElement.querySelectorAll('.theme-opt').forEach(o => o.classList.remove('active'));
|
||
el.classList.add('active');
|
||
showToast('Theme changed (visual only in prototype)');
|
||
}
|
||
|
||
// ═══ Utilities ═══
|
||
|
||
function escapeHtml(s) {
|
||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
function closePopups() {
|
||
document.getElementById('userPopup').classList.remove('open');
|
||
document.getElementById('emojiPicker').classList.remove('open');
|
||
document.getElementById('statusPicker').classList.remove('open');
|
||
state.emojiPickerOpen = false;
|
||
state.statusPickerOpen = false;
|
||
}
|
||
|
||
let _toastTimer;
|
||
function showToast(msg) {
|
||
const el = document.getElementById('toast');
|
||
el.textContent = msg;
|
||
el.classList.add('show');
|
||
clearTimeout(_toastTimer);
|
||
_toastTimer = setTimeout(() => el.classList.remove('show'), 2500);
|
||
}
|
||
|
||
// ═══ Event Listeners ═══
|
||
|
||
document.getElementById('msgInput').addEventListener('keydown', e => {
|
||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||
});
|
||
|
||
document.addEventListener('click', e => {
|
||
if (!e.target.closest('.user-popup') && !e.target.closest('.msg-avatar') && !e.target.closest('.member-item') && !e.target.closest('.voice-user-item') && !e.target.closest('.msg-author')) {
|
||
document.getElementById('userPopup').classList.remove('open');
|
||
}
|
||
if (!e.target.closest('.emoji-picker') && !e.target.closest('.input-btn') && !e.target.closest('.reaction-chip') && !e.target.closest('.msg-actions-bar')) {
|
||
document.getElementById('emojiPicker').classList.remove('open');
|
||
state.emojiPickerOpen = false;
|
||
}
|
||
if (!e.target.closest('.status-picker') && !e.target.closest('.ub-avatar')) {
|
||
document.getElementById('statusPicker').classList.remove('open');
|
||
state.statusPickerOpen = false;
|
||
}
|
||
});
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') {
|
||
if (state.settingsOpen) closeSettings();
|
||
else closePopups();
|
||
}
|
||
});
|
||
|
||
// ═══ INIT ═══
|
||
renderStatusPicker();
|
||
renderEmojiPicker();
|
||
renderAll();
|
||
document.getElementById('toggleMembersBtn').classList.add('active');
|
||
</script>
|
||
</body>
|
||
</html>
|