Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inline conversation updates. Fixes #473. #688

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 90 additions & 30 deletions securedrop_client/gui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
QPushButton, QVBoxLayout, QLineEdit, QScrollArea, QDialog, QAction, QMenu, QMessageBox, \
QToolButton, QSizePolicy, QPlainTextEdit, QStatusBar, QGraphicsDropShadowEffect

from securedrop_client.db import DraftReply, Source, Message, File, Reply, User
from securedrop_client.db import (DraftReply, Source, Message, File, Reply, User,
ReplySendStatusCodes)
from securedrop_client.storage import source_exists
from securedrop_client.export import ExportStatus, ExportError
from securedrop_client.gui import SecureQLabel, SvgLabel, SvgPushButton, SvgToggleButton
Expand Down Expand Up @@ -1557,9 +1558,10 @@ class SpeechBubble(QWidget):
TOP_MARGIN = 28
BOTTOM_MARGIN = 10

def __init__(self, message_id: str, text: str, update_signal) -> None:
def __init__(self, message_id: str, text: str, update_signal, index: int) -> None:
super().__init__()
self.message_id = message_id
self.index = index

# Set styles
self.setObjectName('speech_bubble')
Expand Down Expand Up @@ -1620,8 +1622,8 @@ class MessageWidget(SpeechBubble):
Represents an incoming message from the source.
"""

def __init__(self, message_id: str, message: str, update_signal) -> None:
super().__init__(message_id, message, update_signal)
def __init__(self, message_id: str, message: str, update_signal, index: int) -> None:
super().__init__(message_id, message, update_signal, index)


class ReplyWidget(SpeechBubble):
Expand Down Expand Up @@ -1702,8 +1704,9 @@ def __init__(
update_signal,
message_succeeded_signal,
message_failed_signal,
index: int,
) -> None:
super().__init__(message_id, message, update_signal)
super().__init__(message_id, message, update_signal, index)
self.message_id = message_id

error_icon = SvgLabel('error_icon.svg', svg_size=QSize(12, 12))
Expand Down Expand Up @@ -1833,6 +1836,7 @@ def __init__(
file_uuid: str,
controller: Controller,
file_ready_signal: pyqtBoundSignal,
index: int,
) -> None:
"""
Given some text and a reference to the controller, make something to display a file.
Expand All @@ -1841,6 +1845,7 @@ def __init__(

self.controller = controller
self.file = self.controller.get_file(file_uuid)
self.index = index

# Set styles
self.setObjectName('file_widget')
Expand Down Expand Up @@ -2388,6 +2393,9 @@ def __init__(self, source_db_object: Source, controller: Controller):
self.source = source_db_object
self.controller = controller

# To hold currently displayed messages.
self.current_messages = {} # type: Dict[str, QWidget]

# Set styles
self.setStyleSheet(self.CSS)

Expand All @@ -2413,6 +2421,9 @@ def __init__(self, source_db_object: Source, controller: Controller):
self.scroll.setWidget(self.container)
self.scroll.setWidgetResizable(True)

# Flag to show if the current user has sent a reply. See issue #61.
self.reply_flag = False

# Completely unintuitive way to ensure the view remains scrolled to the bottom.
sb = self.scroll.verticalScrollBar()
sb.rangeChanged.connect(self.update_conversation_position)
Expand All @@ -2428,44 +2439,86 @@ def clear_conversation(self):
child.widget().deleteLater()

def update_conversation(self, collection: list) -> None:
# clear all old items
self.clear_conversation()
"""
Given a list of conversation items that reflect the new state of the
conversation, this method does two things:

* Checks if the conversation item already exists in the conversation.
If so, it checks that it's still in the same position. If it isn't,
the item is removed from its current position and re-added at the
new position. Then the index meta-data on the widget is updated to
reflect this change.
* If the item is a new item, this is created (as before) and inserted
into the conversation at the correct index.

Things to note, speech bubbles and files have an index attribute which
defines where they currently are. This is the attribute that's checked
when the new conversation state (i.e. the collection argument) is
passed into this method in case of a mismatch between where the widget
has been and now is in terms of its index in the conversation.
"""
sssoleileraaa marked this conversation as resolved.
Show resolved Hide resolved
self.controller.session.refresh(self.source)
# add new items
for conversation_item in collection:
if isinstance(conversation_item, Message):
self.add_message(conversation_item)
elif isinstance(conversation_item, (DraftReply, Reply)):
self.add_reply(conversation_item)
for index, conversation_item in enumerate(collection):
item_widget = self.current_messages.get(conversation_item.uuid)
if item_widget:
# check an already displayed item.
if item_widget.index != index:
# The existing widget is out of order, remove / re-add it
# and update index details.
self.conversation_layout.removeWidget(item_widget)
item_widget.index = index
if isinstance(item_widget, ReplyWidget):
self.conversation_layout.insertWidget(index, item_widget,
alignment=Qt.AlignRight)
else:
self.conversation_layout.insertWidget(index, item_widget,
alignment=Qt.AlignLeft)
# Check if text in item has changed, then update the
# widget to reflect this change.
if not isinstance(item_widget, FileWidget):
if item_widget.message.text() != conversation_item.content:
item_widget.message.setText(conversation_item.content)
# Check if this is a draft reply then ensure it's removed.
if isinstance(conversation_item, DraftReply):
if conversation_item.send_status.name == ReplySendStatusCodes.PENDING.value:
self.conversation_layout.removeWidget(item_widget)
else:
self.add_file(conversation_item)
# add a new item to be displayed.
if isinstance(conversation_item, Message):
self.add_message(conversation_item, index)
elif isinstance(conversation_item, (DraftReply, Reply)):
self.add_reply(conversation_item, index)
else:
self.add_file(conversation_item, index)

def add_file(self, file: File):
def add_file(self, file: File, index):
"""
Add a file from the source.
"""
conversation_item = FileWidget(file.uuid, self.controller, self.controller.file_ready)
self.conversation_layout.addWidget(conversation_item, alignment=Qt.AlignLeft)
conversation_item = FileWidget(file.uuid, self.controller, self.controller.file_ready,
index)
self.conversation_layout.insertWidget(index, conversation_item, alignment=Qt.AlignLeft)
self.current_messages[file.uuid] = conversation_item

def update_conversation_position(self, min_val, max_val):
"""
Handler called when a new item is added to the conversation. Ensures
it's scrolled to the bottom and thus visible.
"""
current_val = self.scroll.verticalScrollBar().value()
viewport_height = self.scroll.viewport().height()

if current_val + viewport_height > max_val:
if self.reply_flag and max_val > 0:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, this is a nice way to handle the scrolling behavior

self.scroll.verticalScrollBar().setValue(max_val)
self.reply_flag = False

def add_message(self, message: Message) -> None:
def add_message(self, message: Message, index) -> None:
"""
Add a message from the source.
"""
conversation_item = MessageWidget(message.uuid, str(message), self.controller.message_ready)
self.conversation_layout.addWidget(conversation_item, alignment=Qt.AlignLeft)
conversation_item = MessageWidget(message.uuid, str(message), self.controller.message_ready,
index)
self.conversation_layout.insertWidget(index, conversation_item, alignment=Qt.AlignLeft)
self.current_messages[message.uuid] = conversation_item

def add_reply(self, reply: Union[DraftReply, Reply]) -> None:
def add_reply(self, reply: Union[DraftReply, Reply], index) -> None:
"""
Add a reply from a journalist to the source.
"""
Expand All @@ -2481,26 +2534,32 @@ def add_reply(self, reply: Union[DraftReply, Reply]) -> None:
send_status,
self.controller.reply_ready,
self.controller.reply_succeeded,
self.controller.reply_failed)
self.conversation_layout.addWidget(conversation_item, alignment=Qt.AlignRight)
self.controller.reply_failed,
index)
self.conversation_layout.insertWidget(index, conversation_item, alignment=Qt.AlignRight)
self.current_messages[reply.uuid] = conversation_item

