From 96fe4d0b9125338064d68b87625151346eac1156 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 29 Sep 2025 02:43:25 +0300 Subject: [PATCH] add chat history --- app/controllers/main_controller.py | 9 +- app/core/database.py | 11 ++- app/core/models/chat_models.py | 13 ++- app/core/services/auth_service.py | 3 +- app/core/services/chat_service.py | 46 +++++++++- app/ui/views/chat_list_view.py | 17 +++- app/ui/views/chat_view.py | 107 ++++++++++++++++++++++++ app/ui/views/yobble_home_view.py | 95 +++++++++++++++------ app/ui/widgets/message_bubble_widget.py | 58 +++++++++++++ 9 files changed, 326 insertions(+), 33 deletions(-) create mode 100644 app/ui/views/chat_view.py create mode 100644 app/ui/widgets/message_bubble_widget.py diff --git a/app/controllers/main_controller.py b/app/controllers/main_controller.py index ef063fb..05b996d 100644 --- a/app/controllers/main_controller.py +++ b/app/controllers/main_controller.py @@ -45,13 +45,20 @@ class MainController(QStackedWidget): def handle_login_success(self, username: str): """Обрабатывает успешный вход в систему.""" set_last_login(username) + session = get_session(username) + user_id = session["user_id"] if session else None + + if not user_id: + self.show_login() + self.notification_requested.emit("Не удалось получить ID пользователя из сессии.", True) + return if self.login_view: self.login_view.close() self.removeWidget(self.login_view) self.login_view = None - self.yobble_home_view = YobbleHomeView(username=username) + self.yobble_home_view = YobbleHomeView(username=username, current_user_id=user_id) # Подключаем сигналы к слотам в YobbleHomeView self.notification_requested.connect(self.yobble_home_view.show_notification) diff --git a/app/core/database.py b/app/core/database.py index 9759181..0a0e425 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -28,6 +28,7 @@ def init_db(): cursor.execute(''' CREATE TABLE IF NOT EXISTS sessions ( login TEXT PRIMARY KEY, + user_id TEXT NOT NULL, access_token TEXT NOT NULL, refresh_token TEXT NOT NULL, created_at TIMESTAMP NOT NULL @@ -50,10 +51,12 @@ def init_db(): conn.commit() conn.close() -def add_session(login, access_token, refresh_token, update_existing=False): +def add_session(login, access_token, refresh_token, user_id=None, update_existing=False): """Добавляет новую сессию или обновляет существующую.""" conn = get_connection() cursor = conn.cursor() + + print("ffff", login, access_token, refresh_token, user_id, update_existing) if update_existing: # Обновляем существующую сессию по access_token @@ -65,9 +68,9 @@ def add_session(login, access_token, refresh_token, update_existing=False): else: # Вставляем новую или заменяем существующую по логину cursor.execute(''' - INSERT OR REPLACE INTO sessions (login, access_token, refresh_token, created_at) - VALUES (?, ?, ?, ?) - ''', (login, access_token, refresh_token, datetime.now())) + INSERT OR REPLACE INTO sessions (login, user_id, access_token, refresh_token, created_at) + VALUES (?, ?, ?, ?, ?) + ''', (login, user_id, access_token, refresh_token, datetime.now())) conn.commit() conn.close() diff --git a/app/core/models/chat_models.py b/app/core/models/chat_models.py index 855398e..02e7cfd 100644 --- a/app/core/models/chat_models.py +++ b/app/core/models/chat_models.py @@ -44,4 +44,15 @@ class PrivateChatListData(BaseModel): class PrivateChatListResponse(BaseModel): status: str - data: PrivateChatListData \ No newline at end of file + data: PrivateChatListData + + +# history +class PrivateChatHistoryData(BaseModel): + items: List[MessageItem] + has_more: bool + + +class PrivateChatHistoryResponse(BaseModel): + status: str + data: PrivateChatHistoryData \ No newline at end of file diff --git a/app/core/services/auth_service.py b/app/core/services/auth_service.py index e82afdf..086122d 100644 --- a/app/core/services/auth_service.py +++ b/app/core/services/auth_service.py @@ -25,7 +25,8 @@ async def login(login, password): add_session( login=login, access_token=token_data["access_token"], - refresh_token=token_data["refresh_token"] + refresh_token=token_data["refresh_token"], + user_id=token_data["user_id"] ) return True, localizer.translate("Успешный вход") else: diff --git a/app/core/services/chat_service.py b/app/core/services/chat_service.py index 6278c87..750c23b 100644 --- a/app/core/services/chat_service.py +++ b/app/core/services/chat_service.py @@ -1,7 +1,8 @@ import httpx from app.core import config from app.core.localizer import localizer -from app.core.models.chat_models import PrivateChatListResponse, PrivateChatListData +from app.core.models.chat_models import PrivateChatListResponse, PrivateChatListData, PrivateChatHistoryResponse, PrivateChatHistoryData +from uuid import UUID async def get_private_chats(token: str, offset: int = 0, limit: int = 20): """ @@ -46,3 +47,46 @@ async def get_private_chats(token: str, offset: int = 0, limit: int = 20): return False, f"{localizer.translate('Ошибка сети')}: {e}" except Exception as e: return False, f"{localizer.translate('Произошла ошибка')}: {e}" + +async def get_chat_history(token: str, chat_id: UUID, before_message_id: int = None, limit: int = 30): + """ + Получает историю сообщений для указанного приватного чата. + + :param token: Токен доступа + :param chat_id: ID чата + :param before_message_id: ID сообщения для пагинации (загрузка более старых) + :param limit: Количество сообщений для загрузки + :return: Кортеж (успех: bool, данные: PrivateChatHistoryData | str) + """ + url = f"{config.BASE_URL}/v1/chat/private/history" + headers = {"Authorization": f"Bearer {token}"} + params = {"chat_id": str(chat_id), "limit": limit} + if before_message_id: + params["before_message_id"] = before_message_id + + try: + async with httpx.AsyncClient(http2=True) as client: + response = await client.get(url, headers=headers, params=params) + + if response.status_code == 200: + data = response.json() + if data.get("status") == "fine": + response_model = PrivateChatHistoryResponse(**data) + return True, response_model.data + else: + return False, data.get("detail", "Неизвестная ошибка ответа") + + elif response.status_code in [401, 403]: + error_data = response.json() + return False, error_data.get("detail", "Ошибка аутентификации или доступа") + + elif response.status_code == 422: + return False, "Некорректные параметры запроса" + + else: + return False, f"Ошибка сервера: {response.status_code}" + + except httpx.RequestError as e: + return False, f"Ошибка сети: {e}" + except Exception as e: + return False, f"Произошла ошибка: {e}" diff --git a/app/ui/views/chat_list_view.py b/app/ui/views/chat_list_view.py index d9485de..54e8c75 100644 --- a/app/ui/views/chat_list_view.py +++ b/app/ui/views/chat_list_view.py @@ -1,14 +1,18 @@ from PySide6.QtWidgets import QWidget, QListWidget, QVBoxLayout, QListWidgetItem -from PySide6.QtCore import Qt, QSize +from PySide6.QtCore import Qt, QSize, Signal from typing import List from app.core.models.chat_models import PrivateChatListItem from app.ui.widgets.chat_list_item_widget import ChatListItemWidget from app.core.theme import theme_manager from datetime import datetime +from uuid import UUID class ChatListView(QWidget): + chat_selected = Signal(UUID) + def __init__(self): super().__init__() + self.chat_items_map = {} self.init_ui() self.update_theme() theme_manager.theme_changed.connect(self.update_theme) @@ -21,11 +25,18 @@ class ChatListView(QWidget): self.chat_list = QListWidget() self.chat_list.setSpacing(2) + self.chat_list.itemClicked.connect(self.on_chat_item_clicked) layout.addWidget(self.chat_list) # Изначальное состояние self.show_placeholder_message("Загрузка чатов...") + def on_chat_item_clicked(self, item: QListWidgetItem): + """Обработчик клика по элементу списка чатов.""" + chat_id = self.chat_items_map.get(id(item)) + if chat_id: + self.chat_selected.emit(chat_id) + def update_theme(self): """Обновляет стили в соответствии с темой.""" palette = theme_manager.get_current_palette() @@ -81,6 +92,7 @@ class ChatListView(QWidget): Заполняет список чатов данными, полученными от сервера. """ self.chat_list.clear() + self.chat_items_map.clear() if not chat_items: self.show_placeholder_message("У вас пока нет чатов") @@ -117,3 +129,6 @@ class ChatListView(QWidget): list_item.setSizeHint(item_widget.sizeHint()) self.chat_list.addItem(list_item) self.chat_list.setItemWidget(list_item, item_widget) + + # Сохраняем ID чата в словаре + self.chat_items_map[id(list_item)] = chat.chat_id diff --git a/app/ui/views/chat_view.py b/app/ui/views/chat_view.py new file mode 100644 index 0000000..2808c39 --- /dev/null +++ b/app/ui/views/chat_view.py @@ -0,0 +1,107 @@ +from PySide6.QtWidgets import QWidget, QVBoxLayout, QListWidget, QLineEdit, QPushButton, QHBoxLayout, QListWidgetItem +from PySide6.QtCore import Qt +from app.core.models.chat_models import MessageItem +from app.ui.widgets.message_bubble_widget import MessageBubbleWidget +from app.core.theme import theme_manager +from uuid import UUID + +class ChatView(QWidget): + def __init__(self, chat_id: UUID, current_user_id: UUID): + super().__init__() + self.chat_id = chat_id + self.current_user_id = current_user_id + self.init_ui() + self.update_theme() + theme_manager.theme_changed.connect(self.update_theme) + + def init_ui(self): + """Инициализирует пользовательский интерфейс.""" + main_layout = QVBoxLayout(self) + main_layout.setSpacing(0) + main_layout.setContentsMargins(0, 0, 0, 0) + + self.message_list = QListWidget() + self.message_list.setSpacing(10) + self.message_list.setWordWrap(True) + + input_layout = QHBoxLayout() + input_layout.setSpacing(10) + input_layout.setContentsMargins(10, 10, 10, 10) + + self.message_input = QLineEdit() + self.message_input.setPlaceholderText("Введите сообщение...") + + self.send_button = QPushButton("Отправить") + + input_layout.addWidget(self.message_input) + input_layout.addWidget(self.send_button) + + main_layout.addWidget(self.message_list) + main_layout.addLayout(input_layout) + + def update_theme(self): + """Обновляет стили в соответствии с темой.""" + palette = theme_manager.get_current_palette() + self.setStyleSheet(f"background-color: {palette['primary']};") + self.message_list.setStyleSheet(f""" + QListWidget {{ + background-color: {palette['primary']}; + border: none; + padding: 10px; + }} + """) + self.message_input.setStyleSheet(f""" + QLineEdit {{ + background-color: {palette['secondary']}; + color: {palette['text']}; + border: 1px solid {palette['border']}; + border-radius: 15px; + padding: 5px 15px; + font-size: 10pt; + }} + """) + self.send_button.setStyleSheet(f""" + QPushButton {{ + background-color: {palette['accent']}; + color: #ffffff; + border: none; + border-radius: 15px; + padding: 5px 15px; + font-size: 10pt; + font-weight: bold; + }} + QPushButton:hover {{ + background-color: #4a8ac0; + }} + """) + + def add_message(self, message: MessageItem): + """Добавляет сообщение в список.""" + is_own = message.sender_id == self.current_user_id + + bubble = MessageBubbleWidget( + text=message.content, + timestamp=message.created_at.strftime('%H:%M'), + is_own=is_own + ) + + item = QListWidgetItem(self.message_list) + item.setSizeHint(bubble.sizeHint()) + + # Выравнивание сообщения + if is_own: + item.setTextAlignment(Qt.AlignRight) + else: + item.setTextAlignment(Qt.AlignLeft) + + self.message_list.addItem(item) + self.message_list.setItemWidget(item, bubble) + self.message_list.scrollToBottom() + + def populate_history(self, messages: list[MessageItem]): + """Заполняет список сообщениями из истории.""" + self.message_list.clear() + # Сортируем сообщения по дате (от старых к новым) + messages.sort(key=lambda m: m.created_at) + for message in messages: + self.add_message(message) diff --git a/app/ui/views/yobble_home_view.py b/app/ui/views/yobble_home_view.py index 29c1a92..a5ded72 100644 --- a/app/ui/views/yobble_home_view.py +++ b/app/ui/views/yobble_home_view.py @@ -15,6 +15,9 @@ from app.ui.views.chat_list_view import ChatListView from app.core.services.auth_service import get_user_role from app.core.database import get_current_access_token from app.core.localizer import localizer +from app.ui.views.chat_view import ChatView +from app.core.services.chat_service import get_chat_history +from uuid import UUID class YobbleHomeView(QWidget): REQUIRED_PERMISSIONS = { @@ -22,15 +25,17 @@ class YobbleHomeView(QWidget): 1: "music.access" # Музыка } - def __init__(self, username: str): + def __init__(self, username: str, current_user_id: UUID): super().__init__() self.username = username + self.current_user_id = current_user_id self.setWindowTitle(f"Yobble Home - {username}") self.setMinimumSize(360, 640) self.permission_cache = set() self.permissions_preloaded = False self.permissions_preloaded = False self.permissions_preloaded_last = 0.0 + self.chat_view = None # Placeholder for the chat view # --- Основной макет --- # Используем QHBoxLayout, чтобы можно было разместить меню и контент рядом @@ -237,6 +242,14 @@ class YobbleHomeView(QWidget): top_bar_layout = QHBoxLayout(top_bar_widget) top_bar_layout.setContentsMargins(10, 5, 10, 5) + self.back_button = QPushButton("‹") + self.back_button.setObjectName("BackButton") + self.back_button.setFocusPolicy(Qt.NoFocus) + self.back_button.setCursor(Qt.PointingHandCursor) + self.back_button.clicked.connect(self.close_chat_view) + self.back_button.hide() + top_bar_layout.addWidget(self.back_button) + self.burger_menu_button = QPushButton("☰") self.burger_menu_button.setObjectName("BurgerMenuButton") self.burger_menu_button.setFocusPolicy(Qt.NoFocus) @@ -265,16 +278,6 @@ class YobbleHomeView(QWidget): top_bar_layout.addWidget(self.notification_button) self.notification_button.clicked.connect(self.handle_notification_click) - # --- Временные кнопки для теста --- - # self.test_ok_button = QPushButton("Test OK") - # self.test_ok_button.clicked.connect(lambda: self.show_notification("Операция прошла успешно", is_error=False)) - # top_bar_layout.addWidget(self.test_ok_button) - - # self.test_err_button = QPushButton("Test Error") - # self.test_err_button.clicked.connect(lambda: self.show_notification("Произошла ошибка при обновлении", is_error=True)) - # top_bar_layout.addWidget(self.test_err_button) - # --- Конец временного кода --- - return top_bar_widget def create_bottom_bar(self): @@ -368,11 +371,16 @@ class YobbleHomeView(QWidget): # Чаты self.chat_list_view = ChatListView() + self.chat_list_view.chat_selected.connect(self.open_chat_view) self.content_stack.addWidget(self.chat_list_view) # Профиль self.content_stack.addWidget(QLabel("Контент Профиля")) + # Placeholder для открытого чата + self.chat_view_container = QWidget() + self.content_stack.addWidget(self.chat_view_container) + def update_chat_list(self, chat_data): """ Слот для обновления списка чатов. @@ -429,19 +437,6 @@ class YobbleHomeView(QWidget): raise ConnectionError(data) return success, data - # def update_current_tab_content(self): - # """Обновляет контент текущей активной вкладки после предзагрузки прав.""" - # current_index = self.content_stack.currentIndex() - - # # Проверяем, требует ли текущая вкладка прав доступа - # if current_index in self.REQUIRED_PERMISSIONS: - # permission_code = self.REQUIRED_PERMISSIONS[current_index] - - # if permission_code in self.permission_cache: - # self.show_real_content(current_index) - # else: - # self.show_denied(current_index) - async def check_permissions_and_switch(self, index, permission_code): """Асинхронно проверяет права и переключает вкладку.""" self.preload_permissions_first = False @@ -480,6 +475,54 @@ class YobbleHomeView(QWidget): titles = ["Лента", "Музыка", "Чаты", "Лицо"] self.title_label.setText(titles[index]) + def open_chat_view(self, chat_id: UUID): + """Открывает виджет чата, загружая его историю.""" + print(f"Opening chat: {chat_id}") + asyncio.create_task(self.load_and_display_chat(chat_id)) + + async def load_and_display_chat(self, chat_id: UUID): + """Асинхронно загружает историю и отображает чат.""" + token = await get_current_access_token() + if not token: + self.show_notification("Ошибка: сессия не найдена.", is_error=True) + return + + self.show_notification("Загрузка истории...", is_error=False, duration=1000) + success, data = await get_chat_history(token, chat_id) + + if success: + if self.chat_view: + self.content_stack.removeWidget(self.chat_view) + self.chat_view.deleteLater() + + self.chat_view = ChatView(chat_id, self.current_user_id) + self.chat_view.populate_history(data.items) + + # Заменяем плейсхолдер на реальный виджет + self.content_stack.removeWidget(self.chat_view_container) + self.content_stack.addWidget(self.chat_view) + self.content_stack.setCurrentWidget(self.chat_view) + + self.bottom_bar.hide() + self.burger_menu_button.hide() + self.back_button.show() + else: + self.show_notification(f"Не удалось загрузить историю: {data}", is_error=True) + + def close_chat_view(self): + """Закрывает виджет чата и возвращается к списку.""" + self.content_stack.setCurrentWidget(self.chat_list_view) + self.bottom_bar.show() + self.burger_menu_button.show() + self.back_button.hide() + if self.chat_view: + self.content_stack.removeWidget(self.chat_view) + self.chat_view.deleteLater() + self.chat_view = None + # Возвращаем плейсхолдер + self.chat_view_container = QWidget() + self.content_stack.addWidget(self.chat_view_container) + def show_error_message(self, message): """Показывает всплывающее уведомление об ошибке.""" self.show_notification(message, is_error=True) @@ -615,6 +658,10 @@ class YobbleHomeView(QWidget): color: {title_color}; background: transparent; }} + #BackButton {{ + font-size: 28px; + font-weight: bold; + }} #TitleLabel {{ font-size: 18px; font-weight: bold; diff --git a/app/ui/widgets/message_bubble_widget.py b/app/ui/widgets/message_bubble_widget.py new file mode 100644 index 0000000..c22e1d3 --- /dev/null +++ b/app/ui/widgets/message_bubble_widget.py @@ -0,0 +1,58 @@ +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel +from PySide6.QtCore import Qt +from app.core.theme import theme_manager + +class MessageBubbleWidget(QWidget): + def __init__(self, text: str, timestamp: str, is_own: bool): + super().__init__() + self.init_ui(text, timestamp, is_own) + self.update_theme() + + def init_ui(self, text: str, timestamp: str, is_own: bool): + """Инициализирует пользовательский интерфейс.""" + self.layout = QVBoxLayout(self) + self.layout.setSpacing(2) + + self.text_label = QLabel(text) + self.text_label.setWordWrap(True) + + self.timestamp_label = QLabel(timestamp) + self.timestamp_label.setAlignment(Qt.AlignRight) + + self.layout.addWidget(self.text_label) + self.layout.addWidget(self.timestamp_label) + + self.is_own = is_own + self.setObjectName("MessageBubble") + self.update_theme() + + def update_theme(self): + """Обновляет стили виджета в соответствии с текущей темой.""" + palette = theme_manager.get_current_palette() + + if self.is_own: + bg_color = palette['accent'] + text_color = "#ffffff" + timestamp_color = "#dddddd" + alignment = Qt.AlignRight + else: + bg_color = palette['secondary'] + text_color = palette['text'] + timestamp_color = palette['text_secondary'] + alignment = Qt.AlignLeft + + self.setStyleSheet(f""" + #MessageBubble {{ + background-color: {bg_color}; + border-radius: 10px; + padding: 8px 12px; + }} + """) + self.text_label.setStyleSheet(f"color: {text_color}; font-size: 10pt;") + self.timestamp_label.setStyleSheet(f"color: {timestamp_color}; font-size: 8pt;") + + # Устанавливаем выравнивание для всего layout + parent_layout = self.parentWidget().layout() if self.parentWidget() else None + if parent_layout: + # Этот способ не сработает напрямую, выравнивание нужно делать в QListWidgetItem + pass