mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Clean up the code
This commit is contained in:
@@ -1,216 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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();
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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();
|
|
||||||
@@ -1,393 +0,0 @@
|
|||||||
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 {}
|
|
||||||
});
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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();
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { clearAlerts } from "./auth/auth.ts";
|
|
||||||
import { publicChatPanel } from "./chat/chat.ts";
|
|
||||||
import { id } from "./utils/utils.ts";
|
|
||||||
|
|
||||||
const loginForm = id("login-form");
|
|
||||||
const registerForm = id("register-form");
|
|
||||||
const chatInterface = id("chat-interface");
|
|
||||||
const titleBar = id("electron-title-bar");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows the login form and hides other interfaces.
|
|
||||||
*/
|
|
||||||
export function showLogin(): void {
|
|
||||||
loginForm.style.display = 'flex';
|
|
||||||
registerForm.style.display = 'none';
|
|
||||||
chatInterface.style.display = 'none';
|
|
||||||
clearAlerts();
|
|
||||||
titleBar.classList.add("color-surface");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows the registration form and hides other interfaces.
|
|
||||||
*/
|
|
||||||
export function showRegister(): void {
|
|
||||||
loginForm.style.display = 'none';
|
|
||||||
registerForm.style.display = 'flex';
|
|
||||||
chatInterface.style.display = 'none';
|
|
||||||
clearAlerts();
|
|
||||||
titleBar.classList.add("color-surface");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows the chat interface and hides authentication forms.
|
|
||||||
*/
|
|
||||||
export function showChat(): void {
|
|
||||||
loginForm.style.display = 'none';
|
|
||||||
registerForm.style.display = 'none';
|
|
||||||
chatInterface.style.display = 'block';
|
|
||||||
titleBar.classList.remove("color-surface");
|
|
||||||
|
|
||||||
publicChatPanel.activate();
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Profile-related API calls
|
|
||||||
* @description Handles all profile-related HTTP requests to the backend
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getAuthHeaders } from '../../auth/api';
|
|
||||||
import type { ProfileData, UploadResponse } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads user profile data from the server
|
|
||||||
* @async
|
|
||||||
* @returns User profile data or null if failed
|
|
||||||
* @example
|
|
||||||
* const profile = await loadProfile();
|
|
||||||
* if (profile) {
|
|
||||||
* console.log('User nickname:', profile.nickname);
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export async function loadProfile(): Promise<ProfileData | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/user/profile', {
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
// Map backend fields to frontend fields
|
|
||||||
return {
|
|
||||||
profile_picture: data.profile_picture,
|
|
||||||
nickname: data.username,
|
|
||||||
description: data.bio
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading profile:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads a profile picture to the server
|
|
||||||
* @param {Blob} file - The image file to upload
|
|
||||||
* @returns {Promise<UploadResponse | null>} Upload response with URL or null if failed
|
|
||||||
* @example
|
|
||||||
* const fileInput = document.getElementById('file-input');
|
|
||||||
* const file = fileInput.files[0];
|
|
||||||
* const result = await uploadProfilePicture(file);
|
|
||||||
* if (result) {
|
|
||||||
* console.log('Uploaded to:', result.profile_picture_url);
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export async function uploadProfilePicture(file: Blob): Promise<UploadResponse | null> {
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('profile_picture', file, 'profile_picture.jpg');
|
|
||||||
|
|
||||||
const response = await fetch('/api/upload-profile-picture', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
headers: getAuthHeaders(false)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Upload error:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates user profile information
|
|
||||||
* @param {Partial<ProfileData>} data - Profile data to update
|
|
||||||
* @returns {Promise<boolean>} True if update was successful, false otherwise
|
|
||||||
* @example
|
|
||||||
* const success = await updateProfile({
|
|
||||||
* nickname: 'New Name',
|
|
||||||
* description: 'Updated bio'
|
|
||||||
* });
|
|
||||||
* if (success) {
|
|
||||||
* console.log('Profile updated successfully');
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export async function updateProfile(data: Partial<ProfileData>): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Map frontend fields to backend fields
|
|
||||||
const backendData = {
|
|
||||||
nickname: data.nickname,
|
|
||||||
description: data.description
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch('/api/user/profile', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
...getAuthHeaders(),
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(backendData)
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating profile:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates user bio
|
|
||||||
* @param {string} bio - New bio text
|
|
||||||
* @returns {Promise<boolean>} True if update was successful, false otherwise
|
|
||||||
* @example
|
|
||||||
* const success = await updateBio('My new bio text');
|
|
||||||
* if (success) {
|
|
||||||
* console.log('Bio updated successfully');
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export async function updateBio(bio: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/user/bio', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
...getAuthHeaders(),
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ bio })
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating bio:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Profile editing functionality
|
|
||||||
* @description Handles profile form editing and MDUI text field integration
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { updateProfile } from './api';
|
|
||||||
import { loadProfile } from './api';
|
|
||||||
import { showSuccess, showError } from '../../utils/notification';
|
|
||||||
import { TextField } from 'mdui/components/text-field';
|
|
||||||
import { id } from '../../utils/utils';
|
|
||||||
|
|
||||||
let profileForm = id('profile-form')!;
|
|
||||||
let nicknameField = id<TextField>('username-field');
|
|
||||||
let descriptionField = id<TextField>('description-field');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialization state flag
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
let isInitialized = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the username field value
|
|
||||||
* @param {string} value - The username value to set
|
|
||||||
*/
|
|
||||||
export function setUsernameValue(value: string): void {
|
|
||||||
if (nicknameField && nicknameField.value !== undefined) {
|
|
||||||
nicknameField.value = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the description field value
|
|
||||||
* @param {string} value - The description value to set
|
|
||||||
*/
|
|
||||||
export function setDescriptionValue(value: string): void {
|
|
||||||
if (descriptionField && descriptionField.value !== undefined) {
|
|
||||||
descriptionField.value = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the current username field value
|
|
||||||
* @returns {string} The current username value
|
|
||||||
*/
|
|
||||||
export function getUsernameValue(): string {
|
|
||||||
if (nicknameField && nicknameField.value !== undefined) {
|
|
||||||
return nicknameField.value;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the current description field value
|
|
||||||
* @returns {string} The current description value
|
|
||||||
*/
|
|
||||||
export function getDescriptionValue(): string {
|
|
||||||
if (descriptionField && descriptionField.value !== undefined) {
|
|
||||||
return descriptionField.value;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads profile data from the server and populates the form fields
|
|
||||||
*/
|
|
||||||
export async function loadProfileData(): Promise<void> {
|
|
||||||
const userData = await loadProfile();
|
|
||||||
if (userData) {
|
|
||||||
if (userData.nickname) {
|
|
||||||
setUsernameValue(userData.nickname);
|
|
||||||
}
|
|
||||||
if (userData.description) {
|
|
||||||
setDescriptionValue(userData.description);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles profile form submission
|
|
||||||
* @param {Event} e - Form submission event
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
async function handleFormSubmission(e: Event): Promise<void> {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const nickname = getUsernameValue();
|
|
||||||
const description = getDescriptionValue();
|
|
||||||
|
|
||||||
if (nickname || description) {
|
|
||||||
const success = await updateProfile({
|
|
||||||
nickname: nickname || undefined,
|
|
||||||
description: description || undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
showSuccess('Профиль обновлен!');
|
|
||||||
} else {
|
|
||||||
showError('Ошибка при обновлении профиля');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up form submission handler
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function setupFormHandler(): void {
|
|
||||||
if (!isInitialized) {
|
|
||||||
profileForm.addEventListener('submit', handleFormSubmission);
|
|
||||||
isInitialized = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes profile editor functionality
|
|
||||||
*/
|
|
||||||
export function initializeProfileEditor(): void {
|
|
||||||
setupFormHandler();
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Canvas-based image cropping component
|
|
||||||
* @description Provides circular image cropping functionality with drag support
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Size2D } from "../../core/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Image cropper class for circular profile picture cropping
|
|
||||||
* @class ImageCropper
|
|
||||||
*/
|
|
||||||
export class ImageCropper {
|
|
||||||
private canvas: HTMLCanvasElement;
|
|
||||||
private ctx: CanvasRenderingContext2D;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Image element to be cropped
|
|
||||||
* @type {HTMLImageElement}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private image!: HTMLImageElement;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Size of the crop area (diameter)
|
|
||||||
* @type {number}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private cropSize: number = 200;
|
|
||||||
private isDragging: boolean = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starting position of the drag operation
|
|
||||||
* @type {Size2D}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private dragStart: Size2D = { x: 0, y: 0 };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Current position of the crop area
|
|
||||||
* @type {Size2D}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private cropPosition: Size2D = { x: 0, y: 0 };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new ImageCropper instance
|
|
||||||
* @param {HTMLElement} container - Container element to append the canvas to
|
|
||||||
* @constructor
|
|
||||||
* @example
|
|
||||||
* const cropper = new ImageCropper(document.getElementById('cropper-area'));
|
|
||||||
*/
|
|
||||||
constructor(container: HTMLElement) {
|
|
||||||
this.canvas = document.createElement('canvas');
|
|
||||||
this.canvas.width = this.cropSize;
|
|
||||||
this.canvas.height = this.cropSize;
|
|
||||||
this.ctx = this.canvas.getContext('2d')!;
|
|
||||||
|
|
||||||
container.appendChild(this.canvas);
|
|
||||||
this.setupEventListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up mouse and touch event listeners
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private setupEventListeners(): void {
|
|
||||||
this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this));
|
|
||||||
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
|
|
||||||
this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this));
|
|
||||||
this.canvas.addEventListener('touchstart', this.onTouchStart.bind(this));
|
|
||||||
this.canvas.addEventListener('touchmove', this.onTouchMove.bind(this));
|
|
||||||
this.canvas.addEventListener('touchend', this.onTouchEnd.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles mouse down events
|
|
||||||
* @param {MouseEvent} e - Mouse event
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private onMouseDown(e: MouseEvent): void {
|
|
||||||
this.isDragging = true;
|
|
||||||
this.dragStart = { x: e.clientX, y: e.clientY };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles mouse move events during dragging
|
|
||||||
* @param {MouseEvent} e - Mouse event
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private onMouseMove(e: MouseEvent): void {
|
|
||||||
if (!this.isDragging) return;
|
|
||||||
|
|
||||||
const deltaX = e.clientX - this.dragStart.x;
|
|
||||||
const deltaY = e.clientY - this.dragStart.y;
|
|
||||||
|
|
||||||
this.cropPosition.x += deltaX;
|
|
||||||
this.cropPosition.y += deltaY;
|
|
||||||
|
|
||||||
this.dragStart = { x: e.clientX, y: e.clientY };
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles mouse up events
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private onMouseUp(): void {
|
|
||||||
this.isDragging = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles touch start events
|
|
||||||
* @param {TouchEvent} e - Touch event
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private onTouchStart(e: TouchEvent): void {
|
|
||||||
e.preventDefault();
|
|
||||||
const touch = e.touches[0];
|
|
||||||
this.isDragging = true;
|
|
||||||
this.dragStart = { x: touch.clientX, y: touch.clientY };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles touch move events during dragging
|
|
||||||
* @param {TouchEvent} e - Touch event
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private onTouchMove(e: TouchEvent): void {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!this.isDragging) return;
|
|
||||||
|
|
||||||
const touch = e.touches[0];
|
|
||||||
const deltaX = touch.clientX - this.dragStart.x;
|
|
||||||
const deltaY = touch.clientY - this.dragStart.y;
|
|
||||||
|
|
||||||
this.cropPosition.x += deltaX;
|
|
||||||
this.cropPosition.y += deltaY;
|
|
||||||
|
|
||||||
this.dragStart = { x: touch.clientX, y: touch.clientY };
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles touch end events
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private onTouchEnd(): void {
|
|
||||||
this.isDragging = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads an image file for cropping
|
|
||||||
* @param {File} file - Image file to load
|
|
||||||
* @returns {Promise<void>} Promise that resolves when image is loaded
|
|
||||||
*/
|
|
||||||
loadImage(file: File): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
this.image = new Image();
|
|
||||||
this.image.onload = () => {
|
|
||||||
this.render();
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
this.image.src = URL.createObjectURL(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the image with circular crop overlay
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private render(): void {
|
|
||||||
if (!this.image) return;
|
|
||||||
|
|
||||||
// Clear canvas
|
|
||||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
||||||
|
|
||||||
// Calculate crop area
|
|
||||||
const scale = Math.max(this.cropSize / this.image.width, this.cropSize / this.image.height);
|
|
||||||
const scaledWidth = this.image.width * scale;
|
|
||||||
const scaledHeight = this.image.height * scale;
|
|
||||||
|
|
||||||
// Draw image
|
|
||||||
this.ctx.save();
|
|
||||||
this.ctx.globalCompositeOperation = 'source-over';
|
|
||||||
this.ctx.drawImage(
|
|
||||||
this.image,
|
|
||||||
this.cropPosition.x,
|
|
||||||
this.cropPosition.y,
|
|
||||||
scaledWidth,
|
|
||||||
scaledHeight
|
|
||||||
);
|
|
||||||
this.ctx.restore();
|
|
||||||
|
|
||||||
// Draw crop overlay
|
|
||||||
this.ctx.save();
|
|
||||||
this.ctx.globalCompositeOperation = 'destination-in';
|
|
||||||
this.ctx.beginPath();
|
|
||||||
this.ctx.arc(this.cropSize / 2, this.cropSize / 2, this.cropSize / 2, 0, 2 * Math.PI);
|
|
||||||
this.ctx.fill();
|
|
||||||
this.ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the cropped image as a data URL
|
|
||||||
* @returns {string} Data URL of the cropped image
|
|
||||||
*/
|
|
||||||
getCroppedImage(): string {
|
|
||||||
return this.canvas.toDataURL('image/jpeg', 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroys the cropper and removes the canvas from DOM
|
|
||||||
*/
|
|
||||||
destroy(): void {
|
|
||||||
this.canvas.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Profile module entry point and initialization
|
|
||||||
* @description Coordinates profile system initialization and form handling
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Dialog } from "mdui/components/dialog";
|
|
||||||
import { loadProfileData } from './editor';
|
|
||||||
import { loadProfilePicture, initializeProfileUpload } from "./upload";
|
|
||||||
import { initializeProfileEditor } from './editor';
|
|
||||||
import { id } from "../../utils/utils";
|
|
||||||
|
|
||||||
// Handle profile form submission
|
|
||||||
const form = id("profile-form")!;
|
|
||||||
const dialog = id<Dialog>("profile-dialog");
|
|
||||||
|
|
||||||
form.addEventListener("submit", async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// TODO: Process form data if needed
|
|
||||||
// For now, just close the dialog
|
|
||||||
dialog.open = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes profile functionality after user login
|
|
||||||
*/
|
|
||||||
export function initializeProfile(): void {
|
|
||||||
// Initialize profile modules
|
|
||||||
initializeProfileUpload();
|
|
||||||
initializeProfileEditor();
|
|
||||||
|
|
||||||
// Load profile data
|
|
||||||
Promise.all([
|
|
||||||
loadProfilePicture(),
|
|
||||||
loadProfileData()
|
|
||||||
]).catch(error => {
|
|
||||||
console.error('Error initializing profile:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Profile-specific type definitions
|
|
||||||
* @description Contains type definitions for profile-related functionality
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User profile data structure
|
|
||||||
* @interface ProfileData
|
|
||||||
* @property {string} [profile_picture] - URL to user's profile picture
|
|
||||||
* @property {string} [nickname] - User's display name
|
|
||||||
* @property {string} [description] - User's bio or description
|
|
||||||
*/
|
|
||||||
export interface ProfileData {
|
|
||||||
profile_picture?: string;
|
|
||||||
nickname?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Profile picture upload response structure
|
|
||||||
* @interface UploadResponse
|
|
||||||
* @property {string} profile_picture_url - URL to the uploaded profile picture
|
|
||||||
*/
|
|
||||||
export interface UploadResponse {
|
|
||||||
profile_picture_url: string;
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Profile picture upload functionality
|
|
||||||
* @description Handles file selection, image cropping, and profile picture upload
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Dialog } from "mdui/components/dialog";
|
|
||||||
import { ImageCropper } from './imageCropper';
|
|
||||||
import { uploadProfilePicture } from './api';
|
|
||||||
import { loadProfile } from './api';
|
|
||||||
import { showSuccess, showError } from '../../utils/notification';
|
|
||||||
import { id } from "../../utils/utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global image cropper instance
|
|
||||||
*/
|
|
||||||
let cropper: ImageCropper | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialization state flag
|
|
||||||
*/
|
|
||||||
let isInitialized = false;
|
|
||||||
|
|
||||||
let cropperDialog = id<Dialog>('cropper-dialog');
|
|
||||||
let fileInput = id<HTMLInputElement>('pfp-file-input');
|
|
||||||
let uploadBtn = id('upload-pfp-btn');
|
|
||||||
let cropSaveBtn = id('crop-save');
|
|
||||||
let cropCancelBtn = id('crop-cancel');
|
|
||||||
let cropperCloseBtn = id('cropper-close');
|
|
||||||
let cropperArea = id('cropper-area');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the image cropper with the selected file
|
|
||||||
* @param {File} file - The image file to crop
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
async function openCropper(file: File): Promise<void> {
|
|
||||||
// Clear previous cropper
|
|
||||||
cropperArea.innerHTML = '';
|
|
||||||
|
|
||||||
// Create new cropper
|
|
||||||
cropper = new ImageCropper(cropperArea);
|
|
||||||
|
|
||||||
// Load image
|
|
||||||
await cropper.loadImage(file);
|
|
||||||
|
|
||||||
// Open dialog
|
|
||||||
cropperDialog.open = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Closes the image cropper and cleans up resources
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function closeCropper(): void {
|
|
||||||
cropperDialog.open = false;
|
|
||||||
cropperArea.innerHTML = '';
|
|
||||||
if (cropper) {
|
|
||||||
cropper.destroy();
|
|
||||||
cropper = null;
|
|
||||||
}
|
|
||||||
fileInput.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the cropped image and uploads it to the server
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
async function saveCroppedImage(): Promise<void> {
|
|
||||||
if (!cropper) return;
|
|
||||||
|
|
||||||
const croppedImageData = cropper.getCroppedImage();
|
|
||||||
|
|
||||||
// Convert data URL to blob
|
|
||||||
const response = await fetch(croppedImageData);
|
|
||||||
const blob = await response.blob();
|
|
||||||
|
|
||||||
const result = await uploadProfilePicture(blob);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
// Update profile picture display
|
|
||||||
const profilePicture = id<HTMLInputElement>('profile-picture');
|
|
||||||
profilePicture.src = `${result.profile_picture_url}?t=${Date.now()}`; // Cache bust
|
|
||||||
|
|
||||||
// Close cropper
|
|
||||||
closeCropper();
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
showSuccess('Фото профиля обновлено!');
|
|
||||||
} else {
|
|
||||||
showError('Ошибка при загрузке фото');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up event listeners for upload functionality
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function setupEventListeners(): void {
|
|
||||||
if (isInitialized) return;
|
|
||||||
|
|
||||||
uploadBtn.addEventListener('click', () => {
|
|
||||||
fileInput.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
fileInput.addEventListener('change', (e) => {
|
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
|
||||||
if (file) {
|
|
||||||
openCropper(file);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cropSaveBtn.addEventListener('click', () => {
|
|
||||||
saveCroppedImage();
|
|
||||||
});
|
|
||||||
|
|
||||||
cropCancelBtn.addEventListener('click', () => {
|
|
||||||
closeCropper();
|
|
||||||
});
|
|
||||||
|
|
||||||
cropperCloseBtn.addEventListener('click', () => {
|
|
||||||
closeCropper();
|
|
||||||
});
|
|
||||||
|
|
||||||
isInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads and displays the user's profile picture
|
|
||||||
* @async
|
|
||||||
*/
|
|
||||||
export async function loadProfilePicture(): Promise<void> {
|
|
||||||
const userData = await loadProfile();
|
|
||||||
if (userData?.profile_picture) {
|
|
||||||
const url = `${userData.profile_picture}?t=${Date.now()}`;
|
|
||||||
|
|
||||||
const profilePicture = id<HTMLImageElement>('profile-picture');
|
|
||||||
const profilePicture2 = id<HTMLImageElement>("preview1");
|
|
||||||
profilePicture.src = url;
|
|
||||||
profilePicture2.src = url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes profile upload functionality
|
|
||||||
*/
|
|
||||||
export function initializeProfileUpload(): void {
|
|
||||||
setupEventListeners();
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Settings dialog management and panel navigation
|
|
||||||
* @description Handles settings dialog functionality and dynamic panel switching
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Dialog } from "mdui/components/dialog";
|
|
||||||
import { id } from "../utils/utils";
|
|
||||||
|
|
||||||
const dialog = id<Dialog>('settings-dialog');
|
|
||||||
const openButton = id('settings-open');
|
|
||||||
const closeButton = id('settings-close');
|
|
||||||
|
|
||||||
// Settings panel management
|
|
||||||
const settingsList = document.querySelector('#settings-menu mdui-list')!;
|
|
||||||
const settingsPanels = document.querySelectorAll('.settings-panel');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mapping between list item text and their corresponding panel IDs
|
|
||||||
*/
|
|
||||||
const panelMapping: {[x: string]: string} = {
|
|
||||||
'Уведомления': 'notifications-settings',
|
|
||||||
'Внешний вид': 'appearance-settings',
|
|
||||||
'Безопасность': 'security-settings',
|
|
||||||
'Язык': 'language-settings',
|
|
||||||
'Хранилище': 'storage-settings',
|
|
||||||
'Помощь': 'help-settings',
|
|
||||||
'О приложении': 'about-settings'
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles click events on settings list items
|
|
||||||
* @param {Element} item - The clicked list item element
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function handleListItemClick(item: Element): void {
|
|
||||||
// Remove active class from all items and panels
|
|
||||||
const listItems = settingsList.querySelectorAll('mdui-list-item');
|
|
||||||
listItems.forEach(li => li.removeAttribute('active'));
|
|
||||||
settingsPanels.forEach(panel => panel.classList.remove('active'));
|
|
||||||
|
|
||||||
// Add active class to clicked item
|
|
||||||
item.setAttribute('active', '');
|
|
||||||
|
|
||||||
// Show corresponding panel using the mapping
|
|
||||||
const itemText = item.textContent!.trim();
|
|
||||||
const panelId = panelMapping[itemText];
|
|
||||||
|
|
||||||
if (panelId) {
|
|
||||||
const targetPanel = id(panelId);
|
|
||||||
if (targetPanel) {
|
|
||||||
targetPanel.classList.add('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resets settings dialog to show the first panel
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function resetToFirstPanel(): void {
|
|
||||||
const firstItem = settingsList.querySelector('mdui-list-item');
|
|
||||||
const firstPanel = document.querySelector('.settings-panel');
|
|
||||||
|
|
||||||
if (firstItem && firstPanel) {
|
|
||||||
settingsList.querySelectorAll('mdui-list-item').forEach(li => li.removeAttribute('active'));
|
|
||||||
settingsPanels.forEach(panel => panel.classList.remove('active'));
|
|
||||||
firstItem.setAttribute('active', '');
|
|
||||||
firstPanel.classList.add('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes settings.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function init() {
|
|
||||||
// Set up settings navigation
|
|
||||||
settingsList.querySelectorAll('mdui-list-item').forEach((item) => {
|
|
||||||
item.addEventListener('click', () => handleListItemClick(item));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up dialog listeners
|
|
||||||
openButton.addEventListener('click', () => {
|
|
||||||
dialog.open = true;
|
|
||||||
resetToFirstPanel();
|
|
||||||
});
|
|
||||||
|
|
||||||
closeButton.addEventListener('click', () => {
|
|
||||||
dialog.open = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Left panel UI controls and interactions
|
|
||||||
* @description Handles chat collapse/expand, chat switching, and profile dialog
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Dialog } from "mdui/components/dialog";
|
|
||||||
import { loadProfilePicture } from "./profile/upload";
|
|
||||||
import { id } from "../utils/utils";
|
|
||||||
import { publicChatPanel } from "../chat/chat.ts";
|
|
||||||
|
|
||||||
// сварачивание и разворачивание чата
|
|
||||||
const chatCollapseBtn = id('hide-chat')!;
|
|
||||||
const chat1 = id('chat-list-chat-1')!;
|
|
||||||
const chat2 = id('chat-list-chat-2')!;
|
|
||||||
const chatInner = id('chat-inner')!;
|
|
||||||
const chatContainer = document.querySelector('#chat-interface .chat-container') as HTMLElement;
|
|
||||||
const chatName = id('chat-name')!;
|
|
||||||
const profileButton = id('profile-open')!;
|
|
||||||
const dialog = id<Dialog>("profile-dialog");
|
|
||||||
const dialogClose = id("profile-dialog-close")!;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up chat collapse functionality
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function setupChatCollapse(): void {
|
|
||||||
chatCollapseBtn.addEventListener('click', () => {
|
|
||||||
chatCollapseBtn.style.display = 'none';
|
|
||||||
chatInner.style.display = 'none';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up chat switching functionality
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function animateChatSwitch(updateFn: () => void): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// Ensure panel is visible
|
|
||||||
chatCollapseBtn.style.display = 'flex';
|
|
||||||
chatInner.style.display = 'flex';
|
|
||||||
|
|
||||||
// Start out animation
|
|
||||||
if (!chatContainer) {
|
|
||||||
reject("Chat container is missing");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
chatContainer.classList.remove('chat-switch-in');
|
|
||||||
chatContainer.classList.add('chat-switch-out');
|
|
||||||
|
|
||||||
const onOutEnd = () => {
|
|
||||||
chatContainer.removeEventListener('animationend', onOutEnd);
|
|
||||||
// Update content while hidden
|
|
||||||
updateFn();
|
|
||||||
|
|
||||||
// Then play in animation from the same offset
|
|
||||||
chatContainer.classList.remove('chat-switch-out');
|
|
||||||
chatContainer.classList.add('chat-switch-in');
|
|
||||||
|
|
||||||
const onInEnd = () => {
|
|
||||||
chatContainer.removeEventListener("animationend", onInEnd);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
chatContainer.addEventListener("animationend", onInEnd);
|
|
||||||
};
|
|
||||||
|
|
||||||
chatContainer.addEventListener('animationend', onOutEnd);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupChatSwitching(): void {
|
|
||||||
chat1.addEventListener('click', () => {
|
|
||||||
animateChatSwitch(() => {
|
|
||||||
chatName.textContent = 'Общий чат';
|
|
||||||
publicChatPanel.activate();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
chat2.addEventListener('click', () => {
|
|
||||||
animateChatSwitch(() => {
|
|
||||||
chatName.textContent = 'Общий чат 2';
|
|
||||||
publicChatPanel.activate();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up profile dialog functionality
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function setupProfileDialog(): void {
|
|
||||||
profileButton.addEventListener('click', () => {
|
|
||||||
dialog.open = true;
|
|
||||||
loadProfilePicture();
|
|
||||||
});
|
|
||||||
|
|
||||||
dialogClose.addEventListener("click", () => {
|
|
||||||
dialog.open = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setupChatCollapse();
|
|
||||||
setupChatSwitching();
|
|
||||||
setupProfileDialog();
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { API_BASE_URL } from "../core/config";
|
import { API_BASE_URL } from "../core/config";
|
||||||
import { getAuthHeaders } from "../auth/api";
|
import { getAuthHeaders } from "../auth/api";
|
||||||
import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric";
|
import { ecdhSharedSecret, deriveWrappingKey } from "../utils/crypto/asymmetric";
|
||||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric";
|
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../utils/crypto/symmetric";
|
||||||
import { randomBytes } from "../crypto/kdf";
|
import { randomBytes } from "../utils/crypto/kdf";
|
||||||
import { getCurrentKeys } from "../auth/crypto";
|
import { getCurrentKeys } from "../auth/crypto";
|
||||||
import { request } from "../websocket";
|
import { request } from "../core/websocket";
|
||||||
import type { FetchDMResponse, SendDMRequest, DmEnvelope, User } from "../core/types";
|
import type { FetchDMResponse, SendDMRequest, DmEnvelope, User } from "../core/types";
|
||||||
import { b64, ub64 } from "../utils/utils";
|
import { b64, ub64 } from "../utils/utils";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getAuthHeaders } from "../../auth/api";
|
import { getAuthHeaders } 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";
|
||||||
|
|
||||||
export interface ProfileData {
|
export interface ProfileData {
|
||||||
profile_picture?: string;
|
profile_picture?: string;
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
// import { showLogin } from "../navigation";
|
|
||||||
import type { Headers } from "../core/types";
|
import type { Headers } from "../core/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,32 +16,4 @@ export function getAuthHeaders(token: string | null, json: boolean = true): Head
|
|||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks authentication status on page load
|
|
||||||
*/
|
|
||||||
export async function checkAuthStatus(): Promise<void> {
|
|
||||||
// For JWT, we don't have a persistent token on page load
|
|
||||||
// So we'll just show the login form
|
|
||||||
// showLogin();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs out the current user and clears session data
|
|
||||||
*/
|
|
||||||
// export async function logout(): Promise<void> {
|
|
||||||
// try {
|
|
||||||
// await fetch(`${API_BASE_URL}/logout`, {
|
|
||||||
// method: 'GET',
|
|
||||||
// headers: getAuthHeaders()
|
|
||||||
// });
|
|
||||||
// } catch (error) {
|
|
||||||
// console.error('Logout error:', error);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// currentUser = null;
|
|
||||||
// authToken = null;
|
|
||||||
// // showLogin();
|
|
||||||
// clearAlerts();
|
|
||||||
// }
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Authentication system implementation
|
|
||||||
* @description Handles user authentication, registration, and session management
|
|
||||||
* @author Cursor
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { id } from "../utils/utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears all alert messages from authentication forms
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
export function clearAlerts(): void {
|
|
||||||
id('login-alerts').innerHTML = '';
|
|
||||||
id('register-alerts').innerHTML = '';
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { API_BASE_URL } from "../core/config";
|
import { API_BASE_URL } from "../core/config";
|
||||||
import { getAuthHeaders } from "./api";
|
import { getAuthHeaders } from "./api";
|
||||||
import { generateX25519KeyPair } from "../crypto/asymmetric";
|
import { generateX25519KeyPair } from "../utils/crypto/asymmetric";
|
||||||
import { encryptBackupWithPassword, decryptBackupWithPassword, encodeBlob, decodeBlob } from "../crypto/backup";
|
import { encryptBackupWithPassword, decryptBackupWithPassword, encodeBlob, decodeBlob } from "../utils/crypto/backup";
|
||||||
import { b64, ub64 } from "../utils/utils";
|
import { b64, ub64 } from "../utils/utils";
|
||||||
import type { BackupBlob, UploadPublicKeyRequest } from "../core/types";
|
import type { BackupBlob, UploadPublicKeyRequest } from "../core/types";
|
||||||
|
|
||||||
@@ -97,6 +97,4 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis
|
|||||||
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||||
await uploadBackupBlob(encodeBlob(encBlob), token);
|
await uploadBackupBlob(encodeBlob(encBlob), token);
|
||||||
return { publicKey: currentPublicKey, privateKey: currentPrivateKey };
|
return { publicKey: currentPublicKey, privateKey: currentPrivateKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
* @version 1.0.0
|
* @version 1.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { API_WS_BASE_URL } from "./core/config";
|
import { API_WS_BASE_URL } from "./config";
|
||||||
import type { WebSocketMessage } from "./core/types";
|
import type { WebSocketMessage } from "./types";
|
||||||
import { delay } from "./utils/utils";
|
import { delay } from "../utils/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new WebSocket connection to the chat server
|
* Creates a new WebSocket connection to the chat server
|
||||||
+1
-10
@@ -9,16 +9,7 @@ import './resources/css/style.scss';
|
|||||||
import "mdui/mdui.css";
|
import "mdui/mdui.css";
|
||||||
|
|
||||||
import "./utils/material";
|
import "./utils/material";
|
||||||
// import "./chat/chat";
|
import "./core/init";
|
||||||
// import "./userPanel/settings";
|
|
||||||
// import "./userPanel/userpanel";
|
|
||||||
// import "./core/init";
|
|
||||||
// import "./userPanel/profile/profile";
|
|
||||||
// import "./chat/contextMenu";
|
|
||||||
// import "./chat/profileDialog";
|
|
||||||
// import "./electron/electron";
|
|
||||||
// import "./chat/panel";
|
|
||||||
// import "./chat/dm";
|
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from './ui/App';
|
import App from './ui/App';
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import type { Message as MessageType } from "../../../core/types";
|
|||||||
import type { UserProfile } from "../../../core/types";
|
import type { UserProfile } from "../../../core/types";
|
||||||
import { UserProfileDialog } from "./UserProfileDialog";
|
import { UserProfileDialog } from "./UserProfileDialog";
|
||||||
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
|
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
|
||||||
import { fetchUserProfile } from "../../api/profileApi";
|
import { fetchUserProfile } from "../../../api/profileApi";
|
||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { delay } from "../../../utils/utils";
|
import { delay } from "../../../utils/utils";
|
||||||
import { request } from "../../../websocket";
|
import { request } from "../../../core/websocket";
|
||||||
|
|
||||||
interface ChatMessagesProps {
|
interface ChatMessagesProps {
|
||||||
messages?: MessageType[];
|
messages?: MessageType[];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useAppState } from "../../state";
|
import { useAppState } from "../../state";
|
||||||
import { useDM } from "../../../hooks/useDM";
|
import { useDM } from "../../hooks/useDM";
|
||||||
import { ChatMessages } from "./ChatMessages";
|
import { ChatMessages } from "./ChatMessages";
|
||||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useDM } from "../../../hooks/useDM";
|
import { useDM } from "../../hooks/useDM";
|
||||||
import { useAppState } from "../../state";
|
import { useAppState } from "../../state";
|
||||||
import { fetchUserPublicKey } from "../../../api/dmApi";
|
import { fetchUserPublicKey } from "../../../api/dmApi";
|
||||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react";
|
|||||||
import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel";
|
import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel";
|
||||||
import { ChatMessages } from "./ChatMessages";
|
import { ChatMessages } from "./ChatMessages";
|
||||||
import { ChatInputWrapper } from "./ChatInputWrapper";
|
import { ChatInputWrapper } from "./ChatInputWrapper";
|
||||||
import { setGlobalMessageHandler } from "../../../websocket";
|
import { setGlobalMessageHandler } from "../../../core/websocket";
|
||||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||||
|
|
||||||
interface MessagePanelRendererProps {
|
interface MessagePanelRendererProps {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useCallback, useRef } from "react";
|
import { useEffect, useCallback, useRef } from "react";
|
||||||
import { useAppState } from "../state";
|
import { useAppState } from "../state";
|
||||||
import { request } from "../../websocket";
|
import { request } from "../../core/websocket";
|
||||||
import { API_BASE_URL } from "../../core/config";
|
import { API_BASE_URL } from "../../core/config";
|
||||||
import type { Message } from "../../core/types";
|
import type { Message } from "../../core/types";
|
||||||
import { getAuthHeaders } from "../../auth/api";
|
import { getAuthHeaders } from "../../auth/api";
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useAppState } from "../ui/state";
|
import { useAppState } from "../state";
|
||||||
import {
|
import {
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
fetchUserPublicKey,
|
fetchUserPublicKey,
|
||||||
fetchDMHistory,
|
fetchDMHistory,
|
||||||
decryptDm,
|
decryptDm,
|
||||||
sendDMViaWebSocket
|
sendDMViaWebSocket
|
||||||
} from "../api/dmApi";
|
} from "../../api/dmApi";
|
||||||
import type { User, Message } from "../core/types";
|
import type { User, Message } from "../../core/types";
|
||||||
import { websocket } from "../websocket";
|
import { websocket } from "../../core/websocket";
|
||||||
|
|
||||||
interface DMUser extends User {
|
interface DMUser extends User {
|
||||||
lastMessage?: string;
|
lastMessage?: string;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { useAppState } from "../state";
|
import { useAppState } from "../state";
|
||||||
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "../api/profileApi";
|
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "../../api/profileApi";
|
||||||
import { showSuccess, showError } from "../../utils/notification";
|
import { showSuccess, showError } from "../../utils/notification";
|
||||||
|
|
||||||
export function useProfile() {
|
export function useProfile() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MessagePanel, type MessagePanelCallbacks } from "./MessagePanel";
|
import { MessagePanel, type MessagePanelCallbacks } from "./MessagePanel";
|
||||||
import { API_BASE_URL } from "../../core/config";
|
import { API_BASE_URL } from "../../core/config";
|
||||||
import { getAuthHeaders } from "../../auth/api";
|
import { getAuthHeaders } from "../../auth/api";
|
||||||
import { request } from "../../websocket";
|
import { request } from "../../core/websocket";
|
||||||
import type { Message, WebSocketMessage } from "../../core/types";
|
import type { Message, WebSocketMessage } from "../../core/types";
|
||||||
import type { UserState } from "../state";
|
import type { UserState } from "../state";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { Message, User, WebSocketMessage } from "../core/types";
|
import type { Message, User, WebSocketMessage } from "../core/types";
|
||||||
import { request } from "../websocket";
|
import { request } from "../core/websocket";
|
||||||
import { MessagePanel } from "./panels/MessagePanel";
|
import { MessagePanel } from "./panels/MessagePanel";
|
||||||
import { PublicChatPanel } from "./panels/PublicChatPanel";
|
import { PublicChatPanel } from "./panels/PublicChatPanel";
|
||||||
import { DMPanel, type DMPanelData } from "./panels/DMPanel";
|
import { DMPanel, type DMPanelData } from "./panels/DMPanel";
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ import 'mdui/components/text-field';
|
|||||||
import 'mdui/components/button-icon';
|
import 'mdui/components/button-icon';
|
||||||
import 'mdui/components/top-app-bar';
|
import 'mdui/components/top-app-bar';
|
||||||
import 'mdui/components/top-app-bar-title';
|
import 'mdui/components/top-app-bar-title';
|
||||||
import 'mdui/components/dropdown.js';
|
|
||||||
import 'mdui/components/menu.js';
|
|
||||||
import 'mdui/components/menu-item.js';
|
|
||||||
|
|
||||||
import { setColorScheme } from 'mdui/functions/setColorScheme.js';
|
import { setColorScheme } from 'mdui/functions/setColorScheme.js';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user