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()