Implement profile

This commit is contained in:
2025-08-30 23:01:59 +03:00
Unverified
parent 3592262838
commit 9f2f6f86f9
26 changed files with 602 additions and 132 deletions
+216
View File
@@ -0,0 +1,216 @@
/**
* @fileoverview Chat functionality and message management
* @description Handles message display, loading, sending, and real-time updates
* @author Cursor
* @version 1.0.0
*/
import { API_BASE_URL } from "../core/config";
import { request } from "../websocket";
import type { Message, Messages, WebSocketMessage } from "../core/types";
import { formatTime } from "../utils/utils";
import { show as showContextMenu } from "./contextMenu";
import { show as showUserProfileDialog } from "./profileDialog";
import defaultAvatar from "../resources/images/default-avatar.png";
import { authToken, currentUser, getAuthHeaders } from "../auth/api";
import { ChatPanelController, PublicChatPanel } from "./panel";
/**
* Adds a new message to the chat interface
* @param {Message} message - Message object to display
* @param {boolean} isAuthor - Whether the current user is the message author
*/
export function addMessage(message: Message, isAuthor: boolean): void {
const messagesContainer = document.querySelector('.chat-messages') as HTMLElement;
const messageDiv = document.createElement('div');
messageDiv.classList.add("message");
if (isAuthor) {
messageDiv.classList.add("sent");
} else {
messageDiv.classList.add("received");
}
messageDiv.dataset.id = `${message.id}`;
const messageInner = document.createElement('div');
messageInner.classList.add('message-inner');
// Add profile picture for received messages
if (!isAuthor) {
const profilePicDiv = document.createElement('div');
profilePicDiv.classList.add('message-profile-pic');
const profileImg = document.createElement('img');
profileImg.src = message.profile_picture || defaultAvatar;
profileImg.alt = message.username;
let errorLock = false;
profileImg.addEventListener("error", () => {
if (!errorLock) {
profileImg.src = defaultAvatar;
errorLock = true;
}
});
// Add click handler to profile picture
profileImg.style.cursor = 'pointer';
profileImg.addEventListener('click', () => {
showUserProfileDialog(message.username);
});
profilePicDiv.appendChild(profileImg);
messageDiv.appendChild(profilePicDiv);
}
if (!isAuthor) {
const usernameDiv = document.createElement('div');
usernameDiv.classList.add('message-username');
usernameDiv.textContent = message.username;
// Add click handler to username
usernameDiv.style.cursor = 'pointer';
usernameDiv.addEventListener('click', () => {
showUserProfileDialog(message.username);
});
messageInner.appendChild(usernameDiv);
}
// Add reply preview if this is a reply
if (message.reply_to) {
const replyDiv = document.createElement('div');
replyDiv.classList.add('message-reply');
replyDiv.innerHTML = `
<div class="reply-content">
<span class="reply-username">${message.reply_to.username}</span>
<span class="reply-text">${message.reply_to.content}</span>
</div>
`;
messageInner.appendChild(replyDiv);
}
const contentDiv = document.createElement('div');
contentDiv.classList.add('message-content');
contentDiv.textContent = message.content;
messageInner.appendChild(contentDiv);
const timeDiv = document.createElement('div');
timeDiv.classList.add('message-time');
let timeText = formatTime(message.timestamp);
if (message.is_edited) {
timeText += ' (edited)';
}
timeDiv.textContent = timeText;
if (isAuthor && message.is_read) {
const checkIcon = document.createElement('span');
checkIcon.classList.add("material-symbols", "outlined");
timeDiv.appendChild(checkIcon);
}
messageInner.appendChild(timeDiv);
messageDiv.appendChild(messageInner);
messagesContainer.appendChild(messageDiv);
// Add right-click context menu
messageDiv.addEventListener('contextmenu', (e) => {
e.preventDefault();
showContextMenu(message, e.clientX, e.clientY);
});
// Прокрутка к новому сообщению
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
/**
* Sends a message via WebSocket
*/
export async function sendMessage(): Promise<void> {
const input = document.querySelector('.message-input') as HTMLInputElement;
const message = input.value.trim();
if (message) {
const response = await request({
data: {
content: message
},
credentials: {
scheme: "Bearer",
credentials: authToken!
},
type: "sendMessage"
})
console.log(response)
if (!response.error) {
input.value = "";
}
}
}
/**
* Updates an existing message in the chat interface
* @param {Message} message - Updated message object
*/
export function updateMessage(message: Message): void {
const messageElement = document.querySelector(`[data-id="${message.id}"]`) as HTMLElement;
if (!messageElement) return;
const contentDiv = messageElement.querySelector('.message-content') as HTMLElement;
const timeDiv = messageElement.querySelector('.message-time') as HTMLElement;
if (contentDiv) {
contentDiv.textContent = message.content;
}
if (timeDiv) {
let timeText = formatTime(message.timestamp);
if (message.is_edited) {
timeText += ' (edited)';
}
timeDiv.textContent = timeText;
}
}
/**
* Removes a message from the chat interface
* @param {number} messageId - ID of the message to remove
*/
export function removeMessage(messageId: number): void {
const messageElement = document.querySelector(`[data-id="${messageId}"]`) as HTMLElement;
if (messageElement) {
messageElement.remove();
}
}
/**
* Handles WebSocket message updates
* @param {WebSocketMessage} response - WebSocket response
*/
export function handleWebSocketMessage(response: WebSocketMessage): void {
if (ChatPanelController.active == publicChatPanel) {
switch (response.type) {
case 'messageEdited':
if (response.data) {
updateMessage(response.data);
}
break;
case 'messageDeleted':
if (response.data && response.data.message_id) {
removeMessage(response.data.message_id);
}
break;
case 'newMessage':
if (response.data) {
const isAuthor = response.data.username === currentUser?.username;
addMessage(response.data, isAuthor);
}
break;
}
}
}
export const publicChatPanel = new PublicChatPanel();
publicChatPanel.activate();
+321
View File
@@ -0,0 +1,321 @@
/**
* @fileoverview Message context menu functionality
* @description Handles right-click context menu for message actions (edit, delete, reply)
* @author Cursor
* @version 1.0.0
*/
import { request } from "../websocket";
import type { Message } from "../core/types";
import { showSuccess, showError } from "../utils/notification";
import { delay, id } from "../utils/utils";
import type { Dialog } from "mdui/components/dialog";
import type { TextField } from "mdui/components/text-field";
import { currentUser, authToken } from "../auth/api";
let menu = id("message-context-menu")!;
let editDialog = id<Dialog>("edit-message-dialog");
let replyDialog = id<Dialog>("reply-message-dialog");
let currentMessage: Message | null = null;
function init() {
bindEvents();
}
/**
* Binds event listeners
* @private
*/
function bindEvents(): void {
// Context menu events
menu?.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const action = target.closest('.context-menu-item')?.getAttribute('data-action');
if (action && currentMessage) {
handleAction(action, currentMessage);
}
});
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (!menu?.contains(e.target as Node)) {
hide();
}
});
// Edit dialog events
const editCancelBtn = editDialog?.querySelector('#edit-cancel');
const editSaveBtn = editDialog?.querySelector('#edit-save');
editCancelBtn?.addEventListener('click', () => hideEditDialog());
editSaveBtn?.addEventListener('click', () => saveEdit());
// Reply dialog events
const replyCancelBtn = replyDialog?.querySelector('#reply-cancel');
const replySendBtn = replyDialog?.querySelector('#reply-send');
replyCancelBtn?.addEventListener('click', () => hideReplyDialog());
replySendBtn?.addEventListener('click', () => sendReply());
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
hide();
hideEditDialog();
hideReplyDialog();
}
});
}
/**
* Shows the context menu at the specified position
* @param {Message} message - The message to show menu for
* @param {number} x - X coordinate
* @param {number} y - Y coordinate
*/
export function show(message: Message, x: number, y: number): void {
currentMessage = message;
// Show delete for own messages and for owner on any message
const editItem = menu.querySelector('[data-action="edit"]') as HTMLElement;
const deleteItem = menu.querySelector('[data-action="delete"]') as HTMLElement;
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';
// 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';
let menuWidth = menu.offsetWidth;
let menuHeight = menu.offsetHeight;
let adjustedX = x;
let adjustedY = y;
let vertical = "top";
let horizontal = "right";
// Adjust horizontal position if menu would go off-screen
if (x + menuWidth > window.innerWidth) {
adjustedX = x - menuWidth;
horizontal = "left";
}
// Adjust vertical position if menu would go off-screen
if (y + menuHeight > window.innerHeight) {
adjustedY = y - menuHeight;
vertical = "bottom";
}
// Ensure menu doesn't go off the left or top edges
adjustedX = Math.max(0, adjustedX);
adjustedY = Math.max(0, adjustedY);
menu.style.left = `${adjustedX}px`;
menu.style.top = `${adjustedY}px`;
menu.classList.add(`pos-${vertical}-${horizontal}`, "open");
}
/**
* Hides the context menu
*/
export function hide(): void {
menu.style.display = 'none';
menu.classList.forEach((name) => {
if (name.match(/pos-\w+-\w+/)) {
menu.classList.remove(name);
}
})
currentMessage = null;
}
/**
* Handles context menu actions
* @param {string} action - The action to perform
* @param {Message} message - The message to act on
* @private
*/
function handleAction(action: string, message: Message): void {
hide();
switch (action) {
case 'edit':
showEditDialog(message);
break;
case 'delete':
deleteMessage(message);
break;
case 'reply':
showReplyDialog(message);
break;
}
}
/**
* Shows the edit dialog
* @param {Message} message - The message to edit
* @private
*/
async function showEditDialog(message: Message): Promise<void> {
const textField = editDialog.querySelector('#edit-message-input') as TextField;
textField.value = message.content;
currentMessage = message;
editDialog.open = true;
// Focus the text field
await delay(100);
textField?.focus();
}
/**
* Hides the edit dialog
* @private
*/
function hideEditDialog(): void {
editDialog.open = false;
}
/**
* Saves the edited message
* @private
*/
async function saveEdit(): Promise<void> {
if (!currentMessage) return;
const textField = editDialog.querySelector('#edit-message-input') as TextField;
const newContent = textField?.value?.trim() || '';
if (!newContent) {
showError('Message cannot be empty');
return;
}
const response = await request({
type: "editMessage",
data: {
message_id: currentMessage.id,
content: newContent
},
credentials: {
scheme: "Bearer",
credentials: authToken!
}
});
if (response.error) {
showError(response.error.detail);
} else {
showSuccess('Message edited successfully');
hideEditDialog();
}
}
/**
* Shows the reply dialog
* @param {Message} message - The message to reply to
* @private
*/
async function showReplyDialog(message: Message): Promise<void> {
const preview = replyDialog.querySelector('#reply-preview') as HTMLElement;
preview.innerHTML = `
<div class="reply-preview-content">
<strong>${message.username}</strong>: ${message.content}
</div>
`;
currentMessage = message;
replyDialog.open = true;
// Focus the text field
await delay(100);
const textField = replyDialog?.querySelector('#reply-message-input') as TextField;
textField?.focus();
}
/**
* Hides the reply dialog
* @private
*/
function hideReplyDialog(): void {
replyDialog.open = false;
}
/**
* Sends the reply message
* @private
*/
async function sendReply(): Promise<void> {
if (!currentMessage) return;
const textField = replyDialog.querySelector('#reply-message-input') as TextField;
const content = textField?.value?.trim() || '';
if (!content) {
showError('Reply cannot be empty');
return;
}
const response = await request({
type: "replyMessage",
data: {
content: content,
reply_to_id: currentMessage.id
},
credentials: {
scheme: "Bearer",
credentials: authToken!
}
});
if (response.error) {
showError(response.error.detail);
} else {
showSuccess('Reply sent successfully');
hideReplyDialog();
if (textField) {
textField.value = '';
}
}
}
/**
* Deletes a message
* @param {Message} message - The message to delete
* @private
*/
async function deleteMessage(message: Message): Promise<void> {
if (!confirm('Are you sure you want to delete this message?')) {
return;
}
const response = await request({
type: "deleteMessage",
data: {
message_id: message.id
},
credentials: {
scheme: "Bearer",
credentials: authToken!
}
});
if (response.error) {
showError(response.error.detail);
} else {
showSuccess('Message deleted successfully');
}
}
init();
+393
View File
@@ -0,0 +1,393 @@
import { API_BASE_URL } from "../core/config";
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, User, DmEnvelope } from "../core/types";
import type { Tabs } from "mdui/components/tabs";
import { b64, ub64 } from "../utils/utils";
import defaultAvatar from "../resources/images/default-avatar.png";
export async function sendDm(recipientId: number, recipientPublicKeyB64: string, plaintext: string): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
const wrap = await aesGcmEncrypt(wk, mk);
await fetch(`${API_BASE_URL}/dm/send`, {
method: "POST",
headers: getAuthHeaders(true),
body: JSON.stringify({
recipientId: recipientId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
})
});
}
export async function fetchDm(since?: number): Promise<DmEnvelope[]> {
const url = new URL(`${API_BASE_URL}/dm/fetch`);
if (since) url.searchParams.set("since", String(since));
const response = await fetch(url, {
headers: getAuthHeaders(true)
});
if (response.ok) {
const data: FetchDMResponse = await response.json();
return data.messages ?? [];
} else {
return [];
}
}
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Obtain the key
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
// Decrypt
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
return new TextDecoder().decode(msg);
}
let activeDm: { userId: number; username: string; publicKey: string | null } | null = null;
let usersLoaded = false;
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() {
const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(true) });
if (!res.ok) return;
const data = await res.json();
const list = document.getElementById("dm-users")!;
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");
avatar.src = u.profile_picture || defaultAvatar;
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 = defaultAvatar;
});
item.appendChild(avatar);
// Set headline (username)
item.setAttribute("headline", u.username);
// Add supporting text container (hidden until loaded)
const lastMessageEl = document.createElement("div");
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);
observer.unobserve(entry.target);
}
});
});
observer.observe(item);
item.addEventListener("click", async () => {
activeDm = { userId: u.id, username: u.username, publicKey: null };
if (!dmPanel) {
dmPanel = new DmPanel(
async (text: string) => {
if (activeDm?.publicKey) {
// WebSocket realtime send
const keys = getCurrentKeys();
if (!keys) return;
// Encryption key
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = ecdhSharedSecret(keys.privateKey, ub64(activeDm.publicKey));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Encrypt the message
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(text));
const wrap = await aesGcmEncrypt(wk, mk);
const payload: SendDMRequest = {
recipientId: activeDm.userId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
}
request({
type: "dmSend",
credentials: {
scheme: "Bearer",
credentials: authToken!
},
data: payload
});
}
},
async () => {
// Load DM history for the active conversation
if (!activeDm?.publicKey || !dmPanel) return;
const response = await fetch(`${API_BASE_URL}/dm/history/${activeDm.userId}`, {
headers: getAuthHeaders(true)
});
if (response.ok) {
const data = await response.json();
const messages: DmEnvelope[] = data.messages || [];
const container = document.getElementById("chat-messages")!;
container.innerHTML = "";
let maxIncomingId = 0;
for (const env of messages) {
try {
// 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({
id: env.id,
content: text,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false
});
if (env.senderId === activeDm.userId && env.id > maxIncomingId) {
maxIncomingId = env.id;
}
} catch (e) {
console.error("Error while loading message:", e);
}
}
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 = "";
}
}
}
}
);
}
dmPanel.setOtherUser(u.username);
dmPanel.setTitle(u.username);
dmPanel.clearMessages();
// 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();
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): Promise<void> {
try {
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 || [];
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 {
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 {
const supporting = dmSupportingTextByUserId.get(userId);
if (supporting) {
supporting.textContent = "";
supporting.style.display = "none";
}
}
} catch (error) {
const supporting = dmSupportingTextByUserId.get(userId);
if (supporting) {
supporting.textContent = "";
supporting.style.display = "none";
}
}
}
function init() {
const tabs = document.querySelector(".chat-tabs mdui-tabs") as Tabs;
const dmTab = tabs?.querySelector('mdui-tab[value="dms"]')!;
function ensureUsersLoaded() {
if (!usersLoaded) {
usersLoaded = true;
loadUsers();
}
}
dmTab.addEventListener("click", ensureUsersLoaded);
tabs.addEventListener("change", (e: any) => {
if (e.detail?.value === "dms") {
ensureUsersLoaded();
dmPanel?.activate();
}
});
}
init();
// realtime incoming DMs
websocket.addEventListener("message", async (e) => {
try {
const msg: WebSocketMessage = JSON.parse((e as MessageEvent).data);
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 {}
});
+175
View File
@@ -0,0 +1,175 @@
import { authToken, currentUser, getAuthHeaders } from "../auth/api.ts";
import { API_BASE_URL } from "../core/config.ts";
import type { Message, Messages, WebSocketMessage } from "../core/types";
import { request } from "../websocket.ts";
import { addMessage } from "./chat.ts";
import { show as showContextMenu } from "./contextMenu.ts";
import { show as showProfileDialog } from "./profileDialog.ts";
const titleEl = document.getElementById("chat-name")!;
const messages = document.getElementById("chat-messages")!;
const input = document.getElementById("message-input") as HTMLInputElement;
const form = document.getElementById("message-form") as HTMLFormElement;
export abstract class ChatPanelController {
static active: ChatPanelController | null = null;
static mounted = false;
activate(): void {
ChatPanelController.active = this;
if (currentUser) {
this.loadMessages();
}
}
setTitle(title: string): void {
titleEl.textContent = title;
}
clearMessages(): void {
messages.innerHTML = "";
}
appendSimple(text: string, isAuthor: boolean): void {
const div = document.createElement("div");
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);
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
protected abstract onSubmit(text: string): void | Promise<void>;
protected abstract loadMessages(): void | Promise<void>;
public abstract onProfileClicked(): void;
static mountOnce(): void {
if (this.mounted) return;
this.mounted = true;
if (!form) return;
form.addEventListener(
"submit",
(e) => {
if (!ChatPanelController.active) return; // let others handle
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const text = input.value.trim();
if (!text) return;
Promise.resolve(ChatPanelController.active.onSubmit(text)).finally(() => {
input.value = "";
});
},
true
);
}
}
export class PublicChatPanel extends ChatPanelController {
protected async onSubmit(text: string): Promise<void> {
const payload: WebSocketMessage = {
data: { content: text },
credentials: { scheme: "Bearer", credentials: authToken! },
type: "sendMessage"
};
await request(payload);
}
protected loadMessages(): void {
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 chat doesn't have a specific profile to show
}
}
export class DmPanel extends ChatPanelController {
private sender: (text: string) => Promise<void>;
private loader: () => Promise<void> | void;
private otherUsername: string | null = null;
constructor(sender: (text: string) => Promise<void>, loader: () => Promise<void> | void) {
super();
this.sender = sender;
this.loader = loader;
}
setOtherUser(username: string): void {
this.otherUsername = username;
}
protected async onSubmit(text: string): Promise<void> {
this.appendSimple(text, true);
await this.sender(text);
}
protected loadMessages(): void | Promise<void> {
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();
+253
View File
@@ -0,0 +1,253 @@
/**
* @fileoverview User profile dialog functionality
* @description Handles displaying user profiles in a modal dialog
* @author Cursor
* @version 1.0.0
*/
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 { delay, formatTime, id } from "../utils/utils";
import defaultAvatar from "../resources/images/default-avatar.png";
import type { Tabs } from "mdui/components/tabs";
import type { Dialog } from "mdui/components/dialog";
import type { TextField } from "mdui/components/text-field";
let dialog = id<Dialog>("user-profile-dialog");
let currentProfile: UserProfile | null = null;
let isOwnProfile: boolean = false;
function init() {
bindEvents();
}
/**
* Binds event listeners
* @private
*/
function bindEvents(): void {
// Edit bio events
const editBioBtn = dialog?.querySelector('#edit-bio-btn');
const saveBioBtn = dialog?.querySelector('#save-bio-btn');
const cancelBioBtn = dialog?.querySelector('#cancel-bio-btn');
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<void> {
if (!currentProfile || isOwnProfile) return;
// Hide the profile dialog
hide();
// Switch to DMs tab
const tabs = document.querySelector('.chat-tabs mdui-tabs') as Tabs;
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;
}
}
}
}
/**
* Shows the profile dialog for a specific user
* @param {string} username - Username to show profile for
*/
export async function show(username: string): Promise<void> {
if (!dialog) return;
try {
const response = await fetch(`${API_BASE_URL}/user/${username}`, {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to load user profile');
}
const profile: UserProfile = await response.json();
currentProfile = profile;
isOwnProfile = profile.username === currentUser?.username;
populateDialog(profile);
dialog.open = true;
} catch (error) {
showError('Failed to load user profile');
console.error('Error loading user profile:', error);
}
}
/**
* Populates the dialog with user data
* @param {UserProfile} profile - User profile data
* @private
*/
function populateDialog(profile: UserProfile): void {
if (!dialog) return;
// Profile picture
const profilePic = dialog.querySelector('.profile-picture') as HTMLImageElement;
profilePic.src = profile.profile_picture || defaultAvatar;
let errorLock = false
profilePic.addEventListener("error", () => {
if (!errorLock) {
profilePic.src = defaultAvatar;
errorLock = true;
}
});
// Username
const usernameEl = dialog.querySelector('.username') as HTMLElement;
usernameEl.textContent = profile.username;
// Online status
const onlineStatus = dialog.querySelector('.online-status') as HTMLElement;
if (profile.online) {
onlineStatus.innerHTML = '<span class="online-indicator"></span> Online';
onlineStatus.classList.add("online-status", "online");
} else {
onlineStatus.innerHTML = `<span class="offline-indicator"></span> Last seen ${formatTime(profile.last_seen)}`;
onlineStatus.classList.add("online-status", "offline");
}
// Bio
const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement;
const bioEdit = dialog.querySelector('#bio-edit-field') as TextField;
if (profile.bio) {
bioDisplay.textContent = profile.bio;
} else {
bioDisplay.textContent = isOwnProfile ? 'No bio yet. Click "Edit Bio" to add one!' : 'No bio available.';
}
if (bioEdit) {
bioEdit.value = profile.bio || '';
}
// Stats
const memberSince = dialog.querySelector('.member-since') as HTMLElement;
const lastSeen = dialog.querySelector('.last-seen') as HTMLElement;
memberSince.textContent = formatTime(profile.created_at);
lastSeen.textContent = formatTime(profile.last_seen);
}
/**
* Starts editing the bio
* @private
*/
function startEditBio(): void {
if (!dialog) return;
const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement;
const bioEdit = dialog.querySelector('#bio-edit-field') as TextField;
const bioActions = dialog.querySelector('.bio-actions') as HTMLElement;
const editBioBtn = dialog.querySelector('#edit-bio-btn') as HTMLElement;
bioDisplay.style.display = 'none';
if (bioEdit) bioEdit.style.display = 'block';
bioActions.style.display = 'flex';
editBioBtn.style.display = 'none';
if (bioEdit) {
bioEdit.focus();
bioEdit.setSelectionRange(bioEdit.value.length, bioEdit.value.length);
}
}
/**
* Saves the bio
* @private
*/
export async function saveBio(): Promise<void> {
if (!dialog || !currentProfile) return;
const bioEdit = dialog.querySelector('#bio-edit-field') as TextField;
const newBio = bioEdit?.value?.trim() || '';
try {
const response = await fetch(`${API_BASE_URL}/user/bio`, {
method: 'PUT',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ bio: newBio })
});
if (!response.ok) {
throw new Error('Failed to update bio');
}
const result = await response.json();
currentProfile.bio = result.bio;
populateDialog(currentProfile);
cancelEditBio();
showSuccess('Bio updated successfully');
} catch (error) {
showError('Failed to update bio');
console.error('Error updating bio:', error);
}
}
/**
* Cancels bio editing
* @private
*/
function cancelEditBio(): void {
if (!dialog) return;
const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement;
const bioEdit = dialog.querySelector('#bio-edit-field') as TextField;
const bioActions = dialog.querySelector('.bio-actions') as HTMLElement;
const editBioBtn = dialog.querySelector('#edit-bio-btn') as HTMLElement;
bioDisplay.style.display = 'block';
if (bioEdit) bioEdit.style.display = 'none';
bioActions.style.display = 'none';
editBioBtn.style.display = isOwnProfile ? 'block' : 'none';
// Reset bio edit to current value
if (bioEdit) {
bioEdit.value = currentProfile?.bio || '';
}
}
/**
* Hides the dialog
*/
export function hide(): void {
dialog.open = false;
currentProfile = null;
isOwnProfile = false;
}
init();