def add_reply_from_reply_box(self, uuid: str, content: str) -> None:
"""
Add a reply from the reply box.
"""
index = len(self.current_messages)
conversation_item = ReplyWidget(
uuid,
content,
'PENDING',
self.controller.reply_ready,
self.controller.reply_succeeded,
self.controller.reply_failed)
self.conversation_layout.addWidget(conversation_item, alignment=Qt.AlignRight)
self.controller.reply_failed,
index)
self.conversation_layout.insertWidget(index, conversation_item, alignment=Qt.AlignRight)
self.current_messages[uuid] = conversation_item

def on_reply_sent(self, source_uuid: str, reply_uuid: str, reply_text: str) -> None:
"""
Add the reply text sent from ReplyBoxWidget to the conversation.
"""
self.reply_flag = True
if source_uuid == self.source.uuid:
self.add_reply_from_reply_box(reply_uuid, reply_text)

Expand Down Expand Up @@ -2651,10 +2710,11 @@ def send_reply(self) -> None:
"""
reply_text = self.text_edit.toPlainText().strip()
if reply_text:
self.text_edit.clearFocus() # Fixes #691
self.text_edit.setText('')
reply_uuid = str(uuid4())
self.controller.send_reply(self.source.uuid, reply_uuid, reply_text)
self.reply_sent.emit(self.source.uuid, reply_uuid, reply_text)
self.text_edit.setText('')

def _on_authentication_changed(self, authenticated: bool) -> None:
if authenticated:
Expand Down
20 changes: 17 additions & 3 deletions tests/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
FILE_COUNT = 0
REPLY_COUNT = 0
DRAFT_REPLY_COUNT = 0
REPLY_SEND_STATUS_COUNT = 0
USER_COUNT = 0


Expand Down Expand Up @@ -54,7 +55,7 @@ def Message(**attrs):
global MESSAGE_COUNT
MESSAGE_COUNT += 1
defaults = dict(
uuid='source-uuid-{}'.format(MESSAGE_COUNT),
uuid='msg-uuid-{}'.format(MESSAGE_COUNT),
filename='{}-msg.gpg'.format(MESSAGE_COUNT),
size=123,
download_url='http://wat.onion/abc',
Expand All @@ -72,7 +73,7 @@ def Reply(**attrs):
global REPLY_COUNT
REPLY_COUNT += 1
defaults = dict(
uuid='source-uuid-{}'.format(REPLY_COUNT),
uuid='reply-uuid-{}'.format(REPLY_COUNT),
filename='{}-reply.gpg'.format(REPLY_COUNT),
size=123,
is_decrypted=True,
Expand All @@ -95,18 +96,31 @@ def DraftReply(**attrs):
file_counter=1,
uuid='draft-reply-uuid-{}'.format(REPLY_COUNT),
content='content',
send_status_id=1,
)

defaults.update(attrs)

return db.DraftReply(**defaults)


def ReplySendStatus(**attrs):
global REPLY_SEND_STATUS_COUNT
REPLY_SEND_STATUS_COUNT += 1
defaults = dict(
name=db.ReplySendStatusCodes.PENDING.value,
)

defaults.update(attrs)

return db.ReplySendStatus(**defaults)


def File(**attrs):
global FILE_COUNT
FILE_COUNT += 1
defaults = dict(
uuid='source-uuid-{}'.format(FILE_COUNT),
uuid='file-uuid-{}'.format(FILE_COUNT),
filename='{}-doc.gz.gpg'.format(FILE_COUNT),
original_filename='{}-doc.txt'.format(FILE_COUNT),
size=123,
Expand Down
Loading