diff --git a/desktop-client/chat_widgets.py b/desktop-client/chat_widgets.py index 29a50b5..355e876 100644 --- a/desktop-client/chat_widgets.py +++ b/desktop-client/chat_widgets.py @@ -8,6 +8,7 @@ from PyQt6.QtCore import Qt, pyqtSignal class ChatBubble(QWidget): def __init__(self, author, text, time_str, is_self, on_link_clicked): super().__init__() + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) layout = QHBoxLayout(self) layout.setContentsMargins(5, 2, 5, 2) @@ -39,6 +40,7 @@ class ChatBubble(QWidget): # Author author_lbl = QLabel("You" if is_self else author) + author_lbl.setTextFormat(Qt.TextFormat.PlainText) author_lbl.setStyleSheet(f"color: {author_color}; font-weight: bold; font-size: 11px; background: transparent;") bubble_layout.addWidget(author_lbl) @@ -47,6 +49,7 @@ class ChatBubble(QWidget): # width of 0 lets the label wrap within whatever space the bubble # actually receives rather than expanding the bubble outward. self.text_lbl = QLabel(text) + self.text_lbl.setTextFormat(Qt.TextFormat.RichText) self.text_lbl.setWordWrap(True) self.text_lbl.setMinimumWidth(0) self.text_lbl.setSizePolicy( @@ -71,6 +74,14 @@ class ChatBubble(QWidget): layout.addWidget(self.bubble) layout.addStretch() + def resizeEvent(self, event): + super().resizeEvent(event) + # Recalculate after the scrollbar or splitter changes the available width. + # A fixed row height prevents the scroll layout squeezing wrapped text. + height = self.layout().heightForWidth(event.size().width()) + if height >= 0: + self.setFixedHeight(height) + class SystemMessageWidget(QWidget): def __init__(self, text): diff --git a/desktop-client/room_widget.py b/desktop-client/room_widget.py index a93c8ad..dddbecc 100644 --- a/desktop-client/room_widget.py +++ b/desktop-client/room_widget.py @@ -45,6 +45,8 @@ class RoomWidget(QWidget): self.popout_window = None self._setup_ui() + self._unread_messages = 0 + QApplication.instance().focusChanged.connect(self._on_chat_focus_changed) def _setup_ui(self): layout = QVBoxLayout(self) @@ -174,7 +176,7 @@ class RoomWidget(QWidget): self.chat_container.setMaximumWidth(500) chat_layout = QVBoxLayout(self.chat_container) - chat_header = QLabel("Live Chat") + chat_header = self.chat_header = QLabel("Live Chat") chat_header.setObjectName("chatHeader") self.popout_btn = QPushButton("↗") @@ -323,6 +325,7 @@ class RoomWidget(QWidget): self.room_file_badge.setText(f"📄 {file_name}") # Clear chat + self._clear_chat_alert() for i in reversed(range(self.chat_content_layout.count())): self.chat_content_layout.itemAt(i).widget().setParent(None) @@ -335,6 +338,7 @@ class RoomWidget(QWidget): self.vlc_player.set_volume(self.volume_slider.value()) def cleanup(self): + self._clear_chat_alert() self.vlc_player.stop() self.current_users = [] self._is_first_user_update = True @@ -723,13 +727,20 @@ class RoomWidget(QWidget): time_str = dt.strftime("%I:%M %p") safe_text = html.escape(text) - safe_author = html.escape(author) - + # Regex to find timestamps like 1:23 or 01:23:45 pattern = r'\b(\d{1,2}:\d{2}(?::\d{2})?)\b' linked_text = re.sub(pattern, r'\1', safe_text) + linked_text = linked_text.replace('\n', '
') is_self = author == self.username + if not is_self and not self._chat_has_focus(): + self._unread_messages += 1 + self.chat_header.setText(f"Live Chat • {self._unread_messages} new") + self.chat_toggle_btn.setText("💬•") + self.chat_toggle_btn.setToolTip(f"{self._unread_messages} new chat messages") + self.chat_toggle_btn.setStyleSheet("color: #66b3ff; border: 1px solid #66b3ff; border-radius: 6px;") + QApplication.alert(self.chat_container.window(), 3000) # Auto-extract tags added by users directly via /tag command # We look for the pattern generated by /tag: "MM:SS tag text" @@ -742,6 +753,24 @@ class RoomWidget(QWidget): bubble = ChatBubble(author, linked_text, time_str, is_self, self._handle_link_from_bubble) self.chat_content_layout.addWidget(bubble) self._scroll_to_bottom() + + def _chat_has_focus(self): + focus = QApplication.focusWidget() + return (self.chat_container.isVisible() + and self.chat_container.window().isActiveWindow() + and focus is not None + and (focus is self.chat_container or self.chat_container.isAncestorOf(focus))) + + def _on_chat_focus_changed(self, old, new): + if self._chat_has_focus(): + self._clear_chat_alert() + + def _clear_chat_alert(self): + self._unread_messages = 0 + self.chat_header.setText("Live Chat") + self.chat_toggle_btn.setText("💬") + self.chat_toggle_btn.setToolTip("Toggle Chat") + self.chat_toggle_btn.setStyleSheet("") def _handle_link_from_bubble(self, url_str): if url_str.startswith("seek:"): @@ -782,6 +811,7 @@ class RoomWidget(QWidget): self._scroll_to_bottom() def clear_chat(self): + self._clear_chat_alert() for i in reversed(range(self.chat_content_layout.count())): item = self.chat_content_layout.itemAt(i) if item.widget(): diff --git a/desktop-client/test_chat.py b/desktop-client/test_chat.py new file mode 100644 index 0000000..324e589 --- /dev/null +++ b/desktop-client/test_chat.py @@ -0,0 +1,103 @@ +from unittest.mock import Mock + +import pytest +from PyQt6.QtCore import QObject, pyqtSignal +from PyQt6.QtGui import QTextDocument +from PyQt6.QtWidgets import QApplication + +import room_widget + + +class PlayerSignals(QObject): + time_changed = pyqtSignal(int) + state_changed = pyqtSignal(bool, int) + + +@pytest.fixture +def room(qtbot, monkeypatch): + player = Mock(signals=PlayerSignals()) + monkeypatch.setattr(room_widget, 'VLCSyncPlayer', lambda frame: player) + widget = room_widget.RoomWidget() + widget.username = 'Me' + qtbot.addWidget(widget) + widget.resize(1050, 500) + widget.show() + return widget + + +@pytest.mark.parametrize('text', ["It's someone's & my ", + "It's at 1:23 & literal", + 'line one\nline two', 'literal ' entity']) +def test_message_rendering(room, text): + room.add_chat_message("O'Brien", text, 0) + bubble = room.chat_content_layout.itemAt(0).widget() + doc = QTextDocument() + doc.setHtml(bubble.text_lbl.text()) + assert doc.toPlainText() == text + if '1:23' in text: + assert 'href="seek:1:23"' in bubble.text_lbl.text() + + +def test_bubbles_do_not_shrink_or_clip_when_overflowing(room, qtbot): + text = 'A wrapped message with several words. ' * 8 + room.add_chat_message('Other', text, 0) + qtbot.wait(100) + first = room.chat_content_layout.itemAt(0).widget() + initial_height = first.height() + for i in range(35): + room.add_chat_message('Me' if i % 2 else 'Other', text, 0) + qtbot.wait(200) + assert room.chat_messages.verticalScrollBar().maximum() > 0 + assert first.height() >= initial_height + for width in (220, 450, 280): + room.splitter.setSizes([room.width() - width, width]) + qtbot.wait(100) + for i in range(room.chat_content_layout.count()): + bubble = room.chat_content_layout.itemAt(i).widget() + label = bubble.text_lbl + assert label.height() >= label.heightForWidth(label.width()) + assert bubble.bubble.rect().contains(label.geometry()) + + +def test_unread_alert_and_focus_clear(room, qtbot, monkeypatch): + alert = Mock() + monkeypatch.setattr(QApplication, 'alert', alert) + room.activateWindow() + room.setFocus() + qtbot.wait(50) + room.add_chat_message('Other', 'hello', 0) + assert room._unread_messages == 1 + alert.assert_called_once() + room.add_chat_message('Me', 'reply', 0) + assert room._unread_messages == 1 + room.chat_input.setFocus() + qtbot.wait(50) + assert room._unread_messages == 0 + room.add_chat_message('Other', 'focused', 0) + assert room._unread_messages == 0 + room.chat_container.hide() + room.add_chat_message('Other', 'hidden', 0) + assert room._unread_messages == 1 + room.clear_chat() + assert room._unread_messages == 0 + + +def test_popout_focus_and_wrapping(room, qtbot, monkeypatch): + monkeypatch.setattr(QApplication, 'alert', Mock()) + room.popout_chat() + popout = room.popout_window + qtbot.addWidget(popout) + popout.activateWindow() + room.chat_input.setFocus() + qtbot.wait(100) + room.add_chat_message('Other', 'Wrapped popout text ' * 40, 0) + qtbot.wait(100) + assert room._unread_messages == 0 + bubble = room.chat_content_layout.itemAt(0).widget() + assert bubble.text_lbl.height() >= bubble.text_lbl.heightForWidth(bubble.text_lbl.width()) + room.activateWindow() + room.setFocus() + qtbot.wait(100) + room.add_chat_message('Other', 'Background popout', 0) + assert room._unread_messages == 1 + popout.close()