Implement message replying, deleting and editing

This commit is contained in:
2025-08-23 15:21:26 +03:00
Unverified
parent eb85e68fc2
commit 1eb4f8392a
13 changed files with 1372 additions and 46 deletions
+8
View File
@@ -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.
+1 -1
View File
@@ -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:
+35 -1
View File
@@ -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
+147 -25
View File
@@ -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"]
+47 -1
View File
@@ -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
)
+105 -2
View File
@@ -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 = `
<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');
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();
});
});
/**
* 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;
}
}
+246 -4
View File
@@ -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;
}
}
}
}
}
+3 -1
View File
@@ -13,4 +13,6 @@ import "./chat";
import "./settings";
import "./leftpanel";
import "./init";
import "./profile";
import "./profile";
import "./message-context-menu";
import "./user-profile-dialog";
+437
View File
@@ -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 = `
<div class="context-menu-item" data-action="reply">
<span class="material-symbols">reply</span>
Reply
</div>
<div class="context-menu-item" data-action="edit">
<span class="material-symbols">edit</span>
Edit
</div>
<div class="context-menu-item" data-action="delete">
<span class="material-symbols">delete</span>
Delete
</div>
`;
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 = `
<div class="dialog-content">
<h3>Edit Message</h3>
<mdui-text-field
id="edit-message-input"
label="Edit Message"
variant="outlined"
multiline
rows="4"
placeholder="Edit your message..."
maxlength="1000">
</mdui-text-field>
<div class="dialog-actions">
<mdui-button id="edit-cancel" variant="outlined">Cancel</mdui-button>
<mdui-button id="edit-save">Save</mdui-button>
</div>
</div>
`;
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 = `
<div class="dialog-content">
<h3>Reply to Message</h3>
<div class="reply-preview" id="reply-preview"></div>
<mdui-text-field
id="reply-message-input"
label="Reply"
variant="outlined"
multiline
rows="4"
placeholder="Type your reply..."
maxlength="1000">
</mdui-text-field>
<div class="dialog-actions">
<mdui-button id="reply-cancel" variant="outlined">Cancel</mdui-button>
<mdui-button id="reply-send">Send Reply</mdui-button>
</div>
</div>
`;
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 = `
<div class="reply-preview-content">
<strong>${message.username}</strong>: ${message.content}
</div>
`;
}
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();
+30
View File
@@ -100,3 +100,33 @@ export async function updateProfile(data: Partial<ProfileData>): Promise<boolean
return false;
}
}
/**
* Updates user bio
* @async
* @function updateBio
* @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;
}
}
+27
View File
@@ -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;
}
// ----------
+282
View File
@@ -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 = `
<div class="profile-dialog-content">
<div class="profile-picture-section">
<img class="profile-picture" src="" alt="Profile Picture">
</div>
<div class="profile-info">
<div class="username-section">
<h4 class="username"></h4>
<div class="online-status"></div>
</div>
<div class="bio-section">
<label>Bio:</label>
<div class="bio-display"></div>
<mdui-text-field
id="bio-edit-field"
label="Bio"
variant="outlined"
multiline
rows="3"
placeholder="Write something about yourself..."
maxlength="500"
style="display: none;">
</mdui-text-field>
<div class="bio-actions" style="display: none;">
<mdui-button id="save-bio-btn">Save</mdui-button>
<mdui-button id="cancel-bio-btn" variant="outlined">Cancel</mdui-button>
</div>
<mdui-button id="edit-bio-btn" variant="outlined" style="display: none;">Edit Bio</mdui-button>
</div>
<div class="profile-stats">
<div class="stat">
<span class="stat-label">Member since:</span>
<span class="stat-value member-since"></span>
</div>
<div class="stat">
<span class="stat-label">Last seen:</span>
<span class="stat-value last-seen"></span>
</div>
</div>
</div>
</div>
`;
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<void> {
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 = '<span class="online-indicator"></span> Online';
onlineStatus.className = 'online-status online';
} else {
onlineStatus.innerHTML = `<span class="offline-indicator"></span> 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<void> {
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();
+4 -11
View File
@@ -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 () => {