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 = ` +
+ `; + 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 = ` + + + + `; + 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 = ` +