Files
OwnCord/Client/ui-mockup.html
T
jevb c1c25ed26c feat: implement full client UI from mockup — 10 phases, 331 tests
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)
2026-03-15 11:42:25 +01:00

2248 lines
100 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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()">&#127908;</button>
<button id="vwDeafen" title="Deafen" onclick="toggleDeafen()">&#127911;</button>
<button class="disconnect" title="Disconnect" onclick="leaveVoice()">&#9746;</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()">&#127908;</button>
<button id="ubDeafenBtn" title="Deafen" onclick="toggleDeafen()">&#127911;</button>
<button title="Settings" onclick="openSettings()">&#9881;</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">&#128204;</button>
<button id="toggleMembersBtn" title="Member List" onclick="toggleMembers()">&#128101;</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()">&#10005;</button>
</div>
</div>
<div class="message-input-wrap" id="inputWrap">
<div class="message-input-box" id="inputBox">
<button class="input-btn" title="Attach File">&#128206;</button>
<textarea class="msg-textarea" id="msgInput" rows="1" placeholder=""></textarea>
<button class="input-btn" title="Emoji" onclick="toggleEmojiPicker(event)">&#128578;</button>
<button class="input-btn" title="Send" style="color:var(--accent);" onclick="sendMessage()">&#10148;</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()">&#10005;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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>