mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement message replying, deleting and editing
This commit is contained in:
@@ -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.
|
||||||
@@ -19,7 +19,7 @@ def get_db():
|
|||||||
def get_current_user(
|
def get_current_user(
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
) -> User:
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
payload = verify_token(token)
|
payload = verify_token(token)
|
||||||
if not payload:
|
if not payload:
|
||||||
|
|||||||
+35
-1
@@ -1,5 +1,5 @@
|
|||||||
from sqlalchemy.ext.declarative import declarative_base
|
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 sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from db import engine
|
from db import engine
|
||||||
@@ -16,6 +16,7 @@ class User(Base):
|
|||||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
username = Column(String(50), unique=True, nullable=False, index=True)
|
||||||
password_hash = Column(String(200), nullable=False)
|
password_hash = Column(String(200), nullable=False)
|
||||||
profile_picture = Column(String(255), nullable=True)
|
profile_picture = Column(String(255), nullable=True)
|
||||||
|
bio = Column(Text, nullable=True)
|
||||||
online = Column(Boolean, default=False)
|
online = Column(Boolean, default=False)
|
||||||
last_seen = Column(DateTime, default=datetime.now)
|
last_seen = Column(DateTime, default=datetime.now)
|
||||||
created_at = 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)
|
timestamp = Column(DateTime, default=datetime.now)
|
||||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||||
is_read = Column(Boolean, default=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")
|
author = relationship("User", back_populates="messages")
|
||||||
|
reply_to = relationship("Message", remote_side=[id])
|
||||||
|
|
||||||
|
|
||||||
# Pydantic модели
|
# Pydantic модели
|
||||||
@@ -50,6 +54,36 @@ class SendMessageRequest(BaseModel):
|
|||||||
content: str
|
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):
|
class MessageResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
content: str
|
content: str
|
||||||
|
|||||||
+147
-25
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco
|
|||||||
from fastapi.security import HTTPAuthorizationCredentials
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from dependencies import get_current_user, get_db
|
from dependencies import get_current_user, get_db
|
||||||
from models import Message, SendMessageRequest, User
|
from models import Message, SendMessageRequest, EditMessageRequest, ReplyMessageRequest, User
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger("uvicorn.error")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
@@ -15,23 +15,19 @@ def convert_message(msg: Message) -> dict:
|
|||||||
"content": msg.content,
|
"content": msg.content,
|
||||||
"timestamp": msg.timestamp.isoformat(),
|
"timestamp": msg.timestamp.isoformat(),
|
||||||
"is_read": msg.is_read,
|
"is_read": msg.is_read,
|
||||||
|
"is_edited": msg.is_edited,
|
||||||
"username": msg.author.username,
|
"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 = []
|
@router.post("/send_message")
|
||||||
for msg in messages:
|
async def send_message(
|
||||||
messages_data.append(convert_message(msg))
|
request: SendMessageRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
return {
|
db: Session = Depends(get_db)
|
||||||
"status": "success",
|
):
|
||||||
"messages": messages_data
|
|
||||||
}
|
|
||||||
|
|
||||||
async def send_message_inner(request: SendMessageRequest, current_user: User, db: Session):
|
|
||||||
if not request.content.strip():
|
if not request.content.strip():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
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)}
|
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")
|
@router.get("/get_messages")
|
||||||
async def get_messages(db: Session = Depends(get_db)):
|
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:
|
class MessaggingSocketManager:
|
||||||
@@ -96,7 +168,7 @@ class MessaggingSocketManager:
|
|||||||
if not current_user:
|
if not current_user:
|
||||||
raise HTTPException(401)
|
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:
|
except HTTPException as e:
|
||||||
await self.send_error(websocket, type, e)
|
await self.send_error(websocket, type, e)
|
||||||
elif type == "sendMessage":
|
elif type == "sendMessage":
|
||||||
@@ -107,7 +179,57 @@ class MessaggingSocketManager:
|
|||||||
|
|
||||||
request: SendMessageRequest = SendMessageRequest.model_validate(data["data"])
|
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({
|
await self.broadcast({
|
||||||
"type": "newMessage",
|
"type": "newMessage",
|
||||||
"data": response["message"]
|
"data": response["message"]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import uuid
|
|||||||
import io
|
import io
|
||||||
|
|
||||||
from dependencies import get_db, get_current_user
|
from dependencies import get_db, get_current_user
|
||||||
from models import User
|
from models import User, UpdateBioRequest, UserProfileResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -92,7 +92,53 @@ async def get_user_profile(
|
|||||||
"id": current_user.id,
|
"id": current_user.id,
|
||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
"profile_picture": current_user.profile_picture,
|
"profile_picture": current_user.profile_picture,
|
||||||
|
"bio": current_user.bio,
|
||||||
"online": current_user.online,
|
"online": current_user.online,
|
||||||
"last_seen": current_user.last_seen,
|
"last_seen": current_user.last_seen,
|
||||||
"created_at": current_user.created_at
|
"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
@@ -10,6 +10,8 @@ import { API_BASE_URL } from "./config";
|
|||||||
import { websocket } from "./websocket";
|
import { websocket } from "./websocket";
|
||||||
import type { Message, Messages, WebSocketMessage } from "./types";
|
import type { Message, Messages, WebSocketMessage } from "./types";
|
||||||
import { formatTime } from "./utils/utils";
|
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
|
* 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';
|
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);
|
profilePicDiv.appendChild(profileImg);
|
||||||
messageDiv.appendChild(profilePicDiv);
|
messageDiv.appendChild(profilePicDiv);
|
||||||
}
|
}
|
||||||
@@ -53,16 +61,42 @@ export function addMessage(message: Message, isAuthor: boolean): void {
|
|||||||
const usernameDiv = document.createElement('div');
|
const usernameDiv = document.createElement('div');
|
||||||
usernameDiv.classList.add('message-username');
|
usernameDiv.classList.add('message-username');
|
||||||
usernameDiv.textContent = 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);
|
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');
|
const contentDiv = document.createElement('div');
|
||||||
|
contentDiv.classList.add('message-content');
|
||||||
contentDiv.textContent = message.content;
|
contentDiv.textContent = message.content;
|
||||||
messageInner.appendChild(contentDiv);
|
messageInner.appendChild(contentDiv);
|
||||||
|
|
||||||
const timeDiv = document.createElement('div');
|
const timeDiv = document.createElement('div');
|
||||||
timeDiv.classList.add('message-time');
|
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) {
|
if (isAuthor && message.is_read) {
|
||||||
const checkIcon = document.createElement('span');
|
const checkIcon = document.createElement('span');
|
||||||
@@ -74,6 +108,12 @@ export function addMessage(message: Message, isAuthor: boolean): void {
|
|||||||
messageDiv.appendChild(messageInner);
|
messageDiv.appendChild(messageInner);
|
||||||
messagesContainer.appendChild(messageDiv);
|
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;
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||||
}
|
}
|
||||||
@@ -150,4 +190,67 @@ export function sendMessage(): void {
|
|||||||
document.getElementById('message-form')!.addEventListener('submit', (e) => {
|
document.getElementById('message-form')!.addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendMessage();
|
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
@@ -7,7 +7,6 @@
|
|||||||
background-color: $color-dark-surface-container;
|
background-color: $color-dark-surface-container;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 16px 16px;
|
padding: 16px 16px;
|
||||||
position: static;
|
|
||||||
justify-content: end;
|
justify-content: end;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
@@ -70,7 +69,7 @@
|
|||||||
background: $color-dark-surface-container;
|
background: $color-dark-surface-container;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-shadow: black 0px 0px 20px;
|
box-shadow: black 0 0 20px;
|
||||||
|
|
||||||
.chat-header-avatar {
|
.chat-header-avatar {
|
||||||
width: 45px;
|
width: 45px;
|
||||||
@@ -89,8 +88,7 @@
|
|||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
margin: 0;
|
margin: 0 0 0.2rem;
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
@@ -166,6 +164,12 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
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;
|
position: relative;
|
||||||
word-wrap: break-word;
|
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 {
|
.message-time {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: $color-dark-on-surface-variant;
|
color: $color-dark-on-surface-variant;
|
||||||
@@ -209,6 +247,12 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 0.3rem;
|
margin-bottom: 0.3rem;
|
||||||
font-size: 0.9rem;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -13,4 +13,6 @@ import "./chat";
|
|||||||
import "./settings";
|
import "./settings";
|
||||||
import "./leftpanel";
|
import "./leftpanel";
|
||||||
import "./init";
|
import "./init";
|
||||||
import "./profile";
|
import "./profile";
|
||||||
|
import "./message-context-menu";
|
||||||
|
import "./user-profile-dialog";
|
||||||
@@ -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();
|
||||||
@@ -100,3 +100,33 @@ export async function updateProfile(data: Partial<ProfileData>): Promise<boolean
|
|||||||
return false;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,16 +40,20 @@ export interface Size2D {
|
|||||||
* @property {string} username - Username of the message sender
|
* @property {string} username - Username of the message sender
|
||||||
* @property {string} content - Message content
|
* @property {string} content - Message content
|
||||||
* @property {boolean} is_read - Whether the message has been read
|
* @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} timestamp - ISO timestamp of the message
|
||||||
* @property {string} [profile_picture] - URL to sender's profile picture
|
* @property {string} [profile_picture] - URL to sender's profile picture
|
||||||
|
* @property {Message} [reply_to] - The message this is replying to
|
||||||
*/
|
*/
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
content: string;
|
content: string;
|
||||||
is_read: boolean;
|
is_read: boolean;
|
||||||
|
is_edited: boolean;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
profile_picture?: string;
|
profile_picture?: string;
|
||||||
|
reply_to?: Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +73,7 @@ export interface Messages {
|
|||||||
* @property {string} last_seen - ISO timestamp of last activity
|
* @property {string} last_seen - ISO timestamp of last activity
|
||||||
* @property {boolean} online - Whether the user is currently online
|
* @property {boolean} online - Whether the user is currently online
|
||||||
* @property {string} username - Username
|
* @property {string} username - Username
|
||||||
|
* @property {string} [bio] - User biography
|
||||||
*/
|
*/
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -76,6 +81,28 @@ export interface User {
|
|||||||
last_seen: string;
|
last_seen: string;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
username: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------
|
// ----------
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -5,10 +5,9 @@
|
|||||||
* @version 1.0.0
|
* @version 1.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { currentUser } from "./auth";
|
import { handleWebSocketMessage } from "./chat";
|
||||||
import { addMessage } from "./chat";
|
|
||||||
import { API_FULL_BASE_URL } from "./config";
|
import { API_FULL_BASE_URL } from "./config";
|
||||||
import type { WebSocketMessage, Message } from "./types";
|
import type { WebSocketMessage } from "./types";
|
||||||
import { delay } from "./utils/utils";
|
import { delay } from "./utils/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,7 +24,7 @@ function create(): WebSocket {
|
|||||||
* Global WebSocket instance
|
* Global WebSocket instance
|
||||||
* @type {WebSocket}
|
* @type {WebSocket}
|
||||||
*/
|
*/
|
||||||
export let websocket = create();
|
export let websocket: WebSocket = create();
|
||||||
|
|
||||||
// --------------
|
// --------------
|
||||||
// Initialization
|
// Initialization
|
||||||
@@ -33,13 +32,7 @@ export let websocket = create();
|
|||||||
|
|
||||||
websocket.addEventListener("message", (e) => {
|
websocket.addEventListener("message", (e) => {
|
||||||
const message: WebSocketMessage = JSON.parse(e.data);
|
const message: WebSocketMessage = JSON.parse(e.data);
|
||||||
switch (message.type) {
|
handleWebSocketMessage(message);
|
||||||
case "newMessage": {
|
|
||||||
const newMessage: Message = message.data;
|
|
||||||
addMessage(newMessage, newMessage.username == currentUser!.username);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
websocket.addEventListener("error", async () => {
|
websocket.addEventListener("error", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user