mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Improve DMs UI
This commit is contained in:
@@ -417,6 +417,12 @@
|
|||||||
<span class="stat-value last-seen"></span>
|
<span class="stat-value last-seen"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="profile-actions">
|
||||||
|
<mdui-button id="dm-button" variant="filled" style="display: none;">
|
||||||
|
<span class="material-symbols">chat</span>
|
||||||
|
Send Message
|
||||||
|
</mdui-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</mdui-dialog>
|
</mdui-dialog>
|
||||||
|
|||||||
@@ -84,9 +84,16 @@ export function show(message: Message, x: number, y: number): void {
|
|||||||
|
|
||||||
const isAuthor = message.username === currentUser?.username;
|
const isAuthor = message.username === currentUser?.username;
|
||||||
const isOwner = currentUser?.admin;
|
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';
|
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
|
// Position the menu properly
|
||||||
menu.style.display = 'block';
|
menu.style.display = 'block';
|
||||||
|
|||||||
+96
-27
@@ -1,12 +1,12 @@
|
|||||||
import { API_BASE_URL } from "../core/config";
|
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 { DmPanel } from "./panel";
|
||||||
import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric";
|
import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric";
|
||||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric";
|
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric";
|
||||||
import { randomBytes } from "../crypto/kdf";
|
import { randomBytes } from "../crypto/kdf";
|
||||||
import { getCurrentKeys } from "../auth/crypto";
|
import { getCurrentKeys } from "../auth/crypto";
|
||||||
import { request, websocket } from "../websocket";
|
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 type { Tabs } from "mdui/components/tabs";
|
||||||
import { b64, ub64 } from "../utils/utils";
|
import { b64, ub64 } from "../utils/utils";
|
||||||
|
|
||||||
@@ -88,9 +88,48 @@ async function loadUsers() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const list = document.getElementById("dm-users")!;
|
const list = document.getElementById("dm-users")!;
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
(data.users || []).forEach((u: any) => {
|
(data.users || []).forEach((u: User) => {
|
||||||
const item = document.createElement("mdui-list-item");
|
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);
|
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 () => {
|
item.addEventListener("click", async () => {
|
||||||
activeDm = { userId: u.id, username: u.username, publicKey: null };
|
activeDm = { userId: u.id, username: u.username, publicKey: null };
|
||||||
if (!dmPanel) {
|
if (!dmPanel) {
|
||||||
@@ -133,7 +172,7 @@ async function loadUsers() {
|
|||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
// Load DM history for the active conversation
|
// 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}`, {
|
const response = await fetch(`${API_BASE_URL}/dm/history/${activeDm.userId}`, {
|
||||||
headers: getAuthHeaders(true)
|
headers: getAuthHeaders(true)
|
||||||
});
|
});
|
||||||
@@ -145,17 +184,16 @@ async function loadUsers() {
|
|||||||
for (const env of messages) {
|
for (const env of messages) {
|
||||||
try {
|
try {
|
||||||
const text = await decryptDm(env, activeDm.publicKey);
|
const text = await decryptDm(env, activeDm.publicKey);
|
||||||
const div = document.createElement("div");
|
|
||||||
const isAuthor = env.senderId !== activeDm.userId;
|
const isAuthor = env.senderId !== activeDm.userId;
|
||||||
div.className = `message ${isAuthor ? "sent" : "received"}`;
|
const username = isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown");
|
||||||
const inner = document.createElement("div");
|
dmPanel.appendMessageWithId({
|
||||||
inner.className = "message-inner";
|
id: env.id,
|
||||||
const content = document.createElement("div");
|
content: text,
|
||||||
content.className = "message-content";
|
username: username,
|
||||||
content.textContent = text;
|
timestamp: env.timestamp,
|
||||||
inner.appendChild(content);
|
is_read: false,
|
||||||
div.appendChild(inner);
|
is_edited: false
|
||||||
container.appendChild(div);
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
@@ -163,9 +201,18 @@ async function loadUsers() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
dmPanel.setOtherUser(u.username);
|
||||||
dmPanel.setTitle(u.username);
|
dmPanel.setTitle(u.username);
|
||||||
dmPanel.clearMessages();
|
dmPanel.clearMessages();
|
||||||
dmPanel.activate();
|
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) });
|
const resPk = await fetch(`${API_BASE_URL}/crypto/public-key/of/${u.id}`, { headers: getAuthHeaders(true) });
|
||||||
if (resPk.ok) {
|
if (resPk.ok) {
|
||||||
const pkData = await resPk.json();
|
const pkData = await resPk.json();
|
||||||
@@ -176,6 +223,30 @@ async function loadUsers() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadLastMessage(userId: number, element: HTMLElement): Promise<void> {
|
||||||
|
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() {
|
function init() {
|
||||||
const tabs = document.querySelector(".chat-tabs mdui-tabs") as Tabs;
|
const tabs = document.querySelector(".chat-tabs mdui-tabs") as Tabs;
|
||||||
const dmTab = tabs?.querySelector('mdui-tab[value="dms"]')!;
|
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)) {
|
if (msg.type === "dmNew" && activeDm && (msg.data.senderId === activeDm.userId || msg.data.recipientId === activeDm.userId)) {
|
||||||
const plaintext = await decryptDm(msg.data, activeDm.publicKey!);
|
const plaintext = await decryptDm(msg.data, activeDm.publicKey!);
|
||||||
|
|
||||||
const container = document.getElementById("chat-messages")!;
|
if (dmPanel) {
|
||||||
const div = document.createElement("div");
|
const isAuthor = msg.data.senderId !== activeDm.userId;
|
||||||
const isAuthor = msg.data.senderId !== activeDm.userId;
|
dmPanel.appendMessageWithId({
|
||||||
div.className = `message ${isAuthor ? "sent" : "received"}`;
|
id: msg.data.id,
|
||||||
const inner = document.createElement("div");
|
content: plaintext,
|
||||||
inner.className = "message-inner";
|
username: isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown"),
|
||||||
const content = document.createElement("div");
|
timestamp: msg.data.timestamp,
|
||||||
content.className = "message-content";
|
is_read: false,
|
||||||
content.textContent = plaintext;
|
is_edited: false
|
||||||
inner.appendChild(content);
|
});
|
||||||
div.appendChild(inner);
|
}
|
||||||
container.appendChild(div);
|
|
||||||
container.scrollTop = container.scrollHeight;
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { authToken, currentUser } from "../auth/api";
|
import { authToken, currentUser } from "../auth/api";
|
||||||
import type { WebSocketMessage } from "../core/types";
|
import type { Message, WebSocketMessage } from "../core/types";
|
||||||
import { request } from "../websocket";
|
import { request } from "../websocket";
|
||||||
import { loadMessages } from "./chat";
|
import { loadMessages } from "./chat";
|
||||||
|
import { show as showContextMenu } from "./contextMenu";
|
||||||
|
import { show as showProfileDialog } from "./profileDialog";
|
||||||
|
|
||||||
const titleEl = document.getElementById("chat-name")!;
|
const titleEl = document.getElementById("chat-name")!;
|
||||||
const messages = document.getElementById("chat-messages")!;
|
const messages = document.getElementById("chat-messages")!;
|
||||||
@@ -43,6 +45,7 @@ export abstract class ChatPanelController {
|
|||||||
|
|
||||||
protected abstract onSubmit(text: string): void | Promise<void>;
|
protected abstract onSubmit(text: string): void | Promise<void>;
|
||||||
protected abstract loadMessages(): void | Promise<void>;
|
protected abstract loadMessages(): void | Promise<void>;
|
||||||
|
public abstract onProfileClicked(): void;
|
||||||
|
|
||||||
static mountOnce(): void {
|
static mountOnce(): void {
|
||||||
if (this.mounted) return;
|
if (this.mounted) return;
|
||||||
@@ -79,23 +82,69 @@ export class PublicChatPanel extends ChatPanelController {
|
|||||||
protected loadMessages(): void {
|
protected loadMessages(): void {
|
||||||
return loadMessages();
|
return loadMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onProfileClicked(): void {
|
||||||
|
// Public chat doesn't have a specific profile to show
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DmPanel extends ChatPanelController {
|
export class DmPanel extends ChatPanelController {
|
||||||
private sender: (text: string) => Promise<void>;
|
private sender: (text: string) => Promise<void>;
|
||||||
private loader: () => Promise<void> | void;
|
private loader: () => Promise<void> | void;
|
||||||
|
private otherUsername: string | null = null;
|
||||||
|
|
||||||
constructor(sender: (text: string) => Promise<void>, loader: () => Promise<void> | void) {
|
constructor(sender: (text: string) => Promise<void>, loader: () => Promise<void> | void) {
|
||||||
super();
|
super();
|
||||||
this.sender = sender;
|
this.sender = sender;
|
||||||
this.loader = loader;
|
this.loader = loader;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setOtherUser(username: string): void {
|
||||||
|
this.otherUsername = username;
|
||||||
|
}
|
||||||
|
|
||||||
protected async onSubmit(text: string): Promise<void> {
|
protected async onSubmit(text: string): Promise<void> {
|
||||||
this.appendSimple(text, true);
|
this.appendSimple(text, true);
|
||||||
await this.sender(text);
|
await this.sender(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected loadMessages(): void | Promise<void> {
|
protected loadMessages(): void | Promise<void> {
|
||||||
return this.loader();
|
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();
|
ChatPanelController.mountOnce();
|
||||||
@@ -9,7 +9,7 @@ import { getAuthHeaders, currentUser } from "../auth/api";
|
|||||||
import { API_BASE_URL } from "../core/config";
|
import { API_BASE_URL } from "../core/config";
|
||||||
import type { UserProfile } from "../core/types";
|
import type { UserProfile } from "../core/types";
|
||||||
import { showError, showSuccess } from "../utils/notification";
|
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 defaultAvatar from "../resources/images/default-avatar.png";
|
||||||
import type { Dialog } from "mdui/components/dialog";
|
import type { Dialog } from "mdui/components/dialog";
|
||||||
import type { TextField } from "mdui/components/text-field";
|
import type { TextField } from "mdui/components/text-field";
|
||||||
@@ -36,6 +36,41 @@ function bindEvents(): void {
|
|||||||
editBioBtn?.addEventListener('click', () => startEditBio());
|
editBioBtn?.addEventListener('click', () => startEditBio());
|
||||||
saveBioBtn?.addEventListener('click', () => saveBio());
|
saveBioBtn?.addEventListener('click', () => saveBio());
|
||||||
cancelBioBtn?.addEventListener('click', () => cancelEditBio());
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Vendored
+1
@@ -85,6 +85,7 @@ export interface User {
|
|||||||
username: string;
|
username: string;
|
||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
|
profile_picture: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user