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 @@
+
+
+ chat
+ Send Message
+
+
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;
}
/**