mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Fix DMs displaying
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
+139
-29
@@ -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,19 +354,46 @@ 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") {
|
||||||
const plaintext = await decryptDm(msg.data, activeDm.publicKey!);
|
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)
|
||||||
if (dmPanel) {
|
const plaintext = await decryptDm(msg.data, activeDm.publicKey!);
|
||||||
const isAuthor = msg.data.senderId !== activeDm.userId;
|
if (dmPanel) {
|
||||||
dmPanel.appendMessageWithId({
|
const isAuthor = msg.data.senderId !== activeDm.userId;
|
||||||
id: msg.data.id,
|
dmPanel.appendMessageWithId({
|
||||||
content: plaintext,
|
id: msg.data.id,
|
||||||
username: isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown"),
|
content: plaintext,
|
||||||
timestamp: msg.data.timestamp,
|
username: isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown"),
|
||||||
is_read: false,
|
timestamp: msg.data.timestamp,
|
||||||
is_edited: false
|
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 {}
|
} catch {}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user