diff --git a/frontend/index.html b/frontend/index.html index 606d2b5..63d5d66 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -417,6 +417,12 @@ +
+ +
diff --git a/frontend/src/chat/contextMenu.ts b/frontend/src/chat/contextMenu.ts index 579cbba..7c960b1 100644 --- a/frontend/src/chat/contextMenu.ts +++ b/frontend/src/chat/contextMenu.ts @@ -84,9 +84,16 @@ export function show(message: Message, x: number, y: number): void { const isAuthor = message.username === currentUser?.username; const isOwner = currentUser?.admin; - + + // Check if we're in a DM conversation + const isInDm = document.querySelector('.chat-tabs mdui-tab[value="dms"]')?.getAttribute('active') === 'true'; + + // Show edit only for own messages editItem.style.display = isAuthor ? 'flex' : 'none'; - deleteItem.style.display = (isAuthor || isOwner) ? 'flex' : 'none'; + + // Show delete for own messages, admin on any message, or in DMs for any message + const canDelete = isAuthor || isOwner || isInDm; + deleteItem.style.display = canDelete ? 'flex' : 'none'; // Position the menu properly menu.style.display = 'block'; diff --git a/frontend/src/chat/dm.ts b/frontend/src/chat/dm.ts index ebf3f86..7b0e1d0 100644 --- a/frontend/src/chat/dm.ts +++ b/frontend/src/chat/dm.ts @@ -1,12 +1,12 @@ import { API_BASE_URL } from "../core/config"; -import { authToken, getAuthHeaders } from "../auth/api"; +import { authToken, getAuthHeaders, currentUser } from "../auth/api"; import { DmPanel } from "./panel"; import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric"; import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric"; import { randomBytes } from "../crypto/kdf"; import { getCurrentKeys } from "../auth/crypto"; import { request, websocket } from "../websocket"; -import type { FetchDMResponse, SendDMRequest, WebSocketMessage } from "../core/types"; +import type { FetchDMResponse, SendDMRequest, WebSocketMessage, User } from "../core/types"; import type { Tabs } from "mdui/components/tabs"; import { b64, ub64 } from "../utils/utils"; @@ -88,9 +88,48 @@ async function loadUsers() { const data = await res.json(); const list = document.getElementById("dm-users")!; list.innerHTML = ""; - (data.users || []).forEach((u: any) => { + (data.users || []).forEach((u: User) => { const item = document.createElement("mdui-list-item"); + + // Add avatar + const avatar = document.createElement("img"); + avatar.src = u.profile_picture || "./src/resources/images/default-avatar.png"; + avatar.alt = u.username; + avatar.slot = "icon"; + avatar.style.width = "40px"; + avatar.style.height = "40px"; + avatar.style.borderRadius = "50%"; + avatar.style.objectFit = "cover"; + + // Handle avatar load error + avatar.addEventListener("error", () => { + avatar.src = "./src/resources/images/default-avatar.png"; + }); + + item.appendChild(avatar); + + // Set headline (username) item.setAttribute("headline", u.username); + + // Add last message placeholder (will be loaded lazily) + const lastMessageEl = document.createElement("div"); + lastMessageEl.slot = "supporting-text"; + lastMessageEl.textContent = "Loading..."; + lastMessageEl.style.fontSize = "12px"; + lastMessageEl.style.color = "var(--mdui-color-on-surface-variant)"; + item.appendChild(lastMessageEl); + + // Load last message when element becomes visible + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + loadLastMessage(u.id, lastMessageEl); + observer.unobserve(entry.target); + } + }); + }); + observer.observe(item); + item.addEventListener("click", async () => { activeDm = { userId: u.id, username: u.username, publicKey: null }; if (!dmPanel) { @@ -133,7 +172,7 @@ async function loadUsers() { }, async () => { // Load DM history for the active conversation - if (!activeDm?.publicKey) return; + if (!activeDm?.publicKey || !dmPanel) return; const response = await fetch(`${API_BASE_URL}/dm/history/${activeDm.userId}`, { headers: getAuthHeaders(true) }); @@ -145,17 +184,16 @@ async function loadUsers() { for (const env of messages) { try { const text = await decryptDm(env, activeDm.publicKey); - const div = document.createElement("div"); const isAuthor = env.senderId !== activeDm.userId; - div.className = `message ${isAuthor ? "sent" : "received"}`; - const inner = document.createElement("div"); - inner.className = "message-inner"; - const content = document.createElement("div"); - content.className = "message-content"; - content.textContent = text; - inner.appendChild(content); - div.appendChild(inner); - container.appendChild(div); + const username = isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown"); + dmPanel.appendMessageWithId({ + id: env.id, + content: text, + username: username, + timestamp: env.timestamp, + is_read: false, + is_edited: false + }); } catch {} } container.scrollTop = container.scrollHeight; @@ -163,9 +201,18 @@ async function loadUsers() { } ); } + dmPanel.setOtherUser(u.username); dmPanel.setTitle(u.username); dmPanel.clearMessages(); dmPanel.activate(); + + // Add profile click functionality to chat header + const chatHeaderAvatar = document.querySelector('.chat-header-avatar') as HTMLElement; + if (chatHeaderAvatar) { + chatHeaderAvatar.style.cursor = 'pointer'; + chatHeaderAvatar.onclick = () => dmPanel?.onProfileClicked(); + } + const resPk = await fetch(`${API_BASE_URL}/crypto/public-key/of/${u.id}`, { headers: getAuthHeaders(true) }); if (resPk.ok) { const pkData = await resPk.json(); @@ -176,6 +223,30 @@ async function loadUsers() { }); } +async function loadLastMessage(userId: number, element: HTMLElement): Promise { + try { + const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=1`, { + headers: getAuthHeaders(true) + }); + if (response.ok) { + const data = await response.json(); + const messages: DmEnvelope[] = data.messages || []; + if (messages.length > 0) { + const lastMessage = messages[messages.length - 1]; + // For now, just show "Last message" since we can't decrypt without the public key + // In a real implementation, you'd need to store the public key or decrypt here + element.textContent = "Last message"; + } else { + element.textContent = "No messages yet"; + } + } else { + element.textContent = "No messages yet"; + } + } catch (error) { + element.textContent = "No messages yet"; + } +} + function init() { const tabs = document.querySelector(".chat-tabs mdui-tabs") as Tabs; const dmTab = tabs?.querySelector('mdui-tab[value="dms"]')!; @@ -203,19 +274,17 @@ websocket.addEventListener("message", async (e) => { if (msg.type === "dmNew" && activeDm && (msg.data.senderId === activeDm.userId || msg.data.recipientId === activeDm.userId)) { const plaintext = await decryptDm(msg.data, activeDm.publicKey!); - const container = document.getElementById("chat-messages")!; - const div = document.createElement("div"); - const isAuthor = msg.data.senderId !== activeDm.userId; - div.className = `message ${isAuthor ? "sent" : "received"}`; - const inner = document.createElement("div"); - inner.className = "message-inner"; - const content = document.createElement("div"); - content.className = "message-content"; - content.textContent = plaintext; - inner.appendChild(content); - div.appendChild(inner); - container.appendChild(div); - container.scrollTop = container.scrollHeight; + if (dmPanel) { + const isAuthor = msg.data.senderId !== activeDm.userId; + dmPanel.appendMessageWithId({ + id: msg.data.id, + content: plaintext, + username: isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown"), + timestamp: msg.data.timestamp, + is_read: false, + is_edited: false + }); + } } } catch {} }); \ No newline at end of file diff --git a/frontend/src/chat/panel.ts b/frontend/src/chat/panel.ts index 9a99522..542dba3 100644 --- a/frontend/src/chat/panel.ts +++ b/frontend/src/chat/panel.ts @@ -1,7 +1,9 @@ import { authToken, currentUser } from "../auth/api"; -import type { WebSocketMessage } from "../core/types"; +import type { Message, WebSocketMessage } from "../core/types"; import { request } from "../websocket"; import { loadMessages } from "./chat"; +import { show as showContextMenu } from "./contextMenu"; +import { show as showProfileDialog } from "./profileDialog"; const titleEl = document.getElementById("chat-name")!; const messages = document.getElementById("chat-messages")!; @@ -43,6 +45,7 @@ export abstract class ChatPanelController { protected abstract onSubmit(text: string): void | Promise; protected abstract loadMessages(): void | Promise; + public abstract onProfileClicked(): void; static mountOnce(): void { if (this.mounted) return; @@ -79,23 +82,69 @@ export class PublicChatPanel extends ChatPanelController { protected loadMessages(): void { return loadMessages(); } + + public onProfileClicked(): void { + // Public chat doesn't have a specific profile to show + } } export class DmPanel extends ChatPanelController { private sender: (text: string) => Promise; private loader: () => Promise | void; + private otherUsername: string | null = null; + constructor(sender: (text: string) => Promise, loader: () => Promise | void) { super(); this.sender = sender; this.loader = loader; } + + setOtherUser(username: string): void { + this.otherUsername = username; + } + protected async onSubmit(text: string): Promise { this.appendSimple(text, true); await this.sender(text); } + protected loadMessages(): void | Promise { return this.loader(); } + + public onProfileClicked(): void { + if (this.otherUsername) { + // Import and show the profile dialog + showProfileDialog(this.otherUsername!); + } + } + + appendMessageWithId(message: Message): void { + const div = document.createElement("div"); + const isAuthor = message.username === currentUser?.username; + div.className = `message ${isAuthor ? "sent" : "received"}`; + div.setAttribute("data-message-id", message.id.toString()); + div.setAttribute("data-timestamp", message.timestamp); + + const inner = document.createElement("div"); + inner.className = "message-inner"; + + const content = document.createElement("div"); + content.className = "message-content"; + content.textContent = message.content; + + inner.appendChild(content); + div.appendChild(inner); + + // Add context menu support + div.addEventListener("contextmenu", (e) => { + e.preventDefault(); + showContextMenu(message, e.clientX, e.clientY); + }); + + messages.appendChild(div); + messages.scrollTop = messages.scrollHeight; + } } ChatPanelController.mountOnce(); \ No newline at end of file diff --git a/frontend/src/chat/profileDialog.ts b/frontend/src/chat/profileDialog.ts index 3018ee8..f200fa5 100644 --- a/frontend/src/chat/profileDialog.ts +++ b/frontend/src/chat/profileDialog.ts @@ -9,7 +9,7 @@ import { getAuthHeaders, currentUser } from "../auth/api"; import { API_BASE_URL } from "../core/config"; import type { UserProfile } from "../core/types"; import { showError, showSuccess } from "../utils/notification"; -import { formatTime, id } from "../utils/utils"; +import { delay, formatTime, id } from "../utils/utils"; import defaultAvatar from "../resources/images/default-avatar.png"; import type { Dialog } from "mdui/components/dialog"; import type { TextField } from "mdui/components/text-field"; @@ -36,6 +36,41 @@ function bindEvents(): void { editBioBtn?.addEventListener('click', () => startEditBio()); saveBioBtn?.addEventListener('click', () => saveBio()); cancelBioBtn?.addEventListener('click', () => cancelEditBio()); + + // DM button event + const dmButton = dialog?.querySelector('#dm-button'); + dmButton?.addEventListener('click', () => startDm()); +} + +/** + * Starts a direct message conversation + * @private + */ +async function startDm(): Promise { + if (!currentProfile || isOwnProfile) return; + + // Hide the profile dialog + hide(); + + // Switch to DMs tab + const tabs = document.querySelector('.chat-tabs mdui-tabs') as any; + if (tabs) { + tabs.value = 'dms'; + } + + // Find and click on the user in the DM users list + await delay(100); + const dmUsersList = document.getElementById("dm-users"); + if (dmUsersList) { + const userItems = dmUsersList.querySelectorAll('mdui-list-item'); + for (const item of userItems) { + const headline = item.getAttribute('headline'); + if (headline === currentProfile?.username) { + (item as HTMLElement).click(); + break; + } + } + } } /** diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index c76c51b..8f3bf4a 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -85,6 +85,7 @@ export interface User { username: string; admin?: boolean; bio?: string; + profile_picture: string; } /**