diff --git a/.env.old b/.env.old deleted file mode 100755 index bbf8d35..0000000 --- a/.env.old +++ /dev/null @@ -1,53 +0,0 @@ -COMPANY_NAME=AETHER - -MODE=DEV -LOG_LEVEL=DEBUG - -BACKEND_HOST=localhost -BACKEND_PORT=8080 -WORKERS=4 -FRONTEND_URL=http://localhost:5173 - -VITE_API_URL=/api/v1 -FRONTEND_PORT=3056 - -FIRST_SUPER_USER_EMAIL=admin@example.com -FIRST_SUPER_USER_PASS=admin -FIRST_SUPER_USER_USERNAME=admin - -DB_HOST=localhost -DB_PORT=5432 -DB_PASS=postgres -DB_USER=postgres -DB_NAME=Aether - -REDIS_HOST=localhost -REDIS_PORT=6379 -# REDIS_PASS= -# REDIS_DB= - -#CORS_HEADERS=["Content-Type", "Set-Cookie", "Access-Control-Allow-Headers", "Access-Control-Allow-Origin", "Authorization"] -#CORS_ORIGINS=["http://localhost:3000"] -#CORS_METHODS=["GET", "POST", "OPTIONS", "DELETE", "PATCH", "PUT"] - -CORS_HEADERS=["Content-Type", "Set-Cookie", "Access-Control-Allow-Headers", "Access-Control-Allow-Origin", "Authorization"] -CORS_ORIGINS=["http://localhost:5500", "http://localhost:5173", "http://localhost:8080", "http://127.0.0.1:8080", "null"] -CORS_METHODS=["GET", "POST", "OPTIONS", "DELETE", "PATCH", "PUT"] - -SECRET_KEY=sercretKey -ALGORITHM=HS256 - -SMTP_SERVER=localhost -SMTP_PORT=1025 -SMTP_EMAIL=noreply@cityvibe.ru -SMTP_PASS=test - -RMQ_HOST=localhost -RMQ_USER=guest -RMQ_PASS=guest -RMQ_PORT=5672 - -S3_URL=http://192.168.31.190:9002 -S3_ACCESS_KEY_ID=lorsan -S3_SECRET_ACCESS_KEY=Lorser2009! -S3_BUCKET_NAME=aether \ No newline at end of file diff --git a/.gitignore b/.gitignore index a6b3109..d958e7f 100755 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ __pycache__ -.env +.env.prod test.py -.env.old \ No newline at end of file +.env \ No newline at end of file diff --git a/.idea/Aether.iml b/.idea/Aether.iml index da7b72a..f71c807 100755 --- a/.idea/Aether.iml +++ b/.idea/Aether.iml @@ -4,7 +4,7 @@ - + diff --git a/.idea/misc.xml b/.idea/misc.xml index df3a27e..eb937f4 100755 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,5 +3,5 @@ - + \ No newline at end of file diff --git a/README.md b/README.md index 10a6d61..f2b3892 100755 --- a/README.md +++ b/README.md @@ -97,8 +97,8 @@ pip install -e . # Или используя poetry poetry install -# Создайте .env файл -cat > .env << EOF +# Создайте .env.prod файл +cat > .env.prod << EOF DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/aether REDIS_URL=redis://localhost:6379 SECRET_KEY=your-secret-key-here @@ -130,8 +130,8 @@ cd frontend # Установите зависимости npm install -# Создайте .env файл -echo "VITE_API_URL=http://localhost:8000" > .env +# Создайте .env.prod файл +echo "VITE_API_URL=http://localhost:8000" > .env.prod # Запустите dev сервер npm run dev @@ -279,7 +279,7 @@ pytest tests/ -v docker build -t aether-backend . # Запуск контейнера -docker run -p 8000:8000 --env-file .env aether-backend +docker run -p 8000:8000 --env-file .env.prod aether-backend ``` ### Frontend diff --git a/backend/app/chats/dao.py b/backend/app/chats/dao.py index b807440..8c5d718 100644 --- a/backend/app/chats/dao.py +++ b/backend/app/chats/dao.py @@ -77,7 +77,7 @@ class MessageDAO(BaseDAO[MessageModel, MessageCreateDB, MessageUpdateDB]): model = MessageModel @classmethod - async def find_all_asc( + async def find_all_desc( cls, session: AsyncSession, offset: Optional[int], @@ -85,7 +85,7 @@ class MessageDAO(BaseDAO[MessageModel, MessageCreateDB, MessageUpdateDB]): *filter, **filter_by ) -> List[MessageModel]: - stmt = select(MessageModel).filter(*filter).filter_by(**filter_by).order_by(MessageModel.created_at.asc()) + stmt = select(MessageModel).filter(*filter).filter_by(**filter_by).order_by(MessageModel.created_at.desc()) if offset is not None: stmt = stmt.offset(offset) diff --git a/backend/app/chats/models.py b/backend/app/chats/models.py index ec66624..ee0a16e 100644 --- a/backend/app/chats/models.py +++ b/backend/app/chats/models.py @@ -1,7 +1,7 @@ import uuid from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy import ForeignKey, UUID, UniqueConstraint +from sqlalchemy import ForeignKey, UUID, UniqueConstraint, text from app.core.database import Base @@ -14,6 +14,7 @@ class MessageModel(Base): chat_id: Mapped[uuid.UUID] = mapped_column(UUID, ForeignKey("chat.id", ondelete="CASCADE"), index=True) content: Mapped[str] = mapped_column() is_read: Mapped[bool] = mapped_column(default=False) + is_edited: Mapped[bool] = mapped_column(default=False, server_default=text("false")) class ChatModel(Base): diff --git a/backend/app/chats/router.py b/backend/app/chats/router.py index adbd733..6395f99 100644 --- a/backend/app/chats/router.py +++ b/backend/app/chats/router.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect from app.chats.service import ChatService from app.auth.dependencies import get_current_verified_user from app.users.models import UserModel -from app.chats.schemas import Chat, MessageCreate, Message +from app.chats.schemas import Chat, MessageCreate, Message, MessageUpdate router = APIRouter(prefix="/chats", tags=["chats"]) @@ -19,7 +19,7 @@ async def get_chats( return await ChatService.get_chats(user, offset, limit) @router.get("/{chat_id}") -async def get_chat( +async def get_messages( chat_id: uuid.UUID, offset: int = 0, limit: int = 10, @@ -31,6 +31,10 @@ async def get_chat( async def send_message(message: MessageCreate, user: UserModel = Depends(get_current_verified_user)) -> Message: return await ChatService.send_message(user, message) +@router.put("/message") +async def edit_message(message_update: MessageUpdate, user: UserModel = Depends(get_current_verified_user)) -> Message: + return await ChatService.update_message(user, message_update) + @router.websocket("/ws") async def websocket_endpoint(ws: WebSocket, user: UserModel = Depends(get_current_verified_user)): diff --git a/backend/app/chats/schemas.py b/backend/app/chats/schemas.py index b47ce92..5c99539 100644 --- a/backend/app/chats/schemas.py +++ b/backend/app/chats/schemas.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Optional import uuid -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict class MessageCreate(BaseModel): @@ -21,10 +21,13 @@ class MessageCreateDB(BaseModel): chat_id: Optional[uuid.UUID] content: Optional[str] is_read: Optional[bool] = False + is_edited: Optional[bool] = False class MessageUpdateDB(BaseModel): content: Optional[str] + is_edited: Optional[bool] = False + class Message(BaseModel): @@ -32,9 +35,12 @@ class Message(BaseModel): sender_id: int chat_id: uuid.UUID content: str + is_edited: Optional[bool] = False created_at: datetime updated_at: datetime + model_config = ConfigDict(from_attributes=True) + class ChatBase(BaseModel): is_group: Optional[bool] = False diff --git a/backend/app/chats/service.py b/backend/app/chats/service.py index ed43429..bfe1a65 100644 --- a/backend/app/chats/service.py +++ b/backend/app/chats/service.py @@ -9,7 +9,7 @@ from sqlalchemy import and_ from app.core.database import async_session_maker from app.chats.dao import ChatDAO, MessageDAO, ParticipantDAO from app.chats.models import ChatModel, MessageModel, ParticipantModel -from app.chats.schemas import Chat, MessageCreate, MessageCreateDB, ChatCreateDB, ParticipantCreateDB, Message +from app.chats.schemas import Chat, MessageCreate, MessageCreateDB, ChatCreateDB, ParticipantCreateDB, Message, MessageUpdateDB, MessageUpdate from app.users.models import UserModel from app.core.redis import get_redis @@ -92,14 +92,7 @@ class ChatService: ) ) - await cls._send_ws_message(members_ids, Message( - id=message_db.id, - sender_id=message_db.sender_id, - chat_id=message_db.chat_id, - content=message_db.content, - created_at=message_db.created_at, - updated_at=message_db.updated_at - )) + await cls._send_ws_message(members_ids, Message.model_validate(message_db)) await ChatDAO.update( session, @@ -124,7 +117,7 @@ class ChatService: log.warning("Access denied to chat", extra={"user_id": user.id, "chat_id": chat_id}) raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Access denied") - messages = await MessageDAO.find_all_asc( + messages = await MessageDAO.find_all_desc( session, offset, limit, @@ -180,4 +173,45 @@ class ChatService: "message": message.model_dump(mode='json') } await redis_client.publish("messenger_updates", json.dumps(payload)) - log.debug(f"Published message for user_id: {user_id}") \ No newline at end of file + log.debug(f"Published message for user_id: {user_id}") + + + @classmethod + async def update_message(cls, user: UserModel, message_update: MessageUpdate) -> Message: + async with async_session_maker() as session: + message_exist = await MessageDAO.find_one_or_none( + session, + and_( + MessageModel.id==message_update.id, + MessageModel.sender_id==user.id + ) + ) + + if message_exist is None: + log.warning("Message not found", extra={"user_id": user.id, "message_id": message_update.id}) + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Message not found") + + + message_update_db = await MessageDAO.update( + session, + MessageModel.id==message_update.id, + obj_in=MessageUpdateDB( + content=message_update.content, + is_edited=True + ) + ) + + members = await ParticipantDAO.find_all( + session, + None, + None, + ParticipantModel.chat_id==message_exist.chat_id + ) + + member_ids = [member.user_id for member in members] + + await cls._send_ws_message(member_ids, Message.model_validate(message_update_db)) + + await session.commit() + log.info("Message update successfully", extra={"user_id": user.id, "message_id": message_update.id}) + return message_update_db diff --git a/backend/app/migration/versions/76159faa56c8_edit_message_table_adding_is_edited_.py b/backend/app/migration/versions/76159faa56c8_edit_message_table_adding_is_edited_.py new file mode 100644 index 0000000..300c5f2 --- /dev/null +++ b/backend/app/migration/versions/76159faa56c8_edit_message_table_adding_is_edited_.py @@ -0,0 +1,32 @@ +"""Edit message table: adding is_edited column + +Revision ID: 76159faa56c8 +Revises: 0d3f7039ba77 +Create Date: 2026-01-20 17:22:23.965106 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '76159faa56c8' +down_revision: Union[str, Sequence[str], None] = '0d3f7039ba77' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('message', sa.Column('is_edited', sa.Boolean(), nullable=False, server_default=sa.text("false"))) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('message', 'is_edited') + # ### end Alembic commands ### diff --git a/docker-compose.yml b/docker-compose.yml index c16539c..040ca14 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,7 +56,7 @@ services: networks: - aether env_file: - - .env + - .env.prod celery: build: @@ -70,7 +70,7 @@ services: networks: - aether env_file: - - .env + - .env.prod restart: unless-stopped backend: @@ -95,7 +95,7 @@ services: networks: - aether env_file: - - .env + - .env.prod restart: unless-stopped frontend: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9dfc66d..cb70f2b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -66,6 +66,14 @@ function App() { } /> + + + + } + /> state.user); const navigate = useNavigate(); + const { chatId } = useParams<{ chatId: string }>(); + + // Chat store with cache + const cachedChats = useChatStore((state) => state.chats); + const setChatsCache = useChatStore((state) => state.setChats); + const updateChatCache = useChatStore((state) => state.updateChat); + + const [chats, setChats] = useState(cachedChats); // Initialize with cached data + const [loading, setLoading] = useState(cachedChats.length === 0); // Don't show loading if we have cache + const [error, setError] = useState(null); + + // Selected chat state + const [selectedChat, setSelectedChat] = useState(null); + const [messages, setMessages] = useState([]); + const [messagesLoading, setMessagesLoading] = useState(false); + const [messagesError, setMessagesError] = useState(null); + const [hasMoreMessages, setHasMoreMessages] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + + // User profile modal state + const [viewingUser, setViewingUser] = useState(null); + const [userProfileLoading, setUserProfileLoading] = useState(false); + + // Message input state + const [messageText, setMessageText] = useState(''); + const [sendingMessage, setSendingMessage] = useState(false); + + // Message editing state + const [editingMessageId, setEditingMessageId] = useState(null); + const [editingMessageText, setEditingMessageText] = useState(''); + + // Unread messages counter + const [unreadCount, setUnreadCount] = useState(0); + const [showScrollButton, setShowScrollButton] = useState(false); + const isAtBottomRef = useRef(true); // Track if user is at bottom + + // Ref for auto-scroll to bottom + const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + + // WebSocket ref + const wsRef = useRef(null); + + // Input ref for auto-focus + const messageInputRef = useRef(null); + + useEffect(() => { + const loadChats = async () => { + try { + if (cachedChats.length === 0) { + setLoading(true); + } + setError(null); + + const data = await chatService.getChats(0, 50); + setChats(data); + setChatsCache(data); + + // If chatId in URL, select that chat (messages will load in next useEffect) + if (chatId && data.length > 0) { + const chat = data.find(c => c.chat_id === chatId); + if (chat) { + setSelectedChat(chat); + } + } + } catch (err: any) { + setError(err.response?.data?.detail || 'Ошибка загрузки чатов'); + console.error('Failed to load chats:', err); + } finally { + setLoading(false); + } + }; + + loadChats(); + }, [chatId, setChatsCache]); + + useEffect(() => { + if (selectedChat) { + // Reset messages and load from beginning + setMessages([]); + setHasMoreMessages(true); + setUnreadCount(0); + setShowScrollButton(false); + isAtBottomRef.current = true; + loadMessages(selectedChat.chat_id, true); + // Auto-focus input when chat is selected + setTimeout(() => { + messageInputRef.current?.focus(); + }, 100); + } + }, [selectedChat?.chat_id]); + + // WebSocket connection + useEffect(() => { + const connectWebSocket = () => { + // Get token from cookies for WebSocket auth + const wsUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1'; + const wsProtocol = wsUrl.startsWith('https') ? 'wss' : 'ws'; + const wsBase = wsUrl.replace('http://', '').replace('https://', ''); + + const ws = new WebSocket(`${wsProtocol}://${wsBase}/chats/ws`); + + ws.onopen = () => { + console.log('WebSocket connected'); + }; + + ws.onmessage = (event) => { + try { + const message: Message = JSON.parse(event.data); + console.log('Received message via WebSocket:', message); + + // Add or update message in current chat if it belongs to it + if (selectedChat && message.chat_id === selectedChat.chat_id) { + const isNewMessage = !messages.some(m => m.id === message.id); + + setMessages(prev => { + // Check if message already exists (update it if edited) + const existingIndex = prev.findIndex(m => m.id === message.id); + if (existingIndex !== -1) { + // Update existing message + const updated = [...prev]; + updated[existingIndex] = message; + return updated; + } + // Add new message + return [...prev, message]; + }); + + // Handle scroll and unread counter for new messages + if (isNewMessage) { + // Use ref to check current position synchronously + setTimeout(() => { + if (isAtBottomRef.current) { + // Auto-scroll if at bottom + scrollToBottom(true); + } else { + // Increment unread counter if not at bottom + setUnreadCount(prev => prev + 1); + setShowScrollButton(true); + } + }, 100); + } + } + + // Update chat list with new last message + setChats(prevChats => + prevChats.map(chat => + chat.chat_id === message.chat_id + ? { ...chat, last_message: message.content } + : chat + ) + ); + + // Update cache + updateChatCache(message.chat_id, { last_message: message.content }); + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + + ws.onerror = (error) => { + console.error('WebSocket error:', error); + }; + + ws.onclose = () => { + console.log('WebSocket disconnected'); + // Attempt to reconnect after 3 seconds + setTimeout(() => { + if (wsRef.current === ws) { + connectWebSocket(); + } + }, 3000); + }; + + wsRef.current = ws; + }; + + connectWebSocket(); + + // Cleanup on unmount + return () => { + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [selectedChat, updateChatCache]); + + // Handle Escape key to close chat + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (viewingUser) { + setViewingUser(null); + } else if (selectedChat) { + handleBackToChats(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [selectedChat, viewingUser]); + + const loadMessages = async (chatId: string, isInitial: boolean = false) => { + try { + if (isInitial) { + setMessagesLoading(true); + } else { + setLoadingMore(true); + } + setMessagesError(null); + + const offset = isInitial ? 0 : messages.length; + + // Save scroll position before loading more + const scrollContainer = messagesContainerRef.current; + const previousScrollHeight = scrollContainer?.scrollHeight || 0; + + const data = await chatService.getChatMessages(chatId, offset, 50); + + if (data.length < 50) { + setHasMoreMessages(false); + } + + // Backend returns messages in DESC order (newest first), reverse to show oldest first + const sortedData = [...data].reverse(); + + if (isInitial) { + setMessages(sortedData); + // Scroll to bottom on initial load + setTimeout(() => { + scrollToBottom(false); + checkScrollPosition(); + }, 100); + } else { + // Prepend old messages to the beginning + setMessages(prev => [...sortedData, ...prev]); + + // Restore scroll position after render + setTimeout(() => { + if (scrollContainer) { + const newScrollHeight = scrollContainer.scrollHeight; + scrollContainer.scrollTop = newScrollHeight - previousScrollHeight; + } + }, 0); + } + } catch (err: any) { + setMessagesError(err.response?.data?.detail || 'Ошибка загрузки сообщений'); + console.error('Failed to load messages:', err); + } finally { + setMessagesLoading(false); + setLoadingMore(false); + } + }; + + const checkScrollPosition = () => { + const element = messagesContainerRef.current; + if (!element) return; + + const threshold = 150; // pixels from bottom + const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight; + const atBottom = distanceFromBottom < threshold; + + isAtBottomRef.current = atBottom; + setShowScrollButton(!atBottom); + + if (atBottom) { + setUnreadCount(0); + } + }; + + const scrollToBottom = (smooth: boolean = true) => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' }); + setUnreadCount(0); + setShowScrollButton(false); + isAtBottomRef.current = true; + } + }; + + const handleScroll = (e: React.UIEvent) => { + const element = e.currentTarget; + + // Update scroll position tracking + checkScrollPosition(); + + // If scrolled to top and has more messages + if (element.scrollTop === 0 && hasMoreMessages && !loadingMore && selectedChat) { + loadMessages(selectedChat.chat_id, false); + } + }; + + const handleChatClick = (chat: Chat) => { + setSelectedChat(chat); + navigate(`/chat/${chat.chat_id}`); + }; + + const handleBackToChats = () => { + setSelectedChat(null); + setMessages([]); + navigate('/chat'); + }; + + const handleViewUserProfile = async (userId: number) => { + try { + setUserProfileLoading(true); + const userData = await userService.getUserById(userId); + setViewingUser(userData); + } catch (err: any) { + console.error('Failed to load user profile:', err); + } finally { + setUserProfileLoading(false); + } + }; + + const handleSendMessage = async () => { + if (!messageText.trim() || sendingMessage) return; + + try { + setSendingMessage(true); + + // Send message with chat_id (если чат существует) или recipient_id (если новый чат) + const newMessage = await chatService.sendMessage({ + content: messageText.trim(), + chat_id: selectedChat?.chat_id, + recipient_id: selectedChat?.user_id, + }); + + // Add message to list + setMessages(prev => [...prev, newMessage]); + + // Scroll to bottom after sending + setTimeout(() => { + scrollToBottom(true); + }, 100); + + // Clear input + setMessageText(''); + + // Keep input focused + messageInputRef.current?.focus(); + + // Update chat list with new last message + const updatedChats = chats.map(chat => + chat.chat_id === selectedChat?.chat_id + ? { ...chat, last_message: messageText.trim() } + : chat + ); + setChats(updatedChats); + setChatsCache(updatedChats); // Update cache + + // Update cache for specific chat + if (selectedChat?.chat_id) { + updateChatCache(selectedChat.chat_id, { last_message: messageText.trim() }); + } + } catch (err: any) { + console.error('Failed to send message:', err); + alert('Не удалось отправить сообщение'); + } finally { + setSendingMessage(false); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }; + + const handleStartEdit = (message: Message) => { + setEditingMessageId(message.id); + setEditingMessageText(message.content); + }; + + const handleCancelEdit = () => { + setEditingMessageId(null); + setEditingMessageText(''); + }; + + const handleSaveEdit = async (messageId: string) => { + if (!editingMessageText.trim() || sendingMessage) return; + + try { + setSendingMessage(true); + + const updatedMessage = await chatService.updateMessage({ + id: messageId, + content: editingMessageText.trim(), + }); + + // Update message in list + setMessages(prev => + prev.map(m => m.id === messageId ? updatedMessage : m) + ); + + // Clear editing state + setEditingMessageId(null); + setEditingMessageText(''); + + } catch (err: any) { + console.error('Failed to update message:', err); + alert('Не удалось изменить сообщение'); + } finally { + setSendingMessage(false); + } + }; + + const handleEditKeyPress = (e: React.KeyboardEvent, messageId: string) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSaveEdit(messageId); + } else if (e.key === 'Escape') { + handleCancelEdit(); + } + }; return (
@@ -96,18 +517,110 @@ export default function ChatPage() { {/* Chats List */}
- {/* Placeholder for future chats */} -
-
- + {loading ? ( +
+
+

+ Загрузка чатов... +

-

- Пока нет чатов -

-

- Начните новый диалог -

-
+ ) : error ? ( +
+

{error}

+
+ ) : chats.length === 0 ? ( +
+
+ +
+

+ Пока нет чатов +

+

+ Начните новый диалог +

+
+ ) : ( + chats.map((chat) => ( + handleChatClick(chat)} + className="p-4 rounded-2xl cursor-pointer transition-all relative overflow-hidden" + style={{ + backgroundColor: selectedChat?.chat_id === chat.chat_id ? 'var(--accent-primary)' : 'var(--bg-input)', + boxShadow: selectedChat?.chat_id === chat.chat_id ? '0 4px 12px rgba(0,0,0,0.1)' : 'none', + }} + > +
+ {/* Chat Avatar with Online Indicator */} +
+
+ {!chat.avatar_url && ( + Avatar + )} +
+ {/* Online indicator */} +
+
+ + {/* Chat Info */} +
+
+

+ {chat.display_name} +

+ {/* Time badge */} + + 12:34 + +
+ + {chat.last_message ? ( +

+ {chat.last_message} +

+ ) : ( +

+ Нет сообщений +

+ )} +
+
+ + )) + )}
@@ -120,28 +633,512 @@ export default function ChatPage() {
{/* Main Chat Area */} -
- {/* Empty State */} -
-
+
+ {selectedChat ? ( + <> + {/* Chat Header */} +
+ + + + + handleViewUserProfile(selectedChat.user_id)} + className="w-12 h-12 flex-shrink-0 flex items-center justify-center cursor-pointer" + style={{ + backgroundImage: selectedChat.avatar_url ? `url(${selectedChat.avatar_url})` : undefined, + backgroundSize: 'cover', + backgroundPosition: 'center', + borderRadius: selectedChat.avatar_url ? '50%' : '0', + }} + > + {!selectedChat.avatar_url && ( + Avatar + )} + + +
handleViewUserProfile(selectedChat.user_id)} + > +

+ {selectedChat.display_name} +

+

+ в сети +

+
+
+ + {/* Messages Area */} +
+ {loadingMore && ( +
+
+
+ )} + {messagesLoading ? ( +
+
+
+ ) : messagesError ? ( +
+

{messagesError}

+
+ ) : messages.length === 0 ? ( +
+
+
+ +
+

+ Начните общение +

+

+ Отправьте первое сообщение в этот чат +

+
+
+ ) : ( +
+ {messages.map((message, index) => { + const isMyMessage = message.sender_id === user?.id; + const prevMessage = index > 0 ? messages[index - 1] : null; + const showAvatar = !prevMessage || prevMessage.sender_id !== message.sender_id; + const nextMessage = index < messages.length - 1 ? messages[index + 1] : null; + const isLastInGroup = !nextMessage || nextMessage.sender_id !== message.sender_id; + + // Check if date changed from previous message + const messageDate = new Date(message.created_at); + const prevMessageDate = prevMessage ? new Date(prevMessage.created_at) : null; + const showDateDivider = !prevMessageDate || + messageDate.toDateString() !== prevMessageDate.toDateString(); + + return ( +
+ {/* Date divider */} + {showDateDivider && ( +
+
+ {messageDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })} +
+
+ )} + + + {/* Avatar for incoming messages */} + {!isMyMessage && showAvatar && ( + + {!selectedChat.avatar_url && ( + Avatar + )} + + )} + + {/* Message bubble */} +
+ {/* Sender name for incoming messages */} + {!isMyMessage && showAvatar && ( + + {selectedChat.display_name} + + )} + + + {/* Edit button - показываем только для своих сообщений */} + {isMyMessage && editingMessageId !== message.id && ( + + )} + + {/* Message content or edit input */} + {editingMessageId === message.id ? ( +
+ setEditingMessageText(e.target.value)} + onKeyDown={(e) => handleEditKeyPress(e, message.id)} + autoFocus + className="flex-1 bg-transparent border-b-2 outline-none" + style={{ + borderColor: isMyMessage ? 'rgba(255,255,255,0.5)' : 'var(--accent-primary)', + color: isMyMessage ? 'white' : 'var(--text-primary)' + }} + /> + + +
+ ) : ( +

{message.content}

+ )} + + {/* Message metadata */} +
+ {/* Edited indicator */} + {message.is_edited && ( + + изменено + + )} + + + {new Date(message.created_at).toLocaleTimeString('ru-RU', { + hour: '2-digit', + minute: '2-digit' + })} + + + {/* Read status for my messages */} + {isMyMessage && ( + + + + + )} +
+
+ + {/* Reactions placeholder - можно добавить позже */} + {isLastInGroup && false && ( +
+ ❤️ +
+ )} +
+
+
+ ); + })} + + {/* Invisible element to scroll to */} +
+
+ )} +
+ + {/* Scroll to bottom button with unread counter - OUTSIDE scroll container */} + + {showScrollButton && ( + + + {unreadCount > 0 && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} + + )} + + + {/* Message Input */} +
+
+ setMessageText(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Написать сообщение..." + disabled={sendingMessage} + className="flex-1 px-4 py-3 rounded-2xl font-inter text-sm outline-none transition" + style={{ + backgroundColor: 'var(--bg-input)', + color: 'var(--text-primary)', + borderBottom: '2px solid transparent', + }} + onFocus={(e) => e.target.style.borderBottomColor = 'var(--accent-primary)'} + onBlur={(e) => e.target.style.borderBottomColor = 'transparent'} + /> + + {sendingMessage ? ( +
+ ) : ( + + )} +
+
+
+ + ) : ( + /* Empty State */ +
+
+ +
+ Aether Logo +
+

+ Добро пожаловать в Aether +

+

+ Выберите существующий чат из списка слева или создайте новый, чтобы начать общение +

+
+
+
+ )} +
+ + {/* User Profile Modal */} + + {viewingUser && ( + setViewingUser(null)} + > e.stopPropagation()} > -
- Aether Logo + {/* Modal Header */} +
+ +

