<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Chat Hub</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #5865f2;
--primary-dark: #4752c4;
--secondary: #3ba55d;
--danger: #ed4245;
--warning: #fee75c;
--background: #0a0a0a;
--surface: #1a1a1a;
--surface-light: #2a2a2a;
--surface-hover: #3a3a3a;
--text: #e0e0e0;
--text-dim: #a0a0a0;
--text-muted: #6a6a6a;
--online: #3ba55d;
--idle: #faa61a;
--offline: #747f8d;
--border: #333;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
background: var(--background);
color: var(--text);
line-height: 1.6;
height: 100vh;
overflow: hidden;
}
/* Layout */
.app-container {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 240px;
background: var(--surface);
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
}
.sidebar-header {
padding: 1rem;
background: var(--surface-light);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.server-name {
font-weight: bold;
font-size: 1.1rem;
}
.sidebar-section {
padding: 0.5rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem;
color: var(--text-dim);
font-size: 0.85rem;
text-transform: uppercase;
font-weight: 600;
}
.section-header button {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 1.2rem;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.section-header button:hover {
background: var(--surface-hover);
color: var(--text);
}
/* Channel/DM List */
.channel-list {
flex: 1;
overflow-y: auto;
}
.channel-item,
.dm-item {
display: flex;
align-items: center;
padding: 0.5rem;
margin: 0.1rem 0.5rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.channel-item:hover,
.dm-item:hover {
background: var(--surface-hover);
}
.channel-item.active,
.dm-item.active {
background: var(--surface-hover);
color: var(--primary);
}
.channel-icon,
.dm-icon {
margin-right: 0.5rem;
color: var(--text-dim);
}
.channel-name,
.dm-name {
flex: 1;
}
.unread-badge {
background: var(--danger);
color: white;
padding: 0.1rem 0.4rem;
border-radius: 10px;
font-size: 0.75rem;
font-weight: bold;
position: absolute;
right: 0.5rem;
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
margin-left: 0.5rem;
}
.status-online {
background: var(--online);
}
.status-idle {
background: var(--idle);
}
.status-offline {
background: var(--offline);
}
/* User Profile Section */
.user-profile {
padding: 1rem;
background: var(--surface-light);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
margin-right: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.9rem;
color: white;
}
.user-info {
flex: 1;
}
.user-name {
font-weight: 600;
font-size: 0.9rem;
}
.user-status {
font-size: 0.75rem;
color: var(--text-dim);
}
.user-actions {
display: flex;
gap: 0.5rem;
}
.icon-btn {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 0.3rem;
border-radius: 4px;
transition: all 0.2s;
}
.icon-btn:hover {
background: var(--surface-hover);
color: var(--text);
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
}
/* Chat Header */
.chat-header {
padding: 1rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.chat-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.chat-title h2 {
font-size: 1.2rem;
}
.chat-title .channel-icon {
color: var(--text-dim);
}
.header-actions {
display: flex;
gap: 0.5rem;
}
/* Messages Area */
.messages-container {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
}
.message {
display: flex;
padding: 0.5rem 1rem;
margin-bottom: 0.5rem;
transition: background 0.2s;
border-radius: 4px;
}
.message:hover {
background: var(--surface);
}
.message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 1rem;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: white;
flex-shrink: 0;
}
.message-content {
flex: 1;
}
.message-header {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.message-author {
font-weight: 600;
color: var(--primary);
}
.message-timestamp {
font-size: 0.75rem;
color: var(--text-muted);
}
.message-text {
white-space: pre-wrap;
word-wrap: break-word;
}
.message-actions {
display: none;
position: absolute;
right: 1rem;
top: -0.5rem;
background: var(--surface-light);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.25rem;
}
.message:hover .message-actions {
display: flex;
gap: 0.25rem;
}
.typing-indicator {
padding: 0 1rem;
color: var(--text-dim);
font-size: 0.9rem;
font-style: italic;
}
/* Message Input */
.message-input-container {
padding: 1rem;
background: var(--surface);
border-top: 1px solid var(--border);
}
.message-input-wrapper {
display: flex;
background: var(--surface-light);
border-radius: 8px;
padding: 0.5rem;
}
.message-input {
flex: 1;
background: none;
border: none;
color: var(--text);
outline: none;
font-size: 1rem;
padding: 0.5rem;
resize: none;
max-height: 120px;
}
.message-input::placeholder {
color: var(--text-muted);
}
.input-actions {
display: flex;
align-items: flex-end;
gap: 0.5rem;
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--surface);
border-radius: 8px;
padding: 2rem;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.modal-title {
font-size: 1.5rem;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
color: var(--text-dim);
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.3s;
}
.close-btn:hover {
background: var(--surface-light);
color: var(--text);
}
/* Forms */
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-dim);
font-size: 0.9rem;
}
input,
textarea,
select {
width: 100%;
padding: 0.75rem;
background: var(--surface-light);
border: 1px solid transparent;
border-radius: 4px;
color: var(--text);
font-size: 1rem;
transition: border-color 0.3s;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--primary);
}
/* Buttons */
.btn {
background: var(--primary);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: all 0.3s;
text-decoration: none;
display: inline-block;
}
.btn:hover {
background: var(--primary-dark);
}
.btn-secondary {
background: var(--surface-light);
}
.btn-secondary:hover {
background: var(--surface-hover);
}
.btn-danger {
background: var(--danger);
}
.btn-success {
background: var(--secondary);
}
/* Search Results */
.search-results {
max-height: 300px;
overflow-y: auto;
margin-top: 1rem;
}
.search-result-item {
display: flex;
align-items: center;
padding: 0.75rem;
background: var(--surface-light);
border-radius: 4px;
margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.2s;
}
.search-result-item:hover {
background: var(--surface-hover);
}
/* Loading */
.loading {
text-align: center;
padding: 2rem;
color: var(--text-dim);
}
.spinner {
border: 3px solid var(--surface-light);
border-top: 3px solid var(--primary);
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.error-message {
color: var(--danger);
font-size: 0.9rem;
margin-top: 0.5rem;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
width: 60px;
}
.sidebar-header,
.section-header span,
.channel-name,
.dm-name,
.user-info {
display: none;
}
.channel-item,
.dm-item {
justify-content: center;
}
.channel-icon,
.dm-icon {
margin: 0;
}
.unread-badge {
position: absolute;
top: 0;
right: 0;
}
}
</style>
</head>
<body>
<div class="app-container">
<!-- Sidebar Component -->
<chat-sidebar></chat-sidebar>
<!-- Main Chat Area -->
<div class="main-content">
<chat-header></chat-header>
<chat-messages></chat-messages>
<chat-input></chat-input>
</div>
</div>
<!-- Modal Component -->
<chat-modal></chat-modal>
<script>
// Configuration
const API_URL = `${window.location.protocol}//${window.location.host}/api`;
const WS_URL = `${window.location.protocol.replace("http", "ws")}//${window.location.host}/ws`;
const currentUser = {{ current_user | tojson }};
// Global Event Bus
class EventBus {
constructor() {
this.events = {};
}
on(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
}
off(event, callback) {
if (this.events[event]) {
this.events[event] = this.events[event].filter(
(cb) => cb !== callback,
);
}
}
emit(event, data) {
if (this.events[event]) {
this.events[event].forEach((callback) =>
callback(data),
);
}
}
}
const eventBus = new EventBus();
// API Manager
class ApiManager {
async request(endpoint, options = {}) {
const headers = {
"Content-Type": "application/json",
...options.headers,
};
try {
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "Request failed");
}
return await response.json();
} catch (error) {
console.error("API Error:", error);
throw error;
}
}
get(endpoint) {
return this.request(endpoint);
}
post(endpoint, data) {
return this.request(endpoint, {
method: "POST",
body: JSON.stringify(data),
});
}
patch(endpoint, data) {
return this.request(endpoint, {
method: "PATCH",
body: JSON.stringify(data),
});
}
delete(endpoint) {
return this.request(endpoint, {
method: "DELETE",
});
}
}
const apiManager = new ApiManager();
// WebSocket Manager
class WebSocketManager {
constructor() {
this.ws = null;
this.reconnectInterval = null;
this.typingTimeout = null;
}
connect(userId) {
if (this.ws) {
this.ws.close();
}
this.ws = new WebSocket(`${WS_URL}/${userId}`);
this.ws.onopen = () => {
console.log("WebSocket connected");
clearInterval(this.reconnectInterval);
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleMessage(data);
};
this.ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
this.ws.onclose = () => {
console.log("WebSocket disconnected");
this.reconnect(userId);
};
}
reconnect(userId) {
this.reconnectInterval = setInterval(() => {
console.log("Attempting to reconnect WebSocket...");
this.connect(userId);
}, 5000);
}
handleMessage(data) {
switch (data.type) {
case "new_message":
eventBus.emit("new-message", data.data);
break;
case "typing":
eventBus.emit("typing-update", data.data);
break;
case "user_status":
eventBus.emit("user-status-update", data.data);
break;
}
}
sendTyping(channelId) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
// Debounce typing indicator
clearTimeout(this.typingTimeout);
this.ws.send(
JSON.stringify({
type: "typing",
channel_id: channelId,
}),
);
this.typingTimeout = setTimeout(() => {
this.ws.send(
JSON.stringify({
type: "stop_typing",
channel_id: channelId,
}),
);
}, 3000);
}
}
disconnect() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
clearInterval(this.reconnectInterval);
}
}
const wsManager = new WebSocketManager();
if (currentUser) {
wsManager.connect(currentUser.id);
}
// Chat Manager
class ChatManager {
constructor() {
this.channels = [];
this.dms = [];
this.activeChat = null;
this.messages = {};
this.typingUsers = {};
}
async loadChannels() {
try {
this.channels = await apiManager.get("/channels");
eventBus.emit("channels-loaded", this.channels);
// Set first channel as active if none selected
if (!this.activeChat && this.channels.length > 0) {
this.setActiveChat(this.channels[0].id, "channel");
}
} catch (error) {
console.error("Failed to load channels:", error);
}
}
async loadDMs() {
try {
this.dms = await apiManager.get("/dms");
eventBus.emit("dms-loaded", this.dms);
} catch (error) {
console.error("Failed to load DMs:", error);
}
}
async setActiveChat(chatId, type = "channel") {
this.activeChat = { id: chatId, type };
const chat =
type === "channel"
? this.channels.find((c) => c.id === chatId)
: this.dms.find((d) => d.id === chatId);
if (chat) {
chat.unread = 0;
eventBus.emit("chat-changed", chat);
await this.loadMessages(chatId);
}
}
async loadMessages(channelId) {
try {
const messages = await apiManager.get(
`/messages/${channelId}`,
);
this.messages[channelId] = messages;
eventBus.emit("messages-loaded", messages);
} catch (error) {
console.error("Failed to load messages:", error);
this.messages[channelId] = [];
eventBus.emit("messages-loaded", []);
}
}
async sendMessage(content) {
if (!currentUser || !this.activeChat) return;
try {
const message = await apiManager.post("/messages", {
content,
channel_id: this.activeChat.id,
});
// Add to local messages
if (!this.messages[this.activeChat.id]) {
this.messages[this.activeChat.id] = [];
}
this.messages[this.activeChat.id].push(message);
eventBus.emit("message-sent", message);
} catch (error) {
console.error("Failed to send message:", error);
}
}
async searchUsers(query) {
try {
return await apiManager.get(
`/users/search?q=${encodeURIComponent(query)}`,
);
} catch (error) {
console.error("Failed to search users:", error);
return [];
}
}
async startDM(userId) {
try {
const response = await apiManager.post("/dms", {
user_id: userId,
});
await this.loadDMs();
this.setActiveChat(response.channel_id, "dm");
} catch (error) {
console.error("Failed to start DM:", error);
}
}
async createChannel(name) {
try {
const channel = await apiManager.post("/channels", {
name,
});
await this.loadChannels();
this.setActiveChat(channel.id, "channel");
} catch (error) {
console.error("Failed to create channel:", error);
throw error;
}
}
addTypingUser(channelId, username) {
if (!this.typingUsers[channelId]) {
this.typingUsers[channelId] = new Set();
}
this.typingUsers[channelId].add(username);
eventBus.emit("typing-users-changed", {
channelId,
users: Array.from(this.typingUsers[channelId]),
});
// Remove after timeout
setTimeout(() => {
this.removeTypingUser(channelId, username);
}, 5000);
}
removeTypingUser(channelId, username) {
if (this.typingUsers[channelId]) {
this.typingUsers[channelId].delete(username);
eventBus.emit("typing-users-changed", {
channelId,
users: Array.from(this.typingUsers[channelId]),
});
}
}
handleNewMessage(message) {
// Add to messages if it's for active chat
if (
this.activeChat &&
message.channel_id === this.activeChat.id
) {
if (!this.messages[message.channel_id]) {
this.messages[message.channel_id] = [];
}
this.messages[message.channel_id].push(message);
eventBus.emit("new-message-received", message);
} else {
// Update unread count
const channel = this.channels.find(
(c) => c.id === message.channel_id,
);
if (channel) {
channel.unread = (channel.unread || 0) + 1;
eventBus.emit("unread-update", {
channelId: message.channel_id,
unread: channel.unread,
});
} else {
const dm = this.dms.find(
(d) => d.id === message.channel_id,
);
if (dm) {
dm.unread = (dm.unread || 0) + 1;
eventBus.emit("unread-update", {
channelId: message.channel_id,
unread: dm.unread,
});
}
}
}
}
}
const chatManager = new ChatManager();
// Listen for WebSocket messages
eventBus.on("new-message", (message) =>
chatManager.handleNewMessage(message),
);
eventBus.on("typing-update", (data) => {
chatManager.addTypingUser(data.channel_id, data.username);
});
// Helper Functions
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function formatTime(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
if (diff < 60000) return "Just now";
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000)
return date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
});
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
// Components
class ChatSidebar extends HTMLElement {
connectedCallback() {
this.render();
eventBus.on("channels-loaded", () => this.render());
eventBus.on("dms-loaded", () => this.render());
eventBus.on("chat-changed", (chat) =>
this.setActiveChat(chat.id),
);
eventBus.on("unread-update", (data) =>
this.updateUnread(data),
);
if (currentUser) {
chatManager.loadChannels();
chatManager.loadDMs();
}
}
render() {
if (!currentUser) {
this.innerHTML = "";
return;
}
this.innerHTML = `
<div class="sidebar">
<div class="sidebar-header">
<div class="server-name">Chat Hub</div>
<button class="icon-btn" id="settingsBtn">⚙️</button>
</div>
<div class="channel-list">
<div class="sidebar-section">
<div class="section-header">
<span>Channels</span>
<button id="addChannelBtn" title="Add Channel">+</button>
</div>
<div id="channelsList">
${chatManager.channels.map((channel) => this.createChannelItem(channel)).join("")}
</div>
</div>
<div class="sidebar-section">
<div class="section-header">
<span>Direct Messages</span>
<button id="addDMBtn" title="Start DM">+</button>
</div>
<div id="dmsList">
${chatManager.dms.map((dm) => this.createDMItem(dm)).join("")}
</div>
</div>
</div>
<div class="user-profile">
<div class="user-avatar" style="background: #${currentUser.avatar_b}">
${currentUser.username[0].toUpperCase()}
</div>
<div class="user-info">
<div class="user-name">${currentUser.username}</div>
<div class="user-status">Online</div>
</div>
<div class="user-actions">
<a href="/logout" class="icon-btn" title="Logout">🚪</a>
</div>
</div>
</div>
`;
// Add event listeners
this.querySelectorAll(".channel-item").forEach((item) => {
item.addEventListener("click", () => {
chatManager.setActiveChat(
item.dataset.channelId,
"channel",
);
});
});
this.querySelectorAll(".dm-item").forEach((item) => {
item.addEventListener("click", () => {
chatManager.setActiveChat(item.dataset.dmId, "dm");
});
});
this.querySelector("#addChannelBtn")?.addEventListener(
"click",
() => {
eventBus.emit("show-modal", "create-channel");
},
);
this.querySelector("#addDMBtn")?.addEventListener(
"click",
() => {
eventBus.emit("show-modal", "user-search");
},
);
}
createChannelItem(channel) {
const isActive =
chatManager.activeChat &&
chatManager.activeChat.id === channel.id;
return `
<div class="channel-item ${isActive ? "active" : ""}" data-channel-id="${channel.id}">
<span class="channel-icon">#</span>
<span class="channel-name">${channel.name}</span>
${channel.unread > 0 ? `<span class="unread-badge">${channel.unread}</span>` : ""}
</div>
`;
}
createDMItem(dm) {
const isActive =
chatManager.activeChat &&
chatManager.activeChat.id === dm.id;
return `
<div class="dm-item ${isActive ? "active" : ""}" data-dm-id="${dm.id}">
<span class="dm-icon">👤</span>
<span class="dm-name">${dm.name}</span>
<span class="status-indicator status-${dm.user?.status || "offline"}"></span>
${dm.unread > 0 ? `<span class="unread-badge">${dm.unread}</span>` : ""}
</div>
`;
}
setActiveChat(chatId) {
this.querySelectorAll(".channel-item, .dm-item").forEach(
(item) => {
item.classList.remove("active");
},
);
const activeItem = this.querySelector(
`[data-channel-id="${chatId}"], [data-dm-id="${chatId}"]`,
);
if (activeItem) {
activeItem.classList.add("active");
}
}
updateUnread(data) {
const item = this.querySelector(
`[data-channel-id="${data.channelId}"], [data-dm-id="${data.channelId}"]`,
);
if (!item) return;
const badge = item.querySelector(".unread-badge");
if (data.unread > 0) {
if (badge) {
badge.textContent = data.unread;
} else {
item.insertAdjacentHTML(
"beforeend",
`<span class="unread-badge">${data.unread}</span>`,
);
}
} else if (badge) {
badge.remove();
}
}
}
class ChatHeader extends HTMLElement {
constructor() {
super();
this.currentChat = null;
}
connectedCallback() {
this.render();
eventBus.on("chat-changed", (chat) => {
this.currentChat = chat;
this.render();
});
}
render() {
if (!currentUser || !this.currentChat) {
this.innerHTML = "";
return;
}
const icon =
this.currentChat.type === "channel" ? "#" : "👤";
const name = this.currentChat.name;
this.innerHTML = `
<div class="chat-header">
<div class="chat-title">
<span class="channel-icon">${icon}</span>
<h2>${name}</h2>
</div>
<div class="header-actions">
<button class="icon-btn" title="Search">🔍</button>
<button class="icon-btn" title="Members">👥</button>
<button class="icon-btn" title="Settings">⚙️</button>
</div>
</div>
`;
}
}
class ChatMessages extends HTMLElement {
constructor() {
super();
this.messages = [];
this.typingUsers = [];
}
connectedCallback() {
this.render();
eventBus.on("messages-loaded", (messages) => {
this.messages = messages;
this.render();
});
eventBus.on("message-sent", (message) => {
this.addMessage(message);
});
eventBus.on("new-message-received", (message) => {
this.addMessage(message);
});
eventBus.on("typing-users-changed", (data) => {
if (
chatManager.activeChat &&
data.channelId === chatManager.activeChat.id
) {
this.updateTypingIndicator(data.users);
}
});
}
render() {
if (!currentUser) {
this.innerHTML = `
<div class="messages-container">
<div class="loading">
<h2>Welcome to Chat Hub</h2>
<p>Please <a href="/login">login</a> to start chatting</p>
</div>
</div>
`;
return;
}
this.innerHTML = `
<div class="messages-container" id="messagesContainer">
${this.messages.map((msg) => this.createMessage(msg)).join("")}
<div id="typingIndicator" class="typing-indicator" style="display: none;"></div>
</div>
`;
this.scrollToBottom();
}
createMessage(message) {
const isOwn =
currentUser &&
message.author?.id === currentUser.id;
const author = message.author || {
username: message.username,
avatar_color: message.avatar_b,
};
return `
<div class="message" data-message-id="${message.id || message.uid}">
<div class="message-avatar" style="background: #${author.avatar_color}">
${author.username[0].toUpperCase()}
</div>
<div class="message-content">
<div class="message-header">
<span class="message-author">${author.username}</span>
<span class="message-timestamp">${formatTime(message.timestamp || message.created_at)}</span>
</div>
<div class="message-text">${escapeHtml(message.content)}</div>
</div>
</div>
`;
}
addMessage(message) {
const container = this.querySelector("#messagesContainer");
if (container) {
const typingIndicator =
container.querySelector("#typingIndicator");
const messageHtml = this.createMessage(message);
typingIndicator.insertAdjacentHTML(
"beforebegin",
messageHtml,
);
this.scrollToBottom();
}
}
updateTypingIndicator(users) {
const indicator = this.querySelector("#typingIndicator");
if (!indicator) return;
if (users.length > 0) {
const text =
users.length === 1
? `${users[0]} is typing...`
: `${users.join(", ")} are typing...`;
indicator.textContent = text;
indicator.style.display = "block";
} else {
indicator.style.display = "none";
}
}
scrollToBottom() {
const container = this.querySelector("#messagesContainer");
if (container) {
container.scrollTop = container.scrollHeight;
}
}
}
class ChatInput extends HTMLElement {
connectedCallback() {
this.render();
}
render() {
if (!currentUser) {
this.innerHTML = "";
return;
}
this.innerHTML = `
<div class="message-input-container">
<div class="message-input-wrapper">
<textarea
class="message-input"
id="messageInput"
placeholder="Type a message..."
rows="1"
></textarea>
<div class="input-actions">
<button class="btn" id="sendBtn">Send</button>
</div>
</div>
</div>
`;
const input = this.querySelector("#messageInput");
const sendBtn = this.querySelector("#sendBtn");
input.addEventListener("keypress", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
this.sendMessage();
} else {
// Send typing indicator
if (chatManager.activeChat) {
wsManager.sendTyping(chatManager.activeChat.id);
}
}
});
sendBtn.addEventListener("click", () => this.sendMessage());
// Auto-resize textarea
input.addEventListener("input", () => {
input.style.height = "auto";
input.style.height =
Math.min(input.scrollHeight, 120) + "px";
});
}
sendMessage() {
const input = this.querySelector("#messageInput");
const content = input.value.trim();
if (!content) return;
chatManager.sendMessage(content);
input.value = "";
input.style.height = "auto";
}
}
class ChatModal extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<div id="modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" id="modalTitle">Modal</h2>
<button class="close-btn" id="closeModal">&times;</button>
</div>
<div id="modalBody"></div>
</div>
</div>
`;
this.querySelector("#closeModal").addEventListener(
"click",
() => this.close(),
);
this.querySelector("#modal").addEventListener(
"click",
(e) => {
if (e.target.id === "modal") this.close();
},
);
eventBus.on("show-modal", (type) => this.show(type));
}
show(type) {
const modal = this.querySelector("#modal");
const title = this.querySelector("#modalTitle");
const body = this.querySelector("#modalBody");
switch (type) {
case "user-search":
title.textContent = "Start Direct Message";
body.innerHTML = this.getUserSearchForm();
this.attachUserSearchHandlers();
break;
case "create-channel":
title.textContent = "Create Channel";
body.innerHTML = this.getCreateChannelForm();
this.attachCreateChannelHandlers();
break;
}
modal.classList.add("active");
}
close() {
this.querySelector("#modal").classList.remove("active");
}
getUserSearchForm() {
return `
<div class="form-group">
<label>Search Users</label>
<input type="text" id="userSearchInput" placeholder="Type to search...">
</div>
<div id="searchResults" class="search-results"></div>
`;
}
getCreateChannelForm() {
return `
<form id="createChannelForm">
<div class="form-group">
<label>Channel Name</label>
<input type="text" id="channelName" placeholder="new-channel" required>
</div>
<div class="error-message" id="channelError" style="display: none;"></div>
<button type="submit" class="btn" style="width: 100%;">Create Channel</button>
</form>
`;
}
attachUserSearchHandlers() {
const input = this.querySelector("#userSearchInput");
const results = this.querySelector("#searchResults");
let searchTimeout;
input.addEventListener("input", async (e) => {
clearTimeout(searchTimeout);
const query = e.target.value;
if (query.length < 2) {
results.innerHTML = "";
return;
}
searchTimeout = setTimeout(async () => {
const users = await chatManager.searchUsers(query);
results.innerHTML = users
.map(
(user) => `
<div class="search-result-item" data-user-id="${user.id}">
<div class="user-avatar" style="background: #${user.avatar_color}; width: 32px; height: 32px; font-size: 0.9rem;">
${user.username[0].toUpperCase()}
</div>
<div style="flex: 1; margin-left: 1rem;">
<div>${user.username}</div>
<div style="font-size: 0.85rem; color: var(--text-dim);">${user.status}</div>
</div>
</div>
`,
)
.join("");
results
.querySelectorAll(".search-result-item")
.forEach((item) => {
item.addEventListener("click", () => {
chatManager.startDM(
item.dataset.userId,
);
this.close();
});
});
}, 300);
});
}
attachCreateChannelHandlers() {
const form = this.querySelector("#createChannelForm");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const name = this.querySelector("#channelName").value;
try {
await chatManager.createChannel(name);
this.close();
} catch (error) {
const errorEl = this.querySelector("#channelError");
errorEl.textContent = error.message;
errorEl.style.display = "block";
}
});
}
}
// Register all components
customElements.define("chat-sidebar", ChatSidebar);
customElements.define("chat-header", ChatHeader);
customElements.define("chat-messages", ChatMessages);
customElements.define("chat-input", ChatInput);
customElements.define("chat-modal", ChatModal);
</script>
</body>
</html>