Fix DMs displaying

This commit is contained in:
2025-08-26 18:09:55 +03:00
Unverified
parent bf55005d38
commit 1fb01a4404
7 changed files with 176 additions and 76 deletions
+1
View File
@@ -6,3 +6,4 @@ When you work with UI:
1. Use MDUI components as HTML elements 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. 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".
+2 -2
View File
@@ -418,8 +418,8 @@
</div> </div>
</div> </div>
<div class="profile-actions"> <div class="profile-actions">
<mdui-button id="dm-button" variant="filled" style="display: none;"> <mdui-button id="dm-button" variant="filled">
<span class="material-symbols">chat</span> <mdui-icon slot="icon" name="chat--filled"></mdui-icon>
Send Message Send Message
</mdui-button> </mdui-button>
</div> </div>
+2 -2
View File
@@ -8,7 +8,7 @@
import { initializeProfile } from "../userPanel/profile/profile"; import { initializeProfile } from "../userPanel/profile/profile";
import type { ErrorResponse, LoginResponse, LoginRequest, RegisterRequest } from "../core/types"; import type { ErrorResponse, LoginResponse, LoginRequest, RegisterRequest } from "../core/types";
import { API_BASE_URL } from "../core/config"; import { API_BASE_URL } from "../core/config";
import { loadChat, showLogin, showRegister } from "../navigation"; import { showChat, showLogin, showRegister } from "../navigation";
import { setUser } from "./api"; import { setUser } from "./api";
import { ensureKeysOnLogin } from "./crypto"; import { ensureKeysOnLogin } from "./crypto";
import { id } from "../utils/utils"; import { id } from "../utils/utils";
@@ -77,7 +77,7 @@ async function handleLogin(e: Event): Promise<void> {
} catch (e) { } catch (e) {
console.error("Key setup failed:", e); console.error("Key setup failed:", e);
} }
loadChat(); showChat();
initializeProfile(); // Initialize profile after login initializeProfile(); // Initialize profile after login
} else { } else {
const data: ErrorResponse = await response.json(); const data: ErrorResponse = await response.json();
-29
View File
@@ -123,35 +123,6 @@ export function addMessage(message: Message, isAuthor: boolean): void {
messagesContainer.scrollTop = messagesContainer.scrollHeight; 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 * Sends a message via WebSocket
*/ */
+128 -18
View File
@@ -81,6 +81,23 @@ export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string
let activeDm: { userId: number; username: string; publicKey: string | null } | null = null; let activeDm: { userId: number; username: string; publicKey: string | null } | null = null;
let usersLoaded = false; let usersLoaded = false;
let dmPanel: DmPanel | null = null; let dmPanel: DmPanel | null = null;
const dmBadgeByUserId: Map<number, HTMLElement> = new Map();
const dmSupportingTextByUserId: Map<number, HTMLElement> = 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() { async function loadUsers() {
const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(true) }); const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(true) });
@@ -90,6 +107,7 @@ async function loadUsers() {
list.innerHTML = ""; list.innerHTML = "";
(data.users || []).forEach((u: User) => { (data.users || []).forEach((u: User) => {
const item = document.createElement("mdui-list-item"); const item = document.createElement("mdui-list-item");
item.id = `dm-user-${u.id}`;
// Add avatar // Add avatar
const avatar = document.createElement("img"); const avatar = document.createElement("img");
@@ -111,19 +129,28 @@ async function loadUsers() {
// Set headline (username) // Set headline (username)
item.setAttribute("headline", u.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"); const lastMessageEl = document.createElement("div");
lastMessageEl.slot = "supporting-text"; lastMessageEl.slot = "description";
lastMessageEl.textContent = "Loading...";
lastMessageEl.style.fontSize = "12px"; lastMessageEl.style.fontSize = "12px";
lastMessageEl.style.color = "var(--mdui-color-on-surface-variant)"; lastMessageEl.style.color = "var(--mdui-color-on-surface-variant)";
lastMessageEl.style.whiteSpace = "pre-line";
lastMessageEl.style.display = "none";
item.appendChild(lastMessageEl); 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 // Load last message when element becomes visible
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
loadLastMessage(u.id, lastMessageEl); loadLastMessage(u.id);
observer.unobserve(entry.target); observer.unobserve(entry.target);
} }
}); });
@@ -181,9 +208,11 @@ async function loadUsers() {
const messages: DmEnvelope[] = data.messages || []; const messages: DmEnvelope[] = data.messages || [];
const container = document.getElementById("chat-messages")!; const container = document.getElementById("chat-messages")!;
container.innerHTML = ""; container.innerHTML = "";
let maxIncomingId = 0;
for (const env of messages) { for (const env of messages) {
try { 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 isAuthor = env.senderId !== activeDm.userId;
const username = isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown"); const username = isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown");
dmPanel.appendMessageWithId({ dmPanel.appendMessageWithId({
@@ -194,9 +223,20 @@ async function loadUsers() {
is_read: false, is_read: false,
is_edited: false is_edited: false
}); });
if (env.senderId === activeDm.userId && env.id > maxIncomingId) {
maxIncomingId = env.id;
}
} catch {} } catch {}
} }
container.scrollTop = container.scrollHeight; 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.setOtherUser(u.username);
dmPanel.setTitle(u.username); dmPanel.setTitle(u.username);
dmPanel.clearMessages(); dmPanel.clearMessages();
dmPanel.activate();
// Add profile click functionality to chat header // Add profile click functionality to chat header
const chatHeaderAvatar = document.querySelector('.chat-header-avatar') as HTMLElement; const chatHeaderAvatar = document.querySelector('.chat-header-avatar') as HTMLElement;
@@ -218,32 +257,76 @@ async function loadUsers() {
const pkData = await resPk.json(); const pkData = await resPk.json();
activeDm!.publicKey = pkData.publicKey; 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); list.appendChild(item);
}); });
} }
async function loadLastMessage(userId: number, element: HTMLElement): Promise<void> { async function loadLastMessage(userId: number): Promise<void> {
try { 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) headers: getAuthHeaders(true)
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
const messages: DmEnvelope[] = data.messages || []; const messages: DmEnvelope[] = data.messages || [];
if (messages.length > 0) { const supporting = dmSupportingTextByUserId.get(userId);
const lastMessage = messages[messages.length - 1]; const badgeEl = dmBadgeByUserId.get(userId);
// For now, just show "Last message" since we can't decrypt without the public key if (!supporting || !badgeEl) return;
// In a real implementation, you'd need to store the public key or decrypt here let lastPlaintext: string | null = null;
element.textContent = "Last message"; 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 { } 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 { } else {
element.textContent = "No messages yet"; const supporting = dmSupportingTextByUserId.get(userId);
if (supporting) {
supporting.textContent = "";
supporting.style.display = "none";
}
} }
} catch (error) { } catch (error) {
element.textContent = "No messages yet"; const supporting = dmSupportingTextByUserId.get(userId);
if (supporting) {
supporting.textContent = "";
supporting.style.display = "none";
}
} }
} }
@@ -271,9 +354,10 @@ init();
websocket.addEventListener("message", async (e) => { websocket.addEventListener("message", async (e) => {
try { try {
const msg: WebSocketMessage = JSON.parse((e as MessageEvent).data); const msg: WebSocketMessage = JSON.parse((e as MessageEvent).data);
if (msg.type === "dmNew" && activeDm && (msg.data.senderId === activeDm.userId || msg.data.recipientId === activeDm.userId)) { 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!); const plaintext = await decryptDm(msg.data, activeDm.publicKey!);
if (dmPanel) { if (dmPanel) {
const isAuthor = msg.data.senderId !== activeDm.userId; const isAuthor = msg.data.senderId !== activeDm.userId;
dmPanel.appendMessageWithId({ dmPanel.appendMessageWithId({
@@ -285,6 +369,32 @@ websocket.addEventListener("message", async (e) => {
is_edited: 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 {} } catch {}
}); });
+29 -4
View File
@@ -1,7 +1,8 @@
import { authToken, currentUser } from "../auth/api"; import { authToken, currentUser, getAuthHeaders } from "../auth/api";
import type { Message, WebSocketMessage } from "../core/types"; import { API_BASE_URL } from "../core/config";
import type { Message, Messages, WebSocketMessage } from "../core/types";
import { request } from "../websocket"; import { request } from "../websocket";
import { loadMessages } from "./chat"; import { addMessage } from "./chat";
import { show as showContextMenu } from "./contextMenu"; import { show as showContextMenu } from "./contextMenu";
import { show as showProfileDialog } from "./profileDialog"; import { show as showProfileDialog } from "./profileDialog";
@@ -80,7 +81,31 @@ export class PublicChatPanel extends ChatPanelController {
} }
protected loadMessages(): void { 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 { public onProfileClicked(): void {
+2 -9
View File
@@ -1,5 +1,5 @@
import { clearAlerts } from "./auth/auth"; import { clearAlerts } from "./auth/auth";
import { loadMessages } from "./chat/chat"; import { publicChatPanel } from "./chat/chat";
import { id } from "./utils/utils"; import { id } from "./utils/utils";
const loginForm = id("login-form"); const loginForm = id("login-form");
@@ -36,14 +36,7 @@ export function showChat(): void {
loginForm.style.display = 'none'; loginForm.style.display = 'none';
registerForm.style.display = 'none'; registerForm.style.display = 'none';
chatInterface.style.display = 'block'; chatInterface.style.display = 'block';
loadMessages();
titleBar.classList.remove("color-surface"); titleBar.classList.remove("color-surface");
}
/** publicChatPanel.activate();
* Loads the chat interface and initializes messaging.
*/
export function loadChat(): void {
showChat();
loadMessages();
} }