+ Профиль пользователя +

+
+ + {/* Modal Content */} +
+ {/* Avatar */} +
+
+ {!viewingUser.avatar_url && ( + + )} +
+
+ + {/* User Info */} +
+
+

+ {viewingUser.display_name} +

+

+ @{viewingUser.username} +

+
+ + {viewingUser.description && ( +
+

+ {viewingUser.description} +

+
+ )} + + {viewingUser.birth_day && ( +
+ 🎂 + + {new Date(viewingUser.birth_day).toLocaleDateString('ru-RU', { + day: 'numeric', + month: 'long', + year: 'numeric' + })} + +
+ )} + +
+ ✉️ + {viewingUser.email} +
+
+ + {/* Actions */} +
+ setViewingUser(null)} + className="w-full py-3 px-4 rounded-2xl font-inter font-semibold transition hover:opacity-90" + style={{ backgroundColor: 'var(--accent-primary)', color: 'white' }} + > + Закрыть + +
-

- Добро пожаловать в Aether -

-

- Выберите существующий чат из списка слева или создайте новый, чтобы начать общение -

-
-
-
+ + )} +
); } diff --git a/frontend/src/services/chatService.ts b/frontend/src/services/chatService.ts new file mode 100644 index 0000000..5c092b1 --- /dev/null +++ b/frontend/src/services/chatService.ts @@ -0,0 +1,58 @@ +import apiClient from './api'; + +export type Chat = { + chat_id: string; + user_id: number; + last_message: string | null; + avatar_url: string | null; + display_name: string; +} + +export type Message = { + id: string; + sender_id: number; + chat_id: string; + content: string; + is_edited?: boolean; + created_at: string; + updated_at: string; +} + +export type MessageCreate = { + content: string; + chat_id?: string; + recipient_id?: number; +} + +export type MessageUpdate = { + id: string; + content: string; +} + +const chatService = { + async getChats(offset: number = 0, limit: number = 10): Promise { + const response = await apiClient.get('/chats/', { + params: { offset, limit } + }); + return response.data; + }, + + async getChatMessages(chatId: string, offset: number = 0, limit: number = 50): Promise { + const response = await apiClient.get(`/chats/${chatId}`, { + params: { offset, limit } + }); + return response.data; + }, + + async sendMessage(data: MessageCreate): Promise { + const response = await apiClient.post('/chats/message', data); + return response.data; + }, + + async updateMessage(data: MessageUpdate): Promise { + const response = await apiClient.put('/chats/message', data); + return response.data; + } +}; + +export default chatService; diff --git a/frontend/src/services/userService.ts b/frontend/src/services/userService.ts index 5b72555..58d70fe 100644 --- a/frontend/src/services/userService.ts +++ b/frontend/src/services/userService.ts @@ -26,6 +26,11 @@ export const userService = { return response.data; }, + getUserById: async (userId: number): Promise => { + const response = await apiClient.get(`/users/${userId}`); + return response.data; + }, + updateProfile: async (data: UserUpdate): Promise => { const response = await apiClient.put('/users/me', data); return response.data; diff --git a/frontend/src/store/chatStore.ts b/frontend/src/store/chatStore.ts new file mode 100644 index 0000000..7b00f42 --- /dev/null +++ b/frontend/src/store/chatStore.ts @@ -0,0 +1,33 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { Chat } from '../services/chatService'; + +interface ChatStore { + chats: Chat[]; + setChats: (chats: Chat[]) => void; + updateChat: (chatId: string, updates: Partial) => void; + clearChats: () => void; +} + +export const useChatStore = create()( + persist( + (set) => ({ + chats: [], + + setChats: (chats) => set({ chats }), + + updateChat: (chatId, updates) => + set((state) => ({ + chats: state.chats.map(chat => + chat.chat_id === chatId ? { ...chat, ...updates } : chat + ) + })), + + clearChats: () => set({ chats: [] }), + }), + { + name: 'aether-chats', + partialize: (state) => ({ chats: state.chats }), + } + ) +);