diff --git a/.cursor/rules/ui.mdc b/.cursor/rules/ui.mdc
index 6791c5c..737cd03 100644
--- a/.cursor/rules/ui.mdc
+++ b/.cursor/rules/ui.mdc
@@ -5,4 +5,5 @@ alwaysApply: true
When you work with UI:
1. Use MDUI components as HTML elements
-2. Do NOT dynamically create HTML if it's going to be loaded when the page loads, instead put it statically in the HTML.
\ No newline at end of file
+2. Do NOT dynamically create HTML if it's going to be loaded when the page loads, instead put it statically in the HTML.
+3. The supporting text slot for MDUI lists is "description".
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
index 63d5d66..e8e87be 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -418,8 +418,8 @@
-
- chat
+
+
Send Message
diff --git a/frontend/src/auth/auth.ts b/frontend/src/auth/auth.ts
index 3988c2d..5d0da0b 100644
--- a/frontend/src/auth/auth.ts
+++ b/frontend/src/auth/auth.ts
@@ -8,7 +8,7 @@
import { initializeProfile } from "../userPanel/profile/profile";
import type { ErrorResponse, LoginResponse, LoginRequest, RegisterRequest } from "../core/types";
import { API_BASE_URL } from "../core/config";
-import { loadChat, showLogin, showRegister } from "../navigation";
+import { showChat, showLogin, showRegister } from "../navigation";
import { setUser } from "./api";
import { ensureKeysOnLogin } from "./crypto";
import { id } from "../utils/utils";
@@ -77,7 +77,7 @@ async function handleLogin(e: Event): Promise {
} catch (e) {
console.error("Key setup failed:", e);
}
- loadChat();
+ showChat();
initializeProfile(); // Initialize profile after login
} else {
const data: ErrorResponse = await response.json();
diff --git a/frontend/src/chat/chat.ts b/frontend/src/chat/chat.ts
index 914362b..df130f7 100644
--- a/frontend/src/chat/chat.ts
+++ b/frontend/src/chat/chat.ts
@@ -123,35 +123,6 @@ export function addMessage(message: Message, isAuthor: boolean): void {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
-/**
- * Loads chat messages from the server
- */
-export function loadMessages(): void {
- fetch(`${API_BASE_URL}/get_messages`, {
- headers: getAuthHeaders()
- })
- .then(response => response.json())
- .then((data: Messages) => {
- if (data.messages && data.messages.length > 0) {
- const messagesContainer = document.querySelector('.chat-messages') as HTMLElement;
-
- const lastMessage = messagesContainer.lastElementChild as HTMLElement
- let lastMessageId: number = 0
- if (lastMessage) {
- lastMessageId = Number(lastMessage.dataset.id)
- }
-
- // Добавляем только новые сообщения
- data.messages.forEach(msg => {
- console.log(msg);
- if (msg.id > lastMessageId) {
- addMessage(msg, msg.username == currentUser!.username);
- }
- });
- }
- });
-}
-
/**
* Sends a message via WebSocket
*/
diff --git a/frontend/src/chat/dm.ts b/frontend/src/chat/dm.ts
index 7b0e1d0..0d9de54 100644
--- a/frontend/src/chat/dm.ts
+++ b/frontend/src/chat/dm.ts
@@ -81,6 +81,23 @@ export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string
let activeDm: { userId: number; username: string; publicKey: string | null } | null = null;
let usersLoaded = false;
let dmPanel: DmPanel | null = null;
+const dmBadgeByUserId: Map = new Map();
+const dmSupportingTextByUserId: Map = new Map();
+
+function getLastReadId(userId: number): number {
+ try {
+ const v = localStorage.getItem(`dmLastRead:${userId}`);
+ return v ? Number(v) : 0;
+ } catch {
+ return 0;
+ }
+}
+
+function setLastReadId(userId: number, id: number): void {
+ try {
+ localStorage.setItem(`dmLastRead:${userId}`, String(id));
+ } catch {}
+}
async function loadUsers() {
const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(true) });
@@ -90,6 +107,7 @@ async function loadUsers() {
list.innerHTML = "";
(data.users || []).forEach((u: User) => {
const item = document.createElement("mdui-list-item");
+ item.id = `dm-user-${u.id}`;
// Add avatar
const avatar = document.createElement("img");
@@ -111,19 +129,28 @@ async function loadUsers() {
// Set headline (username)
item.setAttribute("headline", u.username);
- // Add last message placeholder (will be loaded lazily)
+ // Add supporting text container (hidden until loaded)
const lastMessageEl = document.createElement("div");
- lastMessageEl.slot = "supporting-text";
- lastMessageEl.textContent = "Loading...";
+ lastMessageEl.slot = "description";
lastMessageEl.style.fontSize = "12px";
lastMessageEl.style.color = "var(--mdui-color-on-surface-variant)";
+ lastMessageEl.style.whiteSpace = "pre-line";
+ lastMessageEl.style.display = "none";
item.appendChild(lastMessageEl);
+ dmSupportingTextByUserId.set(u.id, lastMessageEl);
+
+ // Add unread badge (hidden by default)
+ const badge = document.createElement("mdui-badge");
+ badge.setAttribute("slot", "end-icon");
+ badge.style.display = "none";
+ item.appendChild(badge);
+ dmBadgeByUserId.set(u.id, badge);
// Load last message when element becomes visible
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
- loadLastMessage(u.id, lastMessageEl);
+ loadLastMessage(u.id);
observer.unobserve(entry.target);
}
});
@@ -181,9 +208,11 @@ async function loadUsers() {
const messages: DmEnvelope[] = data.messages || [];
const container = document.getElementById("chat-messages")!;
container.innerHTML = "";
+ let maxIncomingId = 0;
for (const env of messages) {
try {
- const text = await decryptDm(env, activeDm.publicKey);
+ // Always use other user's public key for ECDH (our private + their public)
+ const text = await decryptDm(env, activeDm.publicKey!);
const isAuthor = env.senderId !== activeDm.userId;
const username = isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown");
dmPanel.appendMessageWithId({
@@ -194,9 +223,20 @@ async function loadUsers() {
is_read: false,
is_edited: false
});
+ if (env.senderId === activeDm.userId && env.id > maxIncomingId) {
+ maxIncomingId = env.id;
+ }
} catch {}
}
container.scrollTop = container.scrollHeight;
+ if (maxIncomingId > 0) {
+ setLastReadId(activeDm.userId, maxIncomingId);
+ const badgeEl = dmBadgeByUserId.get(activeDm.userId);
+ if (badgeEl) {
+ badgeEl.style.display = "none";
+ badgeEl.textContent = "";
+ }
+ }
}
}
);
@@ -204,7 +244,6 @@ 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;
@@ -218,32 +257,76 @@ async function loadUsers() {
const pkData = await resPk.json();
activeDm!.publicKey = pkData.publicKey;
}
+
+ // Only activate after we have the public key so loader can decrypt
+ dmPanel.activate();
+ // Clear unread badge on open
+ const badgeEl = dmBadgeByUserId.get(u.id);
+ if (badgeEl) {
+ badgeEl.textContent = "";
+ badgeEl.style.display = "none";
+ }
});
list.appendChild(item);
});
}
-async function loadLastMessage(userId: number, element: HTMLElement): Promise {
+async function loadLastMessage(userId: number): Promise {
try {
- const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=1`, {
+ const pkRes = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(true) });
+ if (!pkRes.ok) return;
+ const pkData = await pkRes.json();
+ const otherPk = pkData.publicKey as string;
+ const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=50`, {
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";
+ const supporting = dmSupportingTextByUserId.get(userId);
+ const badgeEl = dmBadgeByUserId.get(userId);
+ if (!supporting || !badgeEl) return;
+ let lastPlaintext: string | null = null;
+ let lastEnv: DmEnvelope | null = null;
+ for (const env of messages) {
+ if (!lastEnv || env.id > lastEnv.id) lastEnv = env;
+ }
+ if (lastEnv) {
+ try { lastPlaintext = await decryptDm(lastEnv, otherPk); } catch {}
+ }
+ if (lastPlaintext && lastPlaintext.trim().length > 0) {
+ const lines = lastPlaintext.split(/\r?\n/).slice(0, 2);
+ supporting.textContent = lines.join("\n");
+ supporting.style.display = "block";
} else {
- element.textContent = "No messages yet";
+ supporting.textContent = "";
+ supporting.style.display = "none";
+ }
+ const lastRead = getLastReadId(userId);
+ let unread = 0;
+ for (const env of messages) {
+ if (env.senderId === userId && env.id > lastRead) unread++;
+ }
+ if (unread > 0) {
+ badgeEl.textContent = String(unread);
+ badgeEl.style.display = "inline-flex";
+ } else {
+ badgeEl.textContent = "";
+ badgeEl.style.display = "none";
}
} else {
- element.textContent = "No messages yet";
+ const supporting = dmSupportingTextByUserId.get(userId);
+ if (supporting) {
+ supporting.textContent = "";
+ supporting.style.display = "none";
+ }
}
} catch (error) {
- element.textContent = "No messages yet";
+ const supporting = dmSupportingTextByUserId.get(userId);
+ if (supporting) {
+ supporting.textContent = "";
+ supporting.style.display = "none";
+ }
}
}
@@ -271,19 +354,46 @@ init();
websocket.addEventListener("message", async (e) => {
try {
const msg: WebSocketMessage = JSON.parse((e as MessageEvent).data);
- if (msg.type === "dmNew" && activeDm && (msg.data.senderId === activeDm.userId || msg.data.recipientId === activeDm.userId)) {
- const plaintext = await decryptDm(msg.data, activeDm.publicKey!);
-
- 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
- });
+ if (msg.type === "dmNew") {
+ if (activeDm && (msg.data.senderId === activeDm.userId || msg.data.recipientId === activeDm.userId)) {
+ // Always use other user's public key (our private is implied by getCurrentKeys)
+ const plaintext = await decryptDm(msg.data, activeDm.publicKey!);
+ 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
+ });
+ }
+ if (msg.data.senderId === activeDm.userId) {
+ setLastReadId(activeDm.userId, Math.max(getLastReadId(activeDm.userId), msg.data.id));
+ }
+ } else {
+ const otherUserId = msg.data.senderId;
+ const badgeEl = dmBadgeByUserId.get(otherUserId);
+ if (badgeEl) {
+ const current = Number(badgeEl.textContent || 0);
+ const next = (current || 0) + 1;
+ badgeEl.textContent = String(next);
+ badgeEl.style.display = "inline-flex";
+ }
+ try {
+ const pkRes = await fetch(`${API_BASE_URL}/crypto/public-key/of/${otherUserId}`, { headers: getAuthHeaders(true) });
+ if (pkRes.ok) {
+ const pkData = await pkRes.json();
+ const plaintext = await decryptDm(msg.data, pkData.publicKey);
+ const supporting = dmSupportingTextByUserId.get(otherUserId);
+ if (supporting && plaintext) {
+ const lines = plaintext.split(/\r?\n/).slice(0, 2);
+ supporting.textContent = lines.join("\n");
+ supporting.style.display = lines.length ? "block" : "none";
+ }
+ }
+ } catch {}
}
}
} catch {}
diff --git a/frontend/src/chat/panel.ts b/frontend/src/chat/panel.ts
index 542dba3..7f888c3 100644
--- a/frontend/src/chat/panel.ts
+++ b/frontend/src/chat/panel.ts
@@ -1,7 +1,8 @@
-import { authToken, currentUser } from "../auth/api";
-import type { Message, WebSocketMessage } from "../core/types";
+import { authToken, currentUser, getAuthHeaders } from "../auth/api";
+import { API_BASE_URL } from "../core/config";
+import type { Message, Messages, WebSocketMessage } from "../core/types";
import { request } from "../websocket";
-import { loadMessages } from "./chat";
+import { addMessage } from "./chat";
import { show as showContextMenu } from "./contextMenu";
import { show as showProfileDialog } from "./profileDialog";
@@ -80,7 +81,31 @@ export class PublicChatPanel extends ChatPanelController {
}
protected loadMessages(): void {
- return loadMessages();
+ fetch(`${API_BASE_URL}/get_messages`, {
+ headers: getAuthHeaders()
+ })
+ .then(response => response.json())
+ .then((data: Messages) => {
+ if (data.messages && data.messages.length > 0) {
+ messages.innerHTML = "";
+
+ const messagesContainer = document.querySelector('.chat-messages') as HTMLElement;
+
+ const lastMessage = messagesContainer.lastElementChild as HTMLElement
+ let lastMessageId: number = 0
+ if (lastMessage) {
+ lastMessageId = Number(lastMessage.dataset.id)
+ }
+
+ // Добавляем только новые сообщения
+ data.messages.forEach(msg => {
+ console.log(msg);
+ if (msg.id > lastMessageId) {
+ addMessage(msg, msg.username == currentUser!.username);
+ }
+ });
+ }
+ });
}
public onProfileClicked(): void {
diff --git a/frontend/src/navigation.ts b/frontend/src/navigation.ts
index f72e4b1..81081dc 100644
--- a/frontend/src/navigation.ts
+++ b/frontend/src/navigation.ts
@@ -1,5 +1,5 @@
import { clearAlerts } from "./auth/auth";
-import { loadMessages } from "./chat/chat";
+import { publicChatPanel } from "./chat/chat";
import { id } from "./utils/utils";
const loginForm = id("login-form");
@@ -36,14 +36,7 @@ export function showChat(): void {
loginForm.style.display = 'none';
registerForm.style.display = 'none';
chatInterface.style.display = 'block';
- loadMessages();
titleBar.classList.remove("color-surface");
-}
-/**
- * Loads the chat interface and initializes messaging.
- */
-export function loadChat(): void {
- showChat();
- loadMessages();
+ publicChatPanel.activate();
}
\ No newline at end of file