From 1eb4f8392a6f15d64c84c80f61c6e6458c6de815 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 23 Aug 2025 15:21:26 +0300 Subject: [PATCH] Implement message replying, deleting and editing --- .cursor/rules/ui.mdc | 8 + backend/dependencies.py | 2 +- backend/models.py | 36 ++- backend/routes/messaging.py | 172 +++++++++-- backend/routes/profile.py | 48 ++- frontend/src/chat.ts | 107 ++++++- frontend/src/css/_chat.scss | 250 ++++++++++++++- frontend/src/main.ts | 4 +- frontend/src/message-context-menu.ts | 437 +++++++++++++++++++++++++++ frontend/src/profile/api.ts | 30 ++ frontend/src/types.ts | 27 ++ frontend/src/user-profile-dialog.ts | 282 +++++++++++++++++ frontend/src/websocket.ts | 15 +- 13 files changed, 1372 insertions(+), 46 deletions(-) create mode 100644 .cursor/rules/ui.mdc create mode 100644 frontend/src/message-context-menu.ts create mode 100644 frontend/src/user-profile-dialog.ts diff --git a/.cursor/rules/ui.mdc b/.cursor/rules/ui.mdc new file mode 100644 index 0000000..6791c5c --- /dev/null +++ b/.cursor/rules/ui.mdc @@ -0,0 +1,8 @@ +--- +alwaysApply: true +--- + +When you work with UI: + +1. Use MDUI components as HTML elements +2. Do NOT dynamically create HTML if it's going to be loaded when the page loads, instead put it statically in the HTML. \ No newline at end of file diff --git a/backend/dependencies.py b/backend/dependencies.py index 7dd7199..f1b47ab 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -19,7 +19,7 @@ def get_db(): def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db) -): +) -> User: token = credentials.credentials payload = verify_token(token) if not payload: diff --git a/backend/models.py b/backend/models.py index 00e7f90..50271d4 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,5 +1,5 @@ from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, inspect, text from sqlalchemy.orm import relationship from datetime import datetime from db import engine @@ -16,6 +16,7 @@ class User(Base): username = Column(String(50), unique=True, nullable=False, index=True) password_hash = Column(String(200), nullable=False) profile_picture = Column(String(255), nullable=True) + bio = Column(Text, nullable=True) online = Column(Boolean, default=False) last_seen = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now) @@ -30,8 +31,11 @@ class Message(Base): timestamp = Column(DateTime, default=datetime.now) user_id = Column(Integer, ForeignKey("user.id"), nullable=False) is_read = Column(Boolean, default=False) + reply_to_id = Column(Integer, ForeignKey("message.id"), nullable=True) + is_edited = Column(Boolean, default=False) author = relationship("User", back_populates="messages") + reply_to = relationship("Message", remote_side=[id]) # Pydantic модели @@ -50,6 +54,36 @@ class SendMessageRequest(BaseModel): content: str +class EditMessageRequest(BaseModel): + content: str + + +class ReplyMessageRequest(BaseModel): + content: str + reply_to_id: int + + +class DeleteMessageRequest(BaseModel): + message_id: int + + +class UpdateBioRequest(BaseModel): + bio: str + + +class UserProfileResponse(BaseModel): + id: int + username: str + profile_picture: str | None + bio: str | None + online: bool + last_seen: datetime + created_at: datetime + + class Config: + from_attributes = True + + class MessageResponse(BaseModel): id: int content: str diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 9e2bafb..220f273 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco from fastapi.security import HTTPAuthorizationCredentials from sqlalchemy.orm import Session from dependencies import get_current_user, get_db -from models import Message, SendMessageRequest, User +from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -15,23 +15,19 @@ def convert_message(msg: Message) -> dict: "content": msg.content, "timestamp": msg.timestamp.isoformat(), "is_read": msg.is_read, + "is_edited": msg.is_edited, "username": msg.author.username, - "profile_picture": msg.author.profile_picture + "profile_picture": msg.author.profile_picture, + "reply_to": convert_message(msg.reply_to) if msg.reply_to else None } -async def get_messages_inner(db: Session): - messages = db.query(Message).order_by(Message.timestamp.asc()).all() - messages_data = [] - for msg in messages: - messages_data.append(convert_message(msg)) - - return { - "status": "success", - "messages": messages_data - } - -async def send_message_inner(request: SendMessageRequest, current_user: User, db: Session): +@router.post("/send_message") +async def send_message( + request: SendMessageRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): if not request.content.strip(): raise HTTPException( status_code=400, @@ -50,18 +46,94 @@ async def send_message_inner(request: SendMessageRequest, current_user: User, db return {"status": "success", "message": convert_message(new_message)} -@router.post("/send_message") -async def send_message( - request: SendMessageRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - return await send_message_inner(request, current_user, db) - @router.get("/get_messages") async def get_messages(db: Session = Depends(get_db)): - return await get_messages_inner(db) + messages = db.query(Message).order_by(Message.timestamp.asc()).all() + + messages_data = [] + for msg in messages: + messages_data.append(convert_message(msg)) + + return { + "status": "success", + "messages": messages_data + } + + +@router.put("/edit_message/{message_id}") +async def edit_message( + message_id: int, + request: EditMessageRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + message = db.query(Message).filter(Message.id == message_id).first() + + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + if message.user_id != current_user.id: + raise HTTPException(status_code=403, detail="You can only edit your own messages") + + if not request.content.strip(): + raise HTTPException(status_code=400, detail="Message content cannot be empty") + + message.content = request.content.strip() + message.is_edited = True + + db.commit() + db.refresh(message) + + return {"status": "success", "message": convert_message(message)} + + +@router.delete("/delete_message/{message_id}") +async def delete_message( + message_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + message = db.query(Message).filter(Message.id == message_id).first() + + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + if message.user_id != current_user.id: + raise HTTPException(status_code=403, detail="You can only delete your own messages") + + db.delete(message) + db.commit() + + return {"status": "success", "message_id": message_id} + + +@router.post("/reply_message") +async def reply_message( + request: ReplyMessageRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # Check if the message being replied to exists + original_message = db.query(Message).filter(Message.id == request.reply_to_id).first() + if not original_message: + raise HTTPException(status_code=404, detail="Original message not found") + + if not request.content.strip(): + raise HTTPException(status_code=400, detail="No content provided") + + new_message = Message( + content=request.content.strip(), + user_id=current_user.id, + timestamp=datetime.now(), + reply_to_id=request.reply_to_id + ) + + db.add(new_message) + db.commit() + db.refresh(new_message) + + return {"status": "success", "message": convert_message(new_message)} class MessaggingSocketManager: @@ -96,7 +168,7 @@ class MessaggingSocketManager: if not current_user: raise HTTPException(401) - await websocket.send_json({"type": type, "data": await get_messages_inner(current_user, db)}) + await websocket.send_json({"type": type, "data": await get_messages(current_user, db)}) except HTTPException as e: await self.send_error(websocket, type, e) elif type == "sendMessage": @@ -107,7 +179,57 @@ class MessaggingSocketManager: request: SendMessageRequest = SendMessageRequest.model_validate(data["data"]) - response = await send_message_inner(request, current_user, db) + response = await send_message(request, current_user, db) + await self.broadcast({ + "type": "newMessage", + "data": response["message"] + }) + + await websocket.send_json({"type": type, "data": response}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "editMessage": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + message_id = data["data"]["message_id"] + request: EditMessageRequest = EditMessageRequest.model_validate(data["data"]) + + response = await edit_message(message_id, request, current_user, db) + await self.broadcast({ + "type": "messageEdited", + "data": response["message"] + }) + + await websocket.send_json({"type": type, "data": response}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "deleteMessage": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + message_id = data["data"]["message_id"] + response = await delete_message(message_id, current_user, db) + await self.broadcast({ + "type": "messageDeleted", + "data": {"message_id": message_id} + }) + + await websocket.send_json({"type": type, "data": response}) + except HTTPException as e: + await self.send_error(websocket, type, e) + elif type == "replyMessage": + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + request: ReplyMessageRequest = ReplyMessageRequest.model_validate(data["data"]) + response = await reply_message(request, current_user, db) await self.broadcast({ "type": "newMessage", "data": response["message"] diff --git a/backend/routes/profile.py b/backend/routes/profile.py index a82dfca..c1fa26f 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -7,7 +7,7 @@ import uuid import io from dependencies import get_db, get_current_user -from models import User +from models import User, UpdateBioRequest, UserProfileResponse router = APIRouter() @@ -92,7 +92,53 @@ async def get_user_profile( "id": current_user.id, "username": current_user.username, "profile_picture": current_user.profile_picture, + "bio": current_user.bio, "online": current_user.online, "last_seen": current_user.last_seen, "created_at": current_user.created_at } + + +@router.put("/user/bio") +async def update_user_bio( + request: UpdateBioRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Update current user's bio + """ + if len(request.bio) > 500: # Limit bio to 500 characters + raise HTTPException(status_code=400, detail="Bio must be 500 characters or less") + + current_user.bio = request.bio.strip() + db.commit() + + return { + "message": "Bio updated successfully", + "bio": current_user.bio + } + + +@router.get("/user/{username}") +async def get_user_by_username( + username: str, + db: Session = Depends(get_db) +): + """ + Get user profile by username + """ + user = db.query(User).filter(User.username == username).first() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return UserProfileResponse( + id=user.id, + username=user.username, + profile_picture=user.profile_picture, + bio=user.bio, + online=user.online, + last_seen=user.last_seen, + created_at=user.created_at + ) diff --git a/frontend/src/chat.ts b/frontend/src/chat.ts index 85502e2..fe6d1ce 100644 --- a/frontend/src/chat.ts +++ b/frontend/src/chat.ts @@ -10,6 +10,8 @@ import { API_BASE_URL } from "./config"; import { websocket } from "./websocket"; import type { Message, Messages, WebSocketMessage } from "./types"; import { formatTime } from "./utils/utils"; +import { messageContextMenu } from "./message-context-menu"; +import { userProfileDialog } from "./user-profile-dialog"; /** * Adds a new message to the chat interface @@ -45,6 +47,12 @@ export function addMessage(message: Message, isAuthor: boolean): void { profileImg.src = './src/images/default-avatar.png'; }; + // Add click handler to profile picture + profileImg.style.cursor = 'pointer'; + profileImg.addEventListener('click', () => { + userProfileDialog.show(message.username); + }); + profilePicDiv.appendChild(profileImg); messageDiv.appendChild(profilePicDiv); } @@ -53,16 +61,42 @@ export function addMessage(message: Message, isAuthor: boolean): void { 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', () => { + userProfileDialog.show(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 = ` +
+ ${message.reply_to.username} + ${message.reply_to.content} +
+ `; + 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'); - timeDiv.textContent = formatTime(message.timestamp); + + let timeText = formatTime(message.timestamp); + if (message.is_edited) { + timeText += ' (edited)'; + } + timeDiv.textContent = timeText; if (isAuthor && message.is_read) { const checkIcon = document.createElement('span'); @@ -74,6 +108,12 @@ export function addMessage(message: Message, isAuthor: boolean): void { messageDiv.appendChild(messageInner); messagesContainer.appendChild(messageDiv); + // Add right-click context menu + messageDiv.addEventListener('contextmenu', (e) => { + e.preventDefault(); + messageContextMenu.show(message, e.clientX, e.clientY); + }); + // Прокрутка к новому сообщению messagesContainer.scrollTop = messagesContainer.scrollHeight; } @@ -150,4 +190,67 @@ export function sendMessage(): void { document.getElementById('message-form')!.addEventListener('submit', (e) => { e.preventDefault(); sendMessage(); -}); \ No newline at end of file +}); + +/** + * Updates an existing message in the chat interface + * @param {Message} message - Updated message object + * @function updateMessage + */ +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 + * @function removeMessage + */ +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 + * @function handleWebSocketMessage + */ +export function handleWebSocketMessage(response: WebSocketMessage): void { + 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; + } +} \ No newline at end of file diff --git a/frontend/src/css/_chat.scss b/frontend/src/css/_chat.scss index 0f143f3..e2f847c 100644 --- a/frontend/src/css/_chat.scss +++ b/frontend/src/css/_chat.scss @@ -7,7 +7,6 @@ background-color: $color-dark-surface-container; color: white; padding: 16px 16px; - position: static; justify-content: end; width: fit-content; z-index: 1000; @@ -70,7 +69,7 @@ background: $color-dark-surface-container; display: flex; align-items: center; - box-shadow: black 0px 0px 20px; + box-shadow: black 0 0 20px; .chat-header-avatar { width: 45px; @@ -89,8 +88,7 @@ h4 { font-size: 1.1rem; - margin: 0; - margin-bottom: 0.2rem; + margin: 0 0 0.2rem; } p { @@ -166,6 +164,12 @@ height: 100%; border-radius: 50%; object-fit: cover; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } } } @@ -175,6 +179,40 @@ position: relative; word-wrap: break-word; + .message-content { + word-wrap: break-word; + margin-bottom: 0.3rem; + } + + .message-reply { + background-color: rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 0.5rem; + margin-bottom: 0.5rem; + border-left: 3px solid $color-dark-primary; + + .reply-content { + display: flex; + flex-direction: column; + gap: 0.2rem; + + .reply-username { + font-weight: 600; + font-size: 0.8rem; + color: $color-dark-primary; + } + + .reply-text { + font-size: 0.85rem; + color: $color-dark-on-surface-variant; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 200px; + } + } + } + .message-time { font-size: 0.7rem; color: $color-dark-on-surface-variant; @@ -209,6 +247,12 @@ font-weight: 600; margin-bottom: 0.3rem; font-size: 0.9rem; + transition: color 0.2s ease; + + &:hover { + color: $color-dark-primary; + text-decoration: underline; + } } } @@ -273,4 +317,202 @@ } } } +} + +// Message Context Menu +.message-context-menu { + position: fixed; + background-color: $color-dark-surface-container; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + padding: 0.5rem 0; + z-index: 1000; + display: none; + min-width: 150px; + max-width: 200px; + white-space: nowrap; + + .context-menu-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + cursor: pointer; + color: $color-dark-on-surface; + transition: background-color 0.2s ease; + font-size: 0.9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + + .material-symbols { + font-size: 1.1rem; + flex-shrink: 0; + } + } +} + +// Dialog content styles +.dialog-content { + padding: 1.5rem; + + h3 { + margin: 0 0 1rem 0; + color: $color-dark-on-surface; + font-size: 1.2rem; + } + + .dialog-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + margin-top: 1.5rem; + } +} + +// Reply preview styles +.reply-preview { + background-color: $color-dark-surface; + border-radius: 8px; + padding: 0.75rem; + margin-bottom: 1rem; + border-left: 3px solid $color-dark-primary; + + .reply-preview-content { + color: $color-dark-on-surface-variant; + font-size: 0.9rem; + line-height: 1.4; + + strong { + color: $color-dark-primary; + } + } +} + +// User profile dialog content styles +.profile-dialog-content { + padding: 1.5rem; + display: flex; + gap: 1.5rem; + + .profile-picture-section { + flex-shrink: 0; + + .profile-picture { + width: 80px; + height: 80px; + border-radius: 50%; + object-fit: cover; + border: 2px solid $color-dark-outline; + } + } + + .profile-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 1rem; + + .username-section { + display: flex; + align-items: center; + gap: 0.75rem; + + .username { + margin: 0; + color: $color-dark-on-surface; + font-size: 1.1rem; + font-weight: 600; + } + + .online-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-weight: 500; + + &.online { + color: $success; + background-color: rgba(76, 175, 80, 0.1); + + .online-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: $success; + } + } + + &.offline { + color: $color-dark-on-surface-variant; + background-color: rgba(255, 255, 255, 0.05); + + .offline-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: $color-dark-on-surface-variant; + } + } + } + } + + .bio-section { + label { + display: block; + color: $color-dark-on-surface-variant; + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 0.5rem; + } + + .bio-display { + color: $color-dark-on-surface; + font-size: 0.9rem; + line-height: 1.4; + padding: 0.75rem; + background-color: $color-dark-surface; + border-radius: 8px; + border: 1px solid $color-dark-outline; + min-height: 60px; + } + + .bio-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + } + } + + .profile-stats { + display: flex; + flex-direction: column; + gap: 0.5rem; + + .stat { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + + .stat-label { + color: $color-dark-on-surface-variant; + font-size: 0.85rem; + } + + .stat-value { + color: $color-dark-on-surface; + font-size: 0.85rem; + font-weight: 500; + } + } + } + } } \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts index f089f78..3b78d81 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -13,4 +13,6 @@ import "./chat"; import "./settings"; import "./leftpanel"; import "./init"; -import "./profile"; \ No newline at end of file +import "./profile"; +import "./message-context-menu"; +import "./user-profile-dialog"; \ No newline at end of file diff --git a/frontend/src/message-context-menu.ts b/frontend/src/message-context-menu.ts new file mode 100644 index 0000000..0835984 --- /dev/null +++ b/frontend/src/message-context-menu.ts @@ -0,0 +1,437 @@ +/** + * @fileoverview Message context menu functionality + * @description Handles right-click context menu for message actions (edit, delete, reply) + * @author Cursor + * @version 1.0.0 + */ + +import { currentUser, authToken } from "./auth"; +import { websocket } from "./websocket"; +import type { Message, WebSocketMessage } from "./types"; +import { showSuccess, showError } from "./utils/notification"; + +/** + * Message context menu class + * @class MessageContextMenu + */ +class MessageContextMenu { + private menu: HTMLElement | null = null; + private editDialog: HTMLElement | null = null; + private replyDialog: HTMLElement | null = null; + private currentMessage: Message | null = null; + + constructor() { + this.createMenu(); + this.createDialogs(); + this.bindEvents(); + } + + /** + * Creates the context menu element + * @private + */ + private createMenu(): void { + this.menu = document.createElement('div'); + this.menu.className = 'message-context-menu'; + this.menu.innerHTML = ` +
+ reply + Reply +
+
+ edit + Edit +
+
+ delete + Delete +
+ `; + document.body.appendChild(this.menu); + } + + /** + * Creates the dialogs using MDUI components + * @private + */ + private createDialogs(): void { + // Create edit dialog + this.editDialog = document.createElement('mdui-dialog'); + this.editDialog.id = 'edit-message-dialog'; + this.editDialog.setAttribute('close-on-overlay-click', ''); + this.editDialog.setAttribute('close-on-esc', ''); + this.editDialog.innerHTML = ` +
+

Edit Message

+ + +
+ Cancel + Save +
+
+ `; + document.body.appendChild(this.editDialog); + + // Create reply dialog + this.replyDialog = document.createElement('mdui-dialog'); + this.replyDialog.id = 'reply-message-dialog'; + this.replyDialog.setAttribute('close-on-overlay-click', ''); + this.replyDialog.setAttribute('close-on-esc', ''); + this.replyDialog.innerHTML = ` +
+

Reply to Message

+
+ + +
+ Cancel + Send Reply +
+
+ `; + document.body.appendChild(this.replyDialog); + } + + /** + * Binds event listeners + * @private + */ + private bindEvents(): void { + // Context menu events + this.menu?.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const action = target.closest('.context-menu-item')?.getAttribute('data-action'); + + if (action && this.currentMessage) { + this.handleAction(action, this.currentMessage); + } + }); + + // Close menu when clicking outside + document.addEventListener('click', (e) => { + if (!this.menu?.contains(e.target as Node)) { + this.hide(); + } + }); + + // Edit dialog events + const editCancelBtn = this.editDialog?.querySelector('#edit-cancel'); + const editSaveBtn = this.editDialog?.querySelector('#edit-save'); + + editCancelBtn?.addEventListener('click', () => this.hideEditDialog()); + editSaveBtn?.addEventListener('click', () => this.saveEdit()); + + // Reply dialog events + const replyCancelBtn = this.replyDialog?.querySelector('#reply-cancel'); + const replySendBtn = this.replyDialog?.querySelector('#reply-send'); + + replyCancelBtn?.addEventListener('click', () => this.hideReplyDialog()); + replySendBtn?.addEventListener('click', () => this.sendReply()); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.hide(); + this.hideEditDialog(); + this.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 + */ + public show(message: Message, x: number, y: number): void { + if (!this.menu) return; + + this.currentMessage = message; + + // Only show edit and delete for own messages + const editItem = this.menu.querySelector('[data-action="edit"]') as HTMLElement; + const deleteItem = this.menu.querySelector('[data-action="delete"]') as HTMLElement; + + if (message.username === currentUser?.username) { + editItem.style.display = 'flex'; + deleteItem.style.display = 'flex'; + } else { + editItem.style.display = 'none'; + deleteItem.style.display = 'none'; + } + + // Calculate menu position + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Default menu size estimates + const menuWidth = 150; + const menuHeight = 120; + + let adjustedX = x; + let adjustedY = y; + + // Adjust horizontal position if menu would go off-screen + if (x + menuWidth > viewportWidth) { + adjustedX = x - menuWidth; + } + + // Adjust vertical position if menu would go off-screen + if (y + menuHeight > viewportHeight) { + adjustedY = y - menuHeight; + } + + // Ensure menu doesn't go off the left or top edges + adjustedX = Math.max(0, adjustedX); + adjustedY = Math.max(0, adjustedY); + + this.menu.style.left = `${adjustedX}px`; + this.menu.style.top = `${adjustedY}px`; + this.menu.style.display = 'block'; + } + + /** + * Hides the context menu + */ + public hide(): void { + if (this.menu) { + this.menu.style.display = 'none'; + } + this.currentMessage = null; + } + + /** + * Handles context menu actions + * @param {string} action - The action to perform + * @param {Message} message - The message to act on + * @private + */ + private handleAction(action: string, message: Message): void { + this.hide(); + + switch (action) { + case 'edit': + this.showEditDialog(message); + break; + case 'delete': + this.deleteMessage(message); + break; + case 'reply': + this.showReplyDialog(message); + break; + } + } + + /** + * Shows the edit dialog + * @param {Message} message - The message to edit + * @private + */ + private showEditDialog(message: Message): void { + if (!this.editDialog) return; + + const textField = this.editDialog.querySelector('#edit-message-input') as any; + if (textField) { + textField.value = message.content; + } + + this.currentMessage = message; + (this.editDialog as any).open = true; + + // Focus the text field + setTimeout(() => { + textField?.focus(); + }, 100); + } + + /** + * Hides the edit dialog + * @private + */ + private hideEditDialog(): void { + if (this.editDialog) { + (this.editDialog as any).open = false; + } + } + + /** + * Saves the edited message + * @private + */ + private saveEdit(): void { + if (!this.currentMessage || !this.editDialog) return; + + const textField = this.editDialog.querySelector('#edit-message-input') as any; + const newContent = textField?.value?.trim() || ''; + + if (!newContent) { + showError('Message cannot be empty'); + return; + } + + const payload: WebSocketMessage = { + type: "editMessage", + data: { + message_id: this.currentMessage.id, + content: newContent + }, + credentials: { + scheme: "Bearer", + credentials: authToken! + } + }; + + let callback: ((e: MessageEvent) => void) | null = null; + callback = (e) => { + websocket.removeEventListener("message", callback!); + const response: WebSocketMessage = JSON.parse(e.data); + + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Message edited successfully'); + this.hideEditDialog(); + } + }; + websocket.addEventListener("message", callback); + websocket.send(JSON.stringify(payload)); + } + + /** + * Shows the reply dialog + * @param {Message} message - The message to reply to + * @private + */ + private showReplyDialog(message: Message): void { + if (!this.replyDialog) return; + + const preview = this.replyDialog.querySelector('#reply-preview') as HTMLElement; + if (preview) { + preview.innerHTML = ` +
+ ${message.username}: ${message.content} +
+ `; + } + + this.currentMessage = message; + (this.replyDialog as any).open = true; + + // Focus the text field + setTimeout(() => { + const textField = this.replyDialog?.querySelector('#reply-message-input') as any; + textField?.focus(); + }, 100); + } + + /** + * Hides the reply dialog + * @private + */ + private hideReplyDialog(): void { + if (this.replyDialog) { + (this.replyDialog as any).open = false; + } + } + + /** + * Sends the reply message + * @private + */ + private sendReply(): void { + if (!this.currentMessage || !this.replyDialog) return; + + const textField = this.replyDialog.querySelector('#reply-message-input') as any; + const content = textField?.value?.trim() || ''; + + if (!content) { + showError('Reply cannot be empty'); + return; + } + + const payload: WebSocketMessage = { + type: "replyMessage", + data: { + content: content, + reply_to_id: this.currentMessage.id + }, + credentials: { + scheme: "Bearer", + credentials: authToken! + } + }; + + let callback: ((e: MessageEvent) => void) | null = null; + callback = (e) => { + websocket.removeEventListener("message", callback!); + const response: WebSocketMessage = JSON.parse(e.data); + + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Reply sent successfully'); + this.hideReplyDialog(); + if (textField) { + textField.value = ''; + } + } + }; + websocket.addEventListener("message", callback); + websocket.send(JSON.stringify(payload)); + } + + /** + * Deletes a message + * @param {Message} message - The message to delete + * @private + */ + private deleteMessage(message: Message): void { + if (!confirm('Are you sure you want to delete this message?')) { + return; + } + + const payload: WebSocketMessage = { + type: "deleteMessage", + data: { + message_id: message.id + }, + credentials: { + scheme: "Bearer", + credentials: authToken! + } + }; + + let callback: ((e: MessageEvent) => void) | null = null; + callback = (e) => { + websocket.removeEventListener("message", callback!); + const response: WebSocketMessage = JSON.parse(e.data); + + if (response.error) { + showError(response.error.detail); + } else { + showSuccess('Message deleted successfully'); + } + }; + websocket.addEventListener("message", callback); + websocket.send(JSON.stringify(payload)); + } +} + +// Export singleton instance +export const messageContextMenu = new MessageContextMenu(); diff --git a/frontend/src/profile/api.ts b/frontend/src/profile/api.ts index 32c8220..d446bca 100644 --- a/frontend/src/profile/api.ts +++ b/frontend/src/profile/api.ts @@ -100,3 +100,33 @@ export async function updateProfile(data: Partial): Promise} 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 { + 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; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index abbecc2..a4b8077 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -40,16 +40,20 @@ export interface Size2D { * @property {string} username - Username of the message sender * @property {string} content - Message content * @property {boolean} is_read - Whether the message has been read + * @property {boolean} is_edited - Whether the message has been edited * @property {string} timestamp - ISO timestamp of the message * @property {string} [profile_picture] - URL to sender's profile picture + * @property {Message} [reply_to] - The message this is replying to */ export interface Message { id: number; username: string; content: string; is_read: boolean; + is_edited: boolean; timestamp: string; profile_picture?: string; + reply_to?: Message; } /** @@ -69,6 +73,7 @@ export interface Messages { * @property {string} last_seen - ISO timestamp of last activity * @property {boolean} online - Whether the user is currently online * @property {string} username - Username + * @property {string} [bio] - User biography */ export interface User { id: number; @@ -76,6 +81,28 @@ export interface User { last_seen: string; online: boolean; username: string; + bio?: string; +} + +/** + * User profile response structure + * @interface UserProfile + * @property {number} id - Unique user identifier + * @property {string} username - Username + * @property {string} [profile_picture] - URL to user's profile picture + * @property {string} [bio] - User biography + * @property {boolean} online - Whether the user is currently online + * @property {string} last_seen - ISO timestamp of last activity + * @property {string} created_at - ISO timestamp of account creation + */ +export interface UserProfile { + id: number; + username: string; + profile_picture?: string; + bio?: string; + online: boolean; + last_seen: string; + created_at: string; } // ---------- diff --git a/frontend/src/user-profile-dialog.ts b/frontend/src/user-profile-dialog.ts new file mode 100644 index 0000000..e75bc64 --- /dev/null +++ b/frontend/src/user-profile-dialog.ts @@ -0,0 +1,282 @@ +/** + * @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"; +import { API_BASE_URL } from "./config"; +import type { UserProfile } from "./types"; +import { showError, showSuccess } from "./utils/notification"; +import { formatTime } from "./utils/utils"; + +/** + * User profile dialog class + * @class UserProfileDialog + */ +class UserProfileDialog { + private dialog: HTMLElement | null = null; + private currentProfile: UserProfile | null = null; + private isOwnProfile: boolean = false; + + constructor() { + this.createDialog(); + this.bindEvents(); + } + + /** + * Creates the profile dialog using MDUI components + * @private + */ + private createDialog(): void { + this.dialog = document.createElement('mdui-dialog'); + this.dialog.id = 'user-profile-dialog'; + this.dialog.setAttribute('close-on-overlay-click', ''); + this.dialog.setAttribute('close-on-esc', ''); + this.dialog.innerHTML = ` +
+
+ Profile Picture +
+
+
+

+
+
+
+ +
+ + + +
+
+
+ Member since: + +
+
+ Last seen: + +
+
+
+
+ `; + document.body.appendChild(this.dialog); + } + + /** + * Binds event listeners + * @private + */ + private bindEvents(): void { + // Edit bio events + const editBioBtn = this.dialog?.querySelector('#edit-bio-btn'); + const saveBioBtn = this.dialog?.querySelector('#save-bio-btn'); + const cancelBioBtn = this.dialog?.querySelector('#cancel-bio-btn'); + + editBioBtn?.addEventListener('click', () => this.startEditBio()); + saveBioBtn?.addEventListener('click', () => this.saveBio()); + cancelBioBtn?.addEventListener('click', () => this.cancelEditBio()); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.dialog?.getAttribute('open') !== null) { + this.hide(); + } + }); + } + + /** + * Shows the profile dialog for a specific user + * @param {string} username - Username to show profile for + */ + public async show(username: string): Promise { + if (!this.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(); + this.currentProfile = profile; + this.isOwnProfile = profile.username === currentUser?.username; + this.populateDialog(profile); + (this.dialog as any).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 + */ + private populateDialog(profile: UserProfile): void { + if (!this.dialog) return; + + // Profile picture + const profilePic = this.dialog.querySelector('.profile-picture') as HTMLImageElement; + profilePic.src = profile.profile_picture || './src/images/default-avatar.png'; + profilePic.onerror = () => { + profilePic.src = './src/images/default-avatar.png'; + }; + + // Username + const usernameEl = this.dialog.querySelector('.username') as HTMLElement; + usernameEl.textContent = profile.username; + + // Online status + const onlineStatus = this.dialog.querySelector('.online-status') as HTMLElement; + if (profile.online) { + onlineStatus.innerHTML = ' Online'; + onlineStatus.className = 'online-status online'; + } else { + onlineStatus.innerHTML = ` Last seen ${formatTime(profile.last_seen)}`; + onlineStatus.className = 'online-status offline'; + } + + // Bio + const bioDisplay = this.dialog.querySelector('.bio-display') as HTMLElement; + const bioEdit = this.dialog.querySelector('#bio-edit-field') as any; + + if (profile.bio) { + bioDisplay.textContent = profile.bio; + } else { + bioDisplay.textContent = this.isOwnProfile ? 'No bio yet. Click "Edit Bio" to add one!' : 'No bio available.'; + } + + if (bioEdit) { + bioEdit.value = profile.bio || ''; + } + + // Stats + const memberSince = this.dialog.querySelector('.member-since') as HTMLElement; + const lastSeen = this.dialog.querySelector('.last-seen') as HTMLElement; + + memberSince.textContent = formatTime(profile.created_at); + lastSeen.textContent = formatTime(profile.last_seen); + + // Show/hide edit button for own profile + const editBioBtn = this.dialog.querySelector('#edit-bio-btn') as HTMLElement; + editBioBtn.style.display = this.isOwnProfile ? 'block' : 'none'; + } + + /** + * Starts editing the bio + * @private + */ + private startEditBio(): void { + if (!this.dialog) return; + + const bioDisplay = this.dialog.querySelector('.bio-display') as HTMLElement; + const bioEdit = this.dialog.querySelector('#bio-edit-field') as any; + const bioActions = this.dialog.querySelector('.bio-actions') as HTMLElement; + const editBioBtn = this.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 + */ + private async saveBio(): Promise { + if (!this.dialog || !this.currentProfile) return; + + const bioEdit = this.dialog.querySelector('#bio-edit-field') as any; + 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(); + this.currentProfile.bio = result.bio; + this.populateDialog(this.currentProfile); + this.cancelEditBio(); + showSuccess('Bio updated successfully'); + + } catch (error) { + showError('Failed to update bio'); + console.error('Error updating bio:', error); + } + } + + /** + * Cancels bio editing + * @private + */ + private cancelEditBio(): void { + if (!this.dialog) return; + + const bioDisplay = this.dialog.querySelector('.bio-display') as HTMLElement; + const bioEdit = this.dialog.querySelector('#bio-edit-field') as any; + const bioActions = this.dialog.querySelector('.bio-actions') as HTMLElement; + const editBioBtn = this.dialog.querySelector('#edit-bio-btn') as HTMLElement; + + bioDisplay.style.display = 'block'; + if (bioEdit) bioEdit.style.display = 'none'; + bioActions.style.display = 'none'; + editBioBtn.style.display = this.isOwnProfile ? 'block' : 'none'; + + // Reset bio edit to current value + if (bioEdit) { + bioEdit.value = this.currentProfile?.bio || ''; + } + } + + /** + * Hides the dialog + */ + public hide(): void { + if (this.dialog) { + (this.dialog as any).open = false; + } + this.currentProfile = null; + this.isOwnProfile = false; + } +} + +// Export singleton instance +export const userProfileDialog = new UserProfileDialog(); diff --git a/frontend/src/websocket.ts b/frontend/src/websocket.ts index 57460e9..b514ca0 100644 --- a/frontend/src/websocket.ts +++ b/frontend/src/websocket.ts @@ -5,10 +5,9 @@ * @version 1.0.0 */ -import { currentUser } from "./auth"; -import { addMessage } from "./chat"; +import { handleWebSocketMessage } from "./chat"; import { API_FULL_BASE_URL } from "./config"; -import type { WebSocketMessage, Message } from "./types"; +import type { WebSocketMessage } from "./types"; import { delay } from "./utils/utils"; /** @@ -25,7 +24,7 @@ function create(): WebSocket { * Global WebSocket instance * @type {WebSocket} */ -export let websocket = create(); +export let websocket: WebSocket = create(); // -------------- // Initialization @@ -33,13 +32,7 @@ export let websocket = create(); websocket.addEventListener("message", (e) => { const message: WebSocketMessage = JSON.parse(e.data); - switch (message.type) { - case "newMessage": { - const newMessage: Message = message.data; - addMessage(newMessage, newMessage.username == currentUser!.username); - break; - } - } + handleWebSocketMessage(message); }); websocket.addEventListener("error", async () => {