diff --git a/client/securedrop_client/gui/actions.py b/client/securedrop_client/gui/actions.py
index 0f3088cbe..73257dbf6 100644
--- a/client/securedrop_client/gui/actions.py
+++ b/client/securedrop_client/gui/actions.py
@@ -81,7 +81,7 @@ def __init__(
source: Source,
parent: QMenu,
controller: Controller,
- confirmation_dialog: Callable[[set[Source]], QDialog],
+ confirmation_dialog: Callable[[list[Source]], QDialog],
) -> None:
self.source = source
self.controller = controller
@@ -91,9 +91,9 @@ def __init__(
# DeleteSource Dialog can accept more than one source (bulk delete),
# but when triggered from this menu, only applies to one source
- self._confirmation_dialog = confirmation_dialog(set([self.source]))
+ self._confirmation_dialog = confirmation_dialog([self.source])
self._confirmation_dialog.accepted.connect(
- lambda: self.controller.delete_source(self.source)
+ lambda: self.controller.delete_sources([self.source])
)
self.triggered.connect(self.trigger)
diff --git a/client/securedrop_client/gui/main.py b/client/securedrop_client/gui/main.py
index 130e15146..b0b368a09 100644
--- a/client/securedrop_client/gui/main.py
+++ b/client/securedrop_client/gui/main.py
@@ -75,6 +75,7 @@ def __init__(
self.main_pane.setLayout(layout)
self.left_pane = LeftPane()
self.main_view = MainView(self.main_pane, app_state)
+
layout.addWidget(self.left_pane)
layout.addWidget(self.main_view)
@@ -107,6 +108,12 @@ def setup(self, controller: Controller) -> None:
self.bottom_pane.setup(self.controller)
self.left_pane.setup(self, self.controller)
self.main_view.setup(self.controller)
+
+ # Listen for changes to the selected sources in sourcelist
+ self.main_view.source_list.selected_sources.connect(
+ self.controller.on_receive_selected_sources
+ )
+
self.show_login()
def show_main_window(self, db_user: User | None = None) -> None:
@@ -184,6 +191,7 @@ def set_logged_in_as(self, db_user: User) -> None:
"""
self.left_pane.set_logged_in_as(db_user)
self.bottom_pane.set_logged_in()
+ self.main_view.set_logged_in()
def logout(self) -> None:
"""
@@ -191,6 +199,7 @@ def logout(self) -> None:
"""
self.left_pane.set_logged_out()
self.bottom_pane.set_logged_out()
+ self.main_view.set_logged_out()
def update_sync_status(self, message: str, duration: int = 0) -> None:
"""
diff --git a/client/securedrop_client/gui/source/delete/dialog.py b/client/securedrop_client/gui/source/delete/dialog.py
index 2592a2f57..6453c23b4 100644
--- a/client/securedrop_client/gui/source/delete/dialog.py
+++ b/client/securedrop_client/gui/source/delete/dialog.py
@@ -27,7 +27,7 @@
class DeleteSourceDialog(ModalDialog):
"""Used to confirm deletion of source accounts."""
- def __init__(self, sources: set[Source]) -> None:
+ def __init__(self, sources: list[Source]) -> None:
super().__init__(show_header=False, dangerous=True)
self.sources = sources
@@ -50,7 +50,7 @@ def __init__(self, sources: set[Source]) -> None:
self.confirmation_label.setText(_("Are you sure this is what you want?"))
self.adjustSize()
- def make_body_text(self, sources: set[Source]) -> str:
+ def make_body_text(self, sources: list[Source]) -> str:
message_tuple = (
"
",
_("Delete entire account for: {source_or_sources}?"),
@@ -74,7 +74,7 @@ def make_body_text(self, sources: set[Source]) -> str:
source_or_sources=f"{self._get_source_names(sources)}"
)
- def _get_source_names(self, sources: set[Source]) -> str:
+ def _get_source_names(self, sources: list[Source]) -> str:
"""
Helper. Return a comma-separated list of journalist designations.
"""
diff --git a/client/securedrop_client/gui/widgets.py b/client/securedrop_client/gui/widgets.py
index 715b8e3ce..26c16dcb7 100644
--- a/client/securedrop_client/gui/widgets.py
+++ b/client/securedrop_client/gui/widgets.py
@@ -42,6 +42,7 @@
QResizeEvent,
)
from PyQt5.QtWidgets import (
+ QAbstractItemView,
QAction,
QGridLayout,
QHBoxLayout,
@@ -54,7 +55,9 @@
QScrollArea,
QSizePolicy,
QSpacerItem,
+ QStackedLayout,
QStatusBar,
+ QToolBar,
QToolButton,
QVBoxLayout,
QWidget,
@@ -215,14 +218,16 @@ def __init__(self) -> None:
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
+ self.user_profile = UserProfile()
self.branding_barre = QLabel()
self.branding_barre.setPixmap(load_image("left_pane.svg"))
- self.user_profile = UserProfile()
# Hide user profile widget until user logs in
self.user_profile.hide()
- # Add widgets to layout
+ # Add widgets to layout. An improvement
+ # to this layout could be to set the branding barre as a
+ # background layout for the other elements
layout.addWidget(self.user_profile)
layout.addWidget(self.branding_barre)
@@ -424,6 +429,140 @@ def clear_message(self) -> None:
self._hide()
+class InnerTopPane(QWidget):
+ """
+ Top pane of the MainView window. This pane holds the Batch Action layout,
+ and eventually will hold the keyword search/filter by codename bar.
+ """
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.setObjectName("InnerTopPane")
+
+ # Use a vertical layout so that the keyword search bar can be added later
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ layout.setAlignment(Qt.AlignVCenter)
+ self.setLayout(layout)
+ self.setAttribute(Qt.WA_StyledBackground, True)
+
+ self.batch_actions = BatchActionWidget()
+ layout.addWidget(self.batch_actions)
+
+ def setup(self, controller: Controller) -> None:
+ self.batch_actions.setup(controller)
+
+ def set_logged_out(self) -> None:
+ """
+ Disable action toolbar if logged out.
+ """
+ self.batch_actions.toolbar.hide_action()
+
+ def set_logged_in(self) -> None:
+ """
+ Enable action toolbar if logged in.
+ """
+ self.batch_actions.toolbar.show_action()
+
+
+class BatchActionWidget(QWidget):
+ def __init__(self) -> None:
+ super().__init__()
+
+ # CSS style id
+ self.setObjectName("BatchActionWidget")
+
+ # Solid background colour
+ self.setAttribute(Qt.WA_StyledBackground, True)
+ layout = QHBoxLayout()
+ self.setLayout(layout)
+
+ self.toolbar = BatchActionToolbar()
+
+ layout.addWidget(self.toolbar)
+ layout.addStretch()
+
+ def setup(self, controller: Controller) -> None:
+ self.toolbar.setup(controller)
+
+
+class BatchActionToolbar(QToolBar):
+ """
+ A toolbar that contains batch actions (actions that target multiple
+ sources in the ConversationView, and therefore don't belong in the
+ individual conversation menu). Currently, this widget will hold the
+ "Delete Sources" (batch-delete) action.
+
+ For user-facing naming consistency, these items won't be called
+ "batch/bulk ", but simply " s" (eg "Delete Sources"), where
+ the original nomenclature comes from the individual Source overflow QAction menu
+ items. Each item may have a tooltip, visible on hover, that provides a more
+ lengthy explanation (e.g., "Delete multiple source accounts").
+ """
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.setObjectName("BatchActionToolbar")
+ self.setContentsMargins(0, 0, 0, 0)
+
+ palette = QPalette()
+ palette.setBrush(
+ QPalette.Background, QBrush(Qt.NoBrush)
+ ) # This makes the widget transparent
+ self.setPalette(palette)
+
+ # Style and attributes
+ self.setMovable(False)
+ self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
+ self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
+
+ self.delete_sources_action = QAction(
+ QIcon(load_image("delete_sources_toolbar_icon.svg")), _("DELETE SOURCES"), self
+ )
+ self.delete_sources_action.setObjectName("BatchActionButton")
+ self.delete_sources_action.setToolTip(
+ _(
+ "Delete selected source accounts. "
+ "Ctrl+click sources below to select multiple sources."
+ )
+ )
+ self.delete_sources_action.triggered.connect(self.on_action_triggered)
+ self.button = self.widgetForAction(self.delete_sources_action)
+
+ # Add spacer. "select all" checkbox can replace the spacer in future
+ spacer = QWidget()
+ spacer.setFixedSize(14, 14) # sourcewidget spacer size
+ self.addWidget(spacer)
+ self.addAction(self.delete_sources_action)
+
+ def setup(self, controller: Controller) -> None:
+ self.controller = controller
+
+ def hide_action(self) -> None:
+ self.delete_sources_action.setVisible(False)
+
+ def show_action(self) -> None:
+ self.delete_sources_action.setVisible(True)
+
+ @pyqtSlot()
+ def on_action_triggered(self) -> None:
+ if self.controller.api is None:
+ self.controller.on_action_requiring_login()
+ else:
+ # The current source selection is continuously received by the controller
+ # as the user selects and deselects; here we retrieve the selection
+ targets = self.controller.get_selected_sources()
+ if targets is not None:
+ dialog = DeleteSourceDialog(targets)
+ self._last_dialog = dialog # FIXME: workaround for #2273
+ dialog.accepted.connect(lambda: self.controller.delete_sources(targets))
+ dialog.open()
+ else:
+ # No selected sources should return an empty set, not None
+ logger.error("Toolbar action triggered without valid data from controller.")
+
+
class UserProfile(QLabel):
"""
A widget that contains user profile information and options.
@@ -603,10 +742,18 @@ def _on_clicked(self) -> None:
class MainView(QWidget):
"""
- Represents the main content of the application (containing the source list
- and main context view).
+ Represents the main content of the application (containing the source list,
+ main context view, and top actions pane).
"""
+ # Index items for StackedLayout. CONVERSATION_INDEX should remain the
+ # biggest int value, for future ease of caching and cleaning up additional
+ # optional pages (eg rendered conversations) in higher index positions
+ NO_SOURCES_INDEX = 0
+ NOTHING_SELECTED_INDEX = 1
+ MULTI_SELECTED_INDEX = 2
+ CONVERSATION_INDEX = 3
+
def __init__(
self,
parent: Optional[QWidget],
@@ -620,15 +767,25 @@ def __init__(
self.setObjectName("MainView")
# Set layout
- self._layout = QHBoxLayout(self)
+ self._layout = QVBoxLayout(self)
+ self._layout.setContentsMargins(0, 0, 0, 0)
+ self._layout.setSpacing(0)
self.setLayout(self._layout)
+ # Top Pane to hold batch actions, eventually will also hold
+ # search bar for keyword filtering
+ self.top_pane = InnerTopPane()
+
+ # Hold main conversation view and sourcelist
+ inner_container = QHBoxLayout()
+
# Set margins and spacing
- self._layout.setContentsMargins(0, 0, 0, 0)
- self._layout.setSpacing(0)
+ inner_container.setContentsMargins(0, 0, 0, 0)
+ inner_container.setSpacing(0)
# Create SourceList widget
self.source_list = SourceList()
+ self.source_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.source_list.itemSelectionChanged.connect(self.on_source_changed)
if app_state is not None:
self.source_list.source_selection_changed.connect(
@@ -639,16 +796,29 @@ def __init__(
# Create widgets
self.view_holder = QWidget()
self.view_holder.setObjectName("MainView_view_holder")
- self.view_layout = QVBoxLayout()
+
+ # Layout where only one view shows at a time. Suitable for the case
+ # where we show either a conversation or a contextually-appropriate
+ # message ("Select a source...", "Nothing to see yet", etc)
+ self.view_layout = QStackedLayout()
self.view_holder.setLayout(self.view_layout)
self.view_layout.setContentsMargins(0, 0, 0, 0)
self.view_layout.setSpacing(0)
- self.empty_conversation_view = EmptyConversationView()
- self.view_layout.addWidget(self.empty_conversation_view)
+
+ self.view_layout.insertWidget(self.NO_SOURCES_INDEX, EmptyConversationView())
+ self.view_layout.insertWidget(self.NOTHING_SELECTED_INDEX, NothingSelectedView())
+ self.view_layout.insertWidget(self.MULTI_SELECTED_INDEX, MultiSelectView())
+
+ # Placeholder widget at the CONVERSATION_INDEX, dynamically replaced by conversation view
+ # as soon as a source conversation is selected
+ self.view_layout.insertWidget(self.CONVERSATION_INDEX, NothingSelectedView())
# Add widgets to layout
- self._layout.addWidget(self.source_list, stretch=1)
- self._layout.addWidget(self.view_holder, stretch=2)
+ inner_container.addWidget(self.source_list, stretch=1)
+ inner_container.addWidget(self.view_holder, stretch=2)
+
+ self._layout.addWidget(self.top_pane)
+ self._layout.addLayout(inner_container, stretch=1)
# Note: We should not delete SourceConversationWrapper when its source is unselected. This
# is a temporary solution to keep copies of our objects since we do delete them.
@@ -660,26 +830,24 @@ def setup(self, controller: Controller) -> None:
"""
self.controller = controller
self.source_list.setup(controller)
+ self.top_pane.setup(controller)
+
+ def set_logged_out(self) -> None:
+ """
+ Logged-out context. Called by parent.
+ """
+ self.top_pane.set_logged_out()
+
+ def set_logged_in(self) -> None:
+ """
+ Logged-in context. Called by parent.
+ """
+ self.top_pane.set_logged_in()
def show_sources(self, sources: list[Source]) -> None:
"""
Update the sources list in the GUI with the supplied list of sources.
"""
- # If no sources are supplied, display the EmptyConversationView with the no-sources message.
- #
- # If there are sources but no source is selected in the GUI, display the
- # EmptyConversationView with the no-source-selected messaging.
- #
- # Otherwise, hide the EmptyConversationView.
- if not sources:
- self.empty_conversation_view.show_no_sources_message()
- self.empty_conversation_view.show()
- elif not self.source_list.get_selected_source():
- self.empty_conversation_view.show_no_source_selected_message()
- self.empty_conversation_view.show()
- else:
- self.empty_conversation_view.hide()
-
# If the source list in the GUI is empty, then we will run the optimized initial update.
# Otherwise, do a regular source list update.
if not self.source_list.source_items:
@@ -690,20 +858,96 @@ def show_sources(self, sources: list[Source]) -> None:
# Then call the function to remove the wrapper and its children.
self.delete_conversation(source_uuid)
+ # Show the correct conversation pane gui element depending on
+ # the number of sources a) available and b) selected.
+ # An improved approach will be to create an `on_source_context_update`
+ # pyQtSlot that subscribes/listens for storage updates and calls
+ # `show_sources` and `show_conversation_context`.
+ self._on_update_conversation_context()
+
+ def _on_update_conversation_context(self) -> None:
+ """
+ Show the correct view type based on the number of available and selected sources.
+
+ If there are no sources, show the empty conversation view.
+ If there are sources, but none are selected, show the "Select a source" view.
+ If there are sources and exactly one has been selected, show the conversation
+ with that source.
+ If multiple sources are selected, show the "Multiple sources selected" view.
+
+ This method can be triggered by a click event (list index changed) or by a "sync"
+ event (sourcelist updated), which redraws the list.
+
+ In the latter case, supply list[Source] and set is_redraw_event to True.
+
+ Return number of selected sources.
+ """
+ selected = len(self.source_list.selectedItems())
+ if selected == 0 and self.source_list.count() == 0:
+ self.view_layout.setCurrentIndex(self.NO_SOURCES_INDEX)
+ elif selected == 0:
+ self.view_layout.setCurrentIndex(self.NOTHING_SELECTED_INDEX)
+ elif selected > 1:
+ self.view_layout.setCurrentIndex(self.MULTI_SELECTED_INDEX)
+ else:
+ # Exactly one source selected
+ self.view_layout.setCurrentIndex(self.CONVERSATION_INDEX)
+
+ @pyqtSlot()
def on_source_changed(self) -> None:
"""
- Show conversation for the selected source.
+ Show conversation for the selected source, or, if multiple sources are selected,
+ show multi select view.
+ """
+
+ selected = len(self.source_list.selectedItems())
+ if selected == 1:
+ # One source selected; prepare the conversation widget
+ try:
+ source = self.source_list.get_selected_source()
+
+ # Avoid race between user selection and remote deletion
+ if not source:
+ return
+
+ self.controller.session.refresh(source)
+
+ # Immediately show the selected source as seen in the UI and then make a
+ # request to mark source as seen.
+ self.source_list.source_selected.emit(source.uuid)
+ self.controller.mark_seen(source)
+
+ # Get or create the SourceConversationWrapper
+ if source.uuid in self.source_conversations:
+ conversation_wrapper = self.source_conversations[source.uuid]
+ conversation_wrapper.conversation_view.update_conversation( # type: ignore[has-type]
+ source.collection
+ )
+ else:
+ conversation_wrapper = SourceConversationWrapper(
+ source, self.controller, self._state
+ )
+ self.source_conversations[source.uuid] = conversation_wrapper
+
+ # Put this widget into the QStackedLayout at the correct position
+ self.set_conversation(conversation_wrapper)
+ logger.debug(f"Set conversation to the selected source with uuid: {source.uuid}")
+
+ except sqlalchemy.exc.InvalidRequestError as e:
+ logger.debug(e)
+
+ # Now show the right widget depending on the selection
+ self._on_update_conversation_context()
+
+ def refresh_source_conversations(self) -> None:
+ """
+ Refresh the selected source conversation.
"""
try:
source = self.source_list.get_selected_source()
if not source:
return
-
self.controller.session.refresh(source)
-
- # Immediately show the selected source as seen in the UI and then make a request to mark
- # source as seen.
- self.source_list.source_selected.emit(source.uuid)
self.controller.mark_seen(source)
# Get or create the SourceConversationWrapper
@@ -718,26 +962,6 @@ def on_source_changed(self) -> None:
)
self.source_conversations[source.uuid] = conversation_wrapper
- self.set_conversation(conversation_wrapper)
- logger.debug(f"Set conversation to the selected source with uuid: {source.uuid}")
-
- except sqlalchemy.exc.InvalidRequestError as e:
- logger.debug(e)
-
- def refresh_source_conversations(self) -> None:
- """
- Refresh the selected source conversation.
- """
- try:
- source = self.source_list.get_selected_source()
- if not source:
- return
- self.controller.session.refresh(source)
- self.controller.mark_seen(source)
- conversation_wrapper = self.source_conversations[source.uuid]
- conversation_wrapper.conversation_view.update_conversation( # type: ignore[has-type]
- source.collection
- )
except sqlalchemy.exc.InvalidRequestError as e:
logger.debug("Error refreshing source conversations: %s", e)
@@ -754,21 +978,26 @@ def delete_conversation(self, source_uuid: str) -> None:
except KeyError:
logger.debug(f"No SourceConversationWrapper for {source_uuid} to delete")
- def set_conversation(self, widget: QWidget) -> None:
+ def set_conversation(self, conversation: SourceConversationWrapper) -> None:
"""
- Update the view holder to contain the referenced widget.
+ Replace rendered conversation at CONVERSATION_INDEX. Does not change
+ QStackedLayout current index.
"""
- old_widget = self.view_layout.takeAt(0)
+ self.view_layout.insertWidget(self.CONVERSATION_INDEX, conversation)
- if old_widget and old_widget.widget():
- old_widget.widget().hide()
+ # At the moment, we don't keep these widgets as pages in the stacked layout,
+ # and we store an in-memory dict of {uuids: widget}s to avoid recreating a widget every
+ # time a conversation is revisited. A fixed-size cache could be implemented here instead.
+ layoutitem = self.view_layout.itemAt(self.CONVERSATION_INDEX + 1)
+ if layoutitem:
+ self.view_layout.removeWidget(layoutitem.widget())
- self.empty_conversation_view.hide()
- self.view_layout.addWidget(widget)
- widget.show()
+class ConversationPaneView(QWidget):
+ """
+ Base widget element for the ConversationPane.
+ """
-class EmptyConversationView(QWidget):
MARGIN = 30
NEWLINE_HEIGHT_PX = 35
@@ -776,17 +1005,20 @@ def __init__(self) -> None:
super().__init__()
self.setObjectName("EmptyConversationView")
+ self._layout = QVBoxLayout()
+ self.setContentsMargins(self.MARGIN, self.MARGIN, self.MARGIN, self.MARGIN)
+ self._layout.setAlignment(Qt.AlignCenter)
+ self.setLayout(self._layout)
- # Set layout
- layout = QHBoxLayout()
- layout.setContentsMargins(self.MARGIN, self.MARGIN, self.MARGIN, self.MARGIN)
- self.setLayout(layout)
- # Create widgets
- self.no_sources = QWidget()
- self.no_sources.setObjectName("EmptyConversationView_no_sources")
- no_sources_layout = QVBoxLayout()
- self.no_sources.setLayout(no_sources_layout)
+class EmptyConversationView(ConversationPaneView):
+ """
+ Displayed in conversation pane when sourcelist is empty.
+ """
+
+ def __init__(self) -> None:
+ super().__init__()
+
no_sources_instructions = QLabel(_("Nothing to see just yet!"))
no_sources_instructions.setObjectName("EmptyConversationView_instructions")
no_sources_instructions.setWordWrap(True)
@@ -798,16 +1030,21 @@ def __init__(self) -> None:
_("This is where you will read messages, reply to sources, and work with files.")
)
no_sources_instruction_details2.setWordWrap(True)
- no_sources_layout.addWidget(no_sources_instructions)
- no_sources_layout.addSpacing(self.NEWLINE_HEIGHT_PX)
- no_sources_layout.addWidget(no_sources_instruction_details1)
- no_sources_layout.addSpacing(self.NEWLINE_HEIGHT_PX)
- no_sources_layout.addWidget(no_sources_instruction_details2)
-
- self.no_source_selected = QWidget()
- self.no_source_selected.setObjectName("EmptyConversationView_no_source_selected")
- no_source_selected_layout = QVBoxLayout()
- self.no_source_selected.setLayout(no_source_selected_layout)
+ self._layout.addWidget(no_sources_instructions)
+ self._layout.addSpacing(self.NEWLINE_HEIGHT_PX)
+ self._layout.addWidget(no_sources_instruction_details1)
+ self._layout.addSpacing(self.NEWLINE_HEIGHT_PX)
+ self._layout.addWidget(no_sources_instruction_details2)
+
+
+class NothingSelectedView(ConversationPaneView):
+ """
+ Displayed in conversation pane when sources are present but none are selected.
+ """
+
+ def __init__(self) -> None:
+ super().__init__()
+
no_source_selected_instructions = QLabel(_("Select a source from the list, to:"))
no_source_selected_instructions.setObjectName("EmptyConversationView_instructions")
no_source_selected_instructions.setWordWrap(True)
@@ -838,24 +1075,50 @@ def __init__(self) -> None:
bullet3_layout.addWidget(bullet3_bullet)
bullet3_layout.addWidget(QLabel(_("Send a response")))
bullet3_layout.addStretch()
- no_source_selected_layout.addWidget(no_source_selected_instructions)
- no_source_selected_layout.addSpacing(self.NEWLINE_HEIGHT_PX)
- no_source_selected_layout.addWidget(bullet1)
- no_source_selected_layout.addWidget(bullet2)
- no_source_selected_layout.addWidget(bullet3)
- no_source_selected_layout.addSpacing(self.NEWLINE_HEIGHT_PX * 4)
-
- # Add widgets
- layout.addWidget(self.no_sources, alignment=Qt.AlignCenter)
- layout.addWidget(self.no_source_selected, alignment=Qt.AlignCenter)
-
- def show_no_sources_message(self) -> None:
- self.no_sources.show()
- self.no_source_selected.hide()
+ no_source_selected_end_instructions = QLabel(
+ _("Or, select multiple sources with Ctrl+click.")
+ )
+ no_source_selected_end_instructions.setObjectName("EmptyConversationView_instructions")
+ no_source_selected_end_instructions.setWordWrap(True)
+ self._layout.addWidget(no_source_selected_instructions)
+ self._layout.addSpacing(self.NEWLINE_HEIGHT_PX)
+ self._layout.addWidget(bullet1)
+ self._layout.addWidget(bullet2)
+ self._layout.addWidget(bullet3)
+ self._layout.addSpacing(self.NEWLINE_HEIGHT_PX * 4)
+ self._layout.addWidget(no_source_selected_end_instructions)
+ self._layout.addSpacing(self.NEWLINE_HEIGHT_PX * 4)
+
+
+class MultiSelectView(ConversationPaneView):
+ """
+ Displayed in conversation pane when multiple sources are selected.
+ """
- def show_no_source_selected_message(self) -> None:
- self.no_sources.hide()
- self.no_source_selected.show()
+ def __init__(self) -> None:
+ super().__init__()
+ multi_sources_instructions = QLabel(_("Multiple Sources Selected"))
+ multi_sources_instructions.setObjectName("EmptyConversationView_instructions")
+ multi_sources_instructions.setWordWrap(True)
+ multi_sources_instruction_details1 = QLabel(
+ _(
+ "Select or de-select sources using Ctrl+click, Shift+click, "
+ "or by dragging the mouse."
+ )
+ )
+ multi_sources_instruction_details1.setWordWrap(True)
+ multi_sources_instruction_details2 = QLabel(
+ _(
+ "Use the top toolbar to delete multiple sources at once. "
+ "You will be shown a confirmation dialog before anything is deleted."
+ )
+ )
+ multi_sources_instruction_details2.setWordWrap(True)
+ self._layout.addWidget(multi_sources_instructions)
+ self._layout.addSpacing(self.NEWLINE_HEIGHT_PX)
+ self._layout.addWidget(multi_sources_instruction_details1)
+ self._layout.addSpacing(self.NEWLINE_HEIGHT_PX)
+ self._layout.addWidget(multi_sources_instruction_details2)
class SourceListWidgetItem(QListWidgetItem):
@@ -880,9 +1143,13 @@ class SourceList(QListWidget):
Displays the list of sources.
"""
+ # State machine signals
source_selection_changed = pyqtSignal(state.SourceId)
source_selection_cleared = pyqtSignal()
+ # Bulk-context signal (toolbar)
+ selected_sources = pyqtSignal(object) # list[Source]
+
NUM_SOURCES_TO_ADD_AT_A_TIME = 32
INITIAL_UPDATE_SCROLLBAR_WIDTH = 20
@@ -1035,6 +1302,7 @@ def schedule_source_management(slice_size: int = slice_size) -> None:
QTimer.singleShot(1, schedule_source_management)
def get_selected_source(self) -> Optional[Source]:
+ # if len == 0, return None
if not self.selectedItems():
return None
@@ -1080,12 +1348,29 @@ def set_snippet(self, source_uuid: str, collection_item_uuid: str, content: str)
@pyqtSlot()
def _on_item_selection_changed(self) -> None:
- source = self.get_selected_source()
- if source is not None:
- self.source_selection_changed.emit(state.SourceId(source.uuid))
+ """
+ 0..n items may be selected. If multiple items are selected, to avoid confusion,
+ don't preview any individual source conversation, but instead show a contextual message.
+ """
+
+ logger.debug(f"{len(self.selectedItems())} selected")
+ selected = []
+ for item in self.selectedItems():
+ widget = self.itemWidget(item)
+ if isinstance(widget, SourceWidget):
+ selected.append(widget.source)
+
+ # Show conversation view if one source selected
+ if len(selected) == 1:
+ source = self.get_selected_source()
+ if source:
+ self.source_selection_changed.emit(state.SourceId(source.uuid))
else:
self.source_selection_cleared.emit()
+ # Update listeners (action toolbar) with current selection
+ self.selected_sources.emit(selected)
+
class SourcePreview(SecureQLabel):
PREVIEW_WIDTH_DIFFERENCE = 140
@@ -3420,9 +3705,12 @@ class SourceMenu(QMenu):
This menu provides below functionality via menu actions:
- 1. Delete source
-
- Note: At present this only supports "delete" operation.
+ * Delete Source
+ * Delete Conversation
+ * Download Files
+ * Export Transcript
+ * Export Conversation and Transcript
+ * Print Transcript
"""
SOURCE_MENU_CSS = load_css("source_menu.css")
diff --git a/client/securedrop_client/locale/messages.pot b/client/securedrop_client/locale/messages.pot
index b5f68b206..15c8b1ec1 100644
--- a/client/securedrop_client/locale/messages.pot
+++ b/client/securedrop_client/locale/messages.pot
@@ -112,6 +112,12 @@ msgstr ""
msgid "Last Refresh: never"
msgstr ""
+msgid "DELETE SOURCES"
+msgstr ""
+
+msgid "Delete selected source accounts. Ctrl+click sources below to select multiple sources."
+msgstr ""
+
msgid "{}"
msgstr ""
@@ -142,6 +148,18 @@ msgstr ""
msgid "Send a response"
msgstr ""
+msgid "Or, select multiple sources with Ctrl+click."
+msgstr ""
+
+msgid "Multiple Sources Selected"
+msgstr ""
+
+msgid "Select or de-select sources using Ctrl+click, Shift+click, or by dragging the mouse."
+msgstr ""
+
+msgid "Use the top toolbar to delete multiple sources at once. You will be shown a confirmation dialog before anything is deleted."
+msgstr ""
+
msgid "Deleting files and messages…"
msgstr ""
diff --git a/client/securedrop_client/logic.py b/client/securedrop_client/logic.py
index 565774ce5..e9d42b3b6 100644
--- a/client/securedrop_client/logic.py
+++ b/client/securedrop_client/logic.py
@@ -417,6 +417,9 @@ def __init__( # type: ignore[no-untyped-def]
):
os.chmod(self.last_sync_filepath, 0o600)
+ # Store currently-selected sources
+ self._selected_sources: list[db.Source] | None = None
+
@pyqtSlot(int)
def _on_main_queue_updated(self, num_items: int) -> None:
if num_items > 0:
@@ -607,6 +610,9 @@ def login_offline_mode(self) -> None:
# may have attempted online mode login, then switched to offline)
self.gui.clear_clipboard()
self.gui.show_main_window()
+
+ # All child elements of main window should show logged_out state
+ self.gui.logout()
self.update_sources()
def on_action_requiring_login(self) -> None:
@@ -1044,21 +1050,32 @@ def on_delete_source_failure(self, e: Exception) -> None:
self.source_deletion_failed.emit(e.source_uuid)
@login_required
- def delete_source(self, source: db.Source) -> None:
+ def delete_sources(self, sources: list[db.Source]) -> None:
"""
- Performs a delete operation on source record.
+ Performs a delete operation on one or more source records.
- This method will submit a job to delete the source record on
- the server. If the job succeeds, the success handler will
+ This method will submit a job to delete each target source record on
+ the server. If a given job succeeds, the success handler will
synchronize the server records with the local state. If not,
the failure handler will display an error.
"""
- job = DeleteSourceJob(source.uuid)
- job.success_signal.connect(self.on_delete_source_success)
- job.failure_signal.connect(self.on_delete_source_failure)
+ for source in sources:
+ try:
+ # Accessing source.uuid requires the source object to be
+ # present in the database.
+ # To avoid passing and referencing orm objects, a simplified
+ # ViewModel layer decoupled from the db that presents data to the API/GUI
+ # would be another possibility.
+ job = DeleteSourceJob(source.uuid)
+ except sqlalchemy.orm.exc.ObjectDeletedError:
+ logger.warning("DeleteSourceJob requested but source already deleted")
+ return
- self.add_job.emit(job)
- self.source_deleted.emit(source.uuid)
+ job.success_signal.connect(self.on_delete_source_success)
+ job.failure_signal.connect(self.on_delete_source_failure)
+
+ self.add_job.emit(job)
+ self.source_deleted.emit(source.uuid)
@login_required
def delete_conversation(self, source: db.Source) -> None:
@@ -1183,3 +1200,10 @@ def update_failed_replies(self) -> None:
failed_replies = storage.mark_all_pending_drafts_as_failed(self.session)
for failed_reply in failed_replies:
self.reply_failed.emit(failed_reply.uuid)
+
+ @pyqtSlot(object)
+ def on_receive_selected_sources(self, sources: list[db.Source]) -> None:
+ self._selected_sources = sources
+
+ def get_selected_sources(self) -> list[db.Source] | None:
+ return self._selected_sources
diff --git a/client/securedrop_client/resources/css/sdclient.css b/client/securedrop_client/resources/css/sdclient.css
index 5aca95c7f..a60b60e8e 100644
--- a/client/securedrop_client/resources/css/sdclient.css
+++ b/client/securedrop_client/resources/css/sdclient.css
@@ -115,6 +115,45 @@ QWidget {
background-color: #85f6fe;
}
+#InnerTopPane {
+ background-color: #f3f5f9;
+}
+
+#BatchActionWidget {
+ background-color: #fff;
+ margin: 2px;
+}
+
+#BatchActionToolbar {
+ min-height: 42px;
+}
+
+#BatchActionToolbar QToolButton:disabled {
+ color: #f9f9ff;
+ background-color: #a5b3e9;
+ border: 2px solid #a5b3e9;
+}
+
+#BatchActionToolbar QToolButton {
+ font-family: 'Montserrat';
+ font-weight: 600;
+ font-size: 12px;
+ padding: 2px;
+ color: #a5b3e9;
+ background-color: #fff;
+ border: 2px solid #a5b3e9;
+ border-radius: 4%;
+ margin-left: 2px;
+ margin-right: 2px;
+}
+
+#BatchActionToolbar QToolButton:hover,
+#BatchActionToolbar QToolButton:checked {
+ background-color: #a5b3e9;
+ border: 2px solid #a5b3e9;
+ color: #fff;
+}
+
#MainView {
min-height: 558;
}
@@ -131,7 +170,7 @@ QWidget {
#EmptyConversationView QLabel {
font-family: Montserrat;
font-weight: 400;
- font-size: 35px;
+ font-size: 30px;
color: #a5b3e9;
}
@@ -141,7 +180,7 @@ QWidget {
#EmptyConversationView QLabel#EmptyConversationView_bullet {
margin: 4px 0px 0px 0px;
- font-size: 35px;
+ font-size: 30px;
font-weight: 600;
}
@@ -154,11 +193,12 @@ QListView#SourceList {
}
QListView#SourceList::item:selected {
- background-color: rgba(244, 245, 255, 0.84);
+ /* #a5b3e94d aka #e4e8f8 */
+ background-color: rgba(165, 179, 233, 0.30);
}
QListView#SourceList::item:hover {
- background-color: #f9f9ff;
+ background-color: #e4e8f8;
}
#SourceWidget_container {
diff --git a/client/securedrop_client/resources/images/delete_sources_toolbar_icon.svg b/client/securedrop_client/resources/images/delete_sources_toolbar_icon.svg
new file mode 100644
index 000000000..8ab2687b4
--- /dev/null
+++ b/client/securedrop_client/resources/images/delete_sources_toolbar_icon.svg
@@ -0,0 +1,11 @@
+
+
\ No newline at end of file
diff --git a/client/tests/functional/data/test_delete_source.yml b/client/tests/functional/data/test_delete_source.yml
deleted file mode 100644
index bdcb0c603..000000000
--- a/client/tests/functional/data/test_delete_source.yml
+++ /dev/null
@@ -1,1192 +0,0 @@
-interactions:
-- request:
- body: '{"username": "journalist", "passphrase": "correct horse battery staple
- profanity oil chewy", "one_time_code": "668631"}'
- headers: {}
- method: POST
- uri: api/v1/token
- response: !!python/object:securedrop_client.sdk.JSONResponse
- data:
- expiration: '2024-02-29T23:51:33.301258Z'
- journalist_first_name: null
- journalist_last_name: null
- journalist_uuid: d53edd4d-1802-40ce-9c69-86d9b6b75ac8
- token: ImxEbER6d3I2enhzdE41d0dqbUpfR1QwdjRmQ0hMVDZ0ZldKQjlqZEJHeHci.ZeD8ZQ.4YCgvY1FDiEt7Rs7ashjW4AgPAs
- headers:
- connection: close
- content-length: '290'
- content-type: application/json
- date: Thu, 29 Feb 2024 21:51:33 GMT
- server: Werkzeug/2.2.3 Python/3.8.10
- status: 200
-- request:
- body: null
- headers:
- Accept:
- - application/json
- Authorization:
- - Token ImxEbER6d3I2enhzdE41d0dqbUpfR1QwdjRmQ0hMVDZ0ZldKQjlqZEJHeHci.ZeD8ZQ.4YCgvY1FDiEt7Rs7ashjW4AgPAs
- Content-Type:
- - application/json
- method: GET
- uri: api/v1/users
- response: !!python/object:securedrop_client.sdk.JSONResponse
- data:
- users:
- - first_name: null
- last_name: null
- username: journalist
- uuid: d53edd4d-1802-40ce-9c69-86d9b6b75ac8
- - first_name: null
- last_name: null
- username: dellsberg
- uuid: 16b3778d-28e5-48fe-afa5-abc2f1f99e62
- - first_name: null
- last_name: null
- username: deleted
- uuid: bfb91841-1eae-4d73-8e42-aa763de1f01b
- headers:
- connection: close
- content-length: '474'
- content-type: application/json
- date: Thu, 29 Feb 2024 21:51:33 GMT
- server: Werkzeug/2.2.3 Python/3.8.10
- status: 200
-- request:
- body: null
- headers:
- Accept:
- - application/json
- Authorization:
- - Token ImxEbER6d3I2enhzdE41d0dqbUpfR1QwdjRmQ0hMVDZ0ZldKQjlqZEJHeHci.ZeD8ZQ.4YCgvY1FDiEt7Rs7ashjW4AgPAs
- Content-Type:
- - application/json
- method: GET
- uri: api/v1/sources
- response: !!python/object:securedrop_client.sdk.JSONResponse
- data:
- sources:
- - add_star_url: /api/v1/sources/71fb3b51-4ee6-4c37-bd13-a6d5a5df71f9/add_star
- interaction_count: 6
- is_flagged: false
- is_starred: false
- journalist_designation: mindless stevia
- key:
- fingerprint: 3565064B66EC1B1FFFF9F91E60C9B9DB9B742F40
- public: '-----BEGIN PGP PUBLIC KEY BLOCK-----
-
- Comment: 3565 064B 66EC 1B1F FFF9 F91E 60C9 B9DB 9B74 2F40
-
- Comment: Source Key = num_to_delete
+
+ qtbot.waitUntil(check_for_sources, timeout=TIME_RENDER_SOURCE_LIST)
+
+ # Select num_to_delete sources at random.
+ source_uuids_to_delete = random.sample(
+ sorted(gui.main_view.source_list.source_items), num_to_delete
+ )
+ for i, source_uuid in enumerate(source_uuids_to_delete):
+ source_item = gui.main_view.source_list.source_items[source_uuid]
+ source_widget = gui.main_view.source_list.itemWidget(source_item)
+
+ # Simulate a non- initial click...
+ if i == 0:
+ qtbot.mouseClick(source_widget, Qt.LeftButton)
+ # ...followed by subsequent -clicks.
+ else:
+ qtbot.mouseClick(
+ source_widget, Qt.LeftButton, modifier=Qt.KeyboardModifier.ControlModifier
+ )
+
+ qtbot.wait(TIME_CLICK_ACTION)
+
+ def check_for_conversation():
+ """
+ Check that the GUI is showing the right view for the number of
+ sources selected.
+ """
+ if num_to_delete == 1:
+ index = gui.main_view.CONVERSATION_INDEX
+ widget = SourceConversationWrapper
+ else:
+ index = gui.main_view.MULTI_SELECTED_INDEX
+ widget = MultiSelectView
+
+ assert gui.main_view.view_layout.currentIndex() == index
+ assert isinstance(
+ gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()), widget
+ )
+
+ qtbot.waitUntil(check_for_conversation, timeout=TIME_RENDER_CONV_VIEW)
+
+ # Delete the selected source. We can't qtbot.mouseClick() on a QAction, but
+ # we can trigger it directly.
+ gui.main_view.top_pane.batch_actions.toolbar.delete_sources_action.trigger()
+
+ def check_and_accept_dialog():
+ """Check that the dialog confirms deletion of all sources selected."""
+ dialog = (
+ gui.main_view.top_pane.batch_actions.toolbar._last_dialog
+ ) # FIXME: workaround for #2273
+ assert set(source_uuids_to_delete) == {source.uuid for source in dialog.sources}
+ dialog.accept()
+
+ qtbot.waitUntil(check_and_accept_dialog, timeout=TIME_CLICK_ACTION)
+
+ def check_source_list():
+ """
+ Check that all of the sources selected for deletion have been removed
+ from the source list.
+ """
+ for source_uuid in source_uuids_to_delete:
+ assert source_uuid not in gui.main_view.source_list.source_items
+
+ qtbot.waitUntil(check_source_list, timeout=TIME_RENDER_SOURCE_LIST)
diff --git a/client/tests/functional/test_deleted_file_filewidget.py b/client/tests/functional/test_deleted_file_filewidget.py
index 7b8f8f872..6b0d673b3 100644
--- a/client/tests/functional/test_deleted_file_filewidget.py
+++ b/client/tests/functional/test_deleted_file_filewidget.py
@@ -40,8 +40,8 @@ def check_for_sources():
qtbot.wait(TIME_CLICK_ACTION)
def conversation_with_file_is_rendered():
- assert gui.main_view.view_layout.itemAt(0)
- conversation = gui.main_view.view_layout.itemAt(0).widget()
+ assert gui.main_view.view_layout.currentIndex() == gui.main_view.CONVERSATION_INDEX
+ conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex())
assert isinstance(conversation, SourceConversationWrapper)
file_id = list(conversation.conversation_view.current_messages.keys())[2]
file_widget = conversation.conversation_view.current_messages[file_id]
@@ -49,7 +49,7 @@ def conversation_with_file_is_rendered():
# Get the selected source conversation that contains a file attachment
qtbot.waitUntil(conversation_with_file_is_rendered, timeout=TIME_RENDER_CONV_VIEW)
- conversation = gui.main_view.view_layout.itemAt(0).widget()
+ conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex())
file_id = list(conversation.conversation_view.current_messages.keys())[2]
file_widget = conversation.conversation_view.current_messages[file_id]
diff --git a/client/tests/functional/test_download_file.py b/client/tests/functional/test_download_file.py
index df31d8ccd..40701305d 100644
--- a/client/tests/functional/test_download_file.py
+++ b/client/tests/functional/test_download_file.py
@@ -40,7 +40,8 @@ def check_for_sources():
def conversation_with_file_is_rendered():
assert gui.main_view.view_layout.itemAt(0)
- conversation = gui.main_view.view_layout.itemAt(0).widget()
+ conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex())
+
assert isinstance(conversation, SourceConversationWrapper)
file_id = list(conversation.conversation_view.current_messages.keys())[2]
file_widget = conversation.conversation_view.current_messages[file_id]
@@ -48,7 +49,8 @@ def conversation_with_file_is_rendered():
# Get the selected source conversation that contains a file attachment
qtbot.waitUntil(conversation_with_file_is_rendered, timeout=TIME_RENDER_CONV_VIEW)
- conversation = gui.main_view.view_layout.itemAt(0).widget()
+ conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex())
+
file_id = list(conversation.conversation_view.current_messages.keys())[2]
file_widget = conversation.conversation_view.current_messages[file_id]
diff --git a/client/tests/functional/test_export_wizard.py b/client/tests/functional/test_export_wizard.py
index a1351bbdd..8d084489c 100644
--- a/client/tests/functional/test_export_wizard.py
+++ b/client/tests/functional/test_export_wizard.py
@@ -49,14 +49,16 @@ def check_for_sources():
def conversation_with_file_is_rendered():
assert gui.main_view.view_layout.itemAt(0)
- conversation = gui.main_view.view_layout.itemAt(0).widget()
+ conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex())
+
assert isinstance(conversation, SourceConversationWrapper)
file_widget = list(conversation.conversation_view.current_messages.values())[2]
assert isinstance(file_widget, FileWidget)
# Get the selected source conversation that contains a file attachment
qtbot.waitUntil(conversation_with_file_is_rendered, timeout=TIME_RENDER_CONV_VIEW)
- conversation = gui.main_view.view_layout.itemAt(0).widget()
+ conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex())
+
file_widget = list(conversation.conversation_view.current_messages.values())[2]
# If the file is not downloaded, click on the download button
diff --git a/client/tests/functional/test_offline_delete_source.py b/client/tests/functional/test_offline_delete_source.py
index 1a108752e..ef0c106e2 100644
--- a/client/tests/functional/test_offline_delete_source.py
+++ b/client/tests/functional/test_offline_delete_source.py
@@ -8,6 +8,7 @@
from flaky import flaky
from PyQt5.QtCore import Qt
+from securedrop_client.gui.widgets import SourceConversationWrapper
from tests.conftest import TIME_CLICK_ACTION, TIME_RENDER_CONV_VIEW, TIME_RENDER_SOURCE_LIST
@@ -31,17 +32,20 @@ def check_for_sources():
qtbot.wait(TIME_CLICK_ACTION)
def check_for_conversation():
- assert gui.main_view.view_layout.itemAt(0)
- assert gui.main_view.view_layout.itemAt(0).widget()
+ assert gui.main_view.view_layout.currentIndex() == gui.main_view.CONVERSATION_INDEX
+ assert isinstance(
+ gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()),
+ SourceConversationWrapper,
+ )
# Get the selected source conversation
qtbot.waitUntil(check_for_conversation, timeout=TIME_RENDER_CONV_VIEW)
- conversation = gui.main_view.view_layout.itemAt(0).widget()
+ conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex())
# Attempt to delete the selected source
# Note: The qtbot object cannot interact with QAction items (as used in the delete button/menu)
# so we programatically attempt to delete the source rather than using the GUI via qtbot
- controller.delete_source(conversation.conversation_title_bar.source)
+ controller.delete_sources([conversation.conversation_title_bar.source])
def check_for_error():
msg = gui.bottom_pane.error_status_bar.status_bar.currentMessage()
diff --git a/client/tests/functional/test_offline_read_conversation.py b/client/tests/functional/test_offline_read_conversation.py
index 21d6e291a..7fcb44fc9 100644
--- a/client/tests/functional/test_offline_read_conversation.py
+++ b/client/tests/functional/test_offline_read_conversation.py
@@ -8,6 +8,7 @@
from flaky import flaky
from PyQt5.QtCore import Qt
+from securedrop_client.gui.widgets import SourceConversationWrapper
from tests.conftest import TIME_CLICK_ACTION, TIME_RENDER_CONV_VIEW, TIME_RENDER_SOURCE_LIST
@@ -30,12 +31,15 @@ def check_for_sources():
qtbot.wait(TIME_CLICK_ACTION)
def check_for_conversation():
- assert gui.main_view.view_layout.itemAt(0)
- assert gui.main_view.view_layout.itemAt(0).widget()
+ assert gui.main_view.view_layout.currentIndex() == gui.main_view.CONVERSATION_INDEX
+ assert isinstance(
+ gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()),
+ SourceConversationWrapper,
+ )
# Get the selected source conversation
qtbot.waitUntil(check_for_conversation, timeout=TIME_RENDER_CONV_VIEW)
- conversation = gui.main_view.view_layout.itemAt(0).widget()
+ conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex())
# Verify that the conversation widgets exist
assert len(list(conversation.conversation_view.current_messages.keys())) > 0
diff --git a/client/tests/functional/test_offline_send_reply.py b/client/tests/functional/test_offline_send_reply.py
index f63e24c46..bec6a871b 100644
--- a/client/tests/functional/test_offline_send_reply.py
+++ b/client/tests/functional/test_offline_send_reply.py
@@ -8,6 +8,7 @@
from flaky import flaky
from PyQt5.QtCore import Qt
+from securedrop_client.gui.widgets import SourceConversationWrapper
from tests.conftest import TIME_CLICK_ACTION, TIME_RENDER_CONV_VIEW, TIME_RENDER_SOURCE_LIST
@@ -30,12 +31,16 @@ def check_for_sources():
qtbot.wait(TIME_CLICK_ACTION)
def check_for_conversation():
- assert gui.main_view.view_layout.itemAt(0)
- assert gui.main_view.view_layout.itemAt(0).widget()
+ assert gui.main_view.view_layout.currentIndex() == gui.main_view.CONVERSATION_INDEX
+ assert isinstance(
+ gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()),
+ SourceConversationWrapper,
+ )
# Get the selected source conversation
qtbot.waitUntil(check_for_conversation, timeout=TIME_RENDER_CONV_VIEW)
- conversation = gui.main_view.view_layout.itemAt(0).widget()
+
+ conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex())
assert not conversation.reply_box.text_edit.isEnabled()
assert not conversation.reply_box.send_button.isVisible()
diff --git a/client/tests/functional/test_receive_message.py b/client/tests/functional/test_receive_message.py
index ea39c7abd..88f6220a4 100644
--- a/client/tests/functional/test_receive_message.py
+++ b/client/tests/functional/test_receive_message.py
@@ -8,6 +8,7 @@
from flaky import flaky
from PyQt5.QtCore import Qt
+from securedrop_client.gui.widgets import SourceConversationWrapper
from tests.conftest import TIME_CLICK_ACTION, TIME_RENDER_CONV_VIEW, TIME_RENDER_SOURCE_LIST
@@ -32,12 +33,16 @@ def check_for_sources():
qtbot.wait(TIME_CLICK_ACTION)
def check_for_conversation():
- assert gui.main_view.view_layout.itemAt(0)
- assert gui.main_view.view_layout.itemAt(0).widget()
+ assert gui.main_view.view_layout.currentIndex() == gui.main_view.CONVERSATION_INDEX
+ assert isinstance(
+ gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()),
+ SourceConversationWrapper,
+ )
# Get the selected source conversation
qtbot.waitUntil(check_for_conversation, timeout=TIME_RENDER_CONV_VIEW)
- conversation = gui.main_view.view_layout.itemAt(0).widget()
+ conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex())
+
message_id = list(conversation.conversation_view.current_messages.keys())[0]
message = conversation.conversation_view.current_messages[message_id]
diff --git a/client/tests/functional/test_send_reply.py b/client/tests/functional/test_send_reply.py
index ab05bd96a..ba889e476 100644
--- a/client/tests/functional/test_send_reply.py
+++ b/client/tests/functional/test_send_reply.py
@@ -8,6 +8,7 @@
from flaky import flaky
from PyQt5.QtCore import Qt
+from securedrop_client.gui.widgets import SourceConversationWrapper
from tests.conftest import TIME_CLICK_ACTION, TIME_RENDER_CONV_VIEW, TIME_RENDER_SOURCE_LIST
@@ -30,12 +31,15 @@ def check_for_sources():
qtbot.wait(TIME_CLICK_ACTION)
def check_for_conversation():
- assert gui.main_view.view_layout.itemAt(0)
- assert gui.main_view.view_layout.itemAt(0).widget()
+ assert gui.main_view.view_layout.currentIndex() == gui.main_view.CONVERSATION_INDEX
+ assert isinstance(
+ gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()),
+ SourceConversationWrapper,
+ )
# Get the selected source conversation
qtbot.waitUntil(check_for_conversation, timeout=TIME_RENDER_CONV_VIEW)
- conversation = gui.main_view.view_layout.itemAt(0).widget()
+ conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex())
item_count = len(list(conversation.conversation_view.current_messages.keys()))
# Focus on the reply box and type a message
diff --git a/client/tests/gui/source/delete/test_dialog.py b/client/tests/gui/source/delete/test_dialog.py
index 29c7417ec..dc854026c 100644
--- a/client/tests/gui/source/delete/test_dialog.py
+++ b/client/tests/gui/source/delete/test_dialog.py
@@ -5,7 +5,7 @@
@pytest.fixture(
- params=[set(), set([factory.Source()]), set([factory.Source(), factory.Source()])],
+ params=[[], [factory.Source()], [factory.Source(), factory.Source()]],
)
def dialog(request):
"""
@@ -22,7 +22,7 @@ def dialog(request):
class TestDeleteSourceDialog:
def test_dialog_setup(self, dialog):
assert type(dialog) is DeleteSourceDialog
- assert type(dialog.sources) is set
+ assert type(dialog.sources) is list
assert dialog.dangerous
def test_default_button_is_safer_choice(self, dialog):
@@ -69,7 +69,7 @@ def test_correct_format_body_text(self):
For n > 1 sources, ensure the warning text includes
all the journalist desginators.
"""
- sources = set()
+ sources = []
names = [
"source one",
"source two",
@@ -82,7 +82,7 @@ def test_correct_format_body_text(self):
for item in names:
source = factory.Source(journalist_designation=item)
- sources.update([source])
+ sources.append(source)
dialog = DeleteSourceDialog(sources)
diff --git a/client/tests/gui/test_actions.py b/client/tests/gui/test_actions.py
index aa7a3cdba..25bb7b955 100644
--- a/client/tests/gui/test_actions.py
+++ b/client/tests/gui/test_actions.py
@@ -102,7 +102,10 @@ def test_deletes_source_when_dialog_accepted(self):
self.action.trigger()
- self._controller.delete_source.assert_called_once_with(self._source)
+ self._controller.delete_sources.assert_called_once()
+ assert (
+ self._source in self._controller.delete_sources.call_args[0][0]
+ ), self._controller.delete_sources.call_args[0][0]
def test_does_not_delete_source_when_dialog_rejected(self):
# Reject the confirmation dialog from a separate thread.
@@ -110,7 +113,7 @@ def test_does_not_delete_source_when_dialog_rejected(self):
self.action.trigger()
- assert not self._controller.delete_source.called
+ assert not self._controller.delete_sources.called
def test_requires_authenticated_journalist(self):
controller = mock.MagicMock(Controller, api=None) # no authenticated user
@@ -122,7 +125,7 @@ def test_requires_authenticated_journalist(self):
self.action.trigger()
assert not confirmation_dialog.exec.called
- assert not controller.delete_source.called
+ assert not controller.delete_sources.called
controller.on_action_requiring_login.assert_called_once()
diff --git a/client/tests/gui/test_widgets.py b/client/tests/gui/test_widgets.py
index f73abc34e..5579d1937 100644
--- a/client/tests/gui/test_widgets.py
+++ b/client/tests/gui/test_widgets.py
@@ -3,7 +3,7 @@
"""
import math
-from datetime import datetime
+from datetime import datetime, timedelta
from gettext import gettext as _
from unittest.mock import Mock, PropertyMock
@@ -31,6 +31,8 @@
LoginButton,
MainView,
MessageWidget,
+ MultiSelectView,
+ NothingSelectedView,
ReplyBoxWidget,
ReplyTextEdit,
ReplyTextEditPlaceholder,
@@ -533,21 +535,17 @@ def test_MainView_show_sources_with_none_selected(mocker):
# Set up SourceList so that SourceList.get_selected_source() returns a source
mv.source_list = SourceList()
+ mv.source_list.controller = mocker.MagicMock()
source_widget = SourceWidget(
mocker.MagicMock(), factory.Source(uuid="stub_uuid"), mocker.MagicMock(), mocker.MagicMock()
)
source_item = SourceListWidgetItem(mv.source_list)
mv.source_list.setItemWidget(source_item, source_widget)
mv.source_list.source_items["stub_uuid"] = source_item
- mocker.patch.object(mv.source_list, "update_sources")
- mv.empty_conversation_view = mocker.MagicMock()
-
- mv.show_sources([1, 2, 3])
+ mv.show_sources([factory.Source(), factory.Source(), factory.Source()])
- mv.source_list.update_sources.assert_called_once_with([1, 2, 3])
- mv.empty_conversation_view.show_no_source_selected_message.assert_called_once_with()
- mv.empty_conversation_view.show.assert_called_once_with()
+ assert mv.view_layout.currentIndex() == mv.NOTHING_SELECTED_INDEX
def test_MainView_show_sources_from_cold_start(mocker):
@@ -570,13 +568,16 @@ def test_MainView_show_sources_with_no_sources_at_all(mocker):
"""
mv = MainView(None)
mv.source_list = mocker.MagicMock()
- mv.empty_conversation_view = mocker.MagicMock()
+ mv.source_list.count = mocker.MagicMock(return_value=0)
+ mv.source_list.selectedItems = mocker.MagicMock(return_value=[])
+
+ mv._on_update_conversation_context = mocker.MagicMock(wraps=mv._on_update_conversation_context)
mv.show_sources([])
mv.source_list.update_sources.assert_called_once_with([])
- mv.empty_conversation_view.show_no_sources_message.assert_called_once_with()
- mv.empty_conversation_view.show.assert_called_once_with()
+ mv._on_update_conversation_context.assert_called()
+ assert mv.view_layout.currentIndex() == mv.NO_SOURCES_INDEX
def test_MainView_show_sources_when_sources_are_deleted(mocker):
@@ -584,18 +585,20 @@ def test_MainView_show_sources_when_sources_are_deleted(mocker):
Ensure that show_sources also deletes the SourceConversationWrapper for a deleted source.
"""
mv = MainView(None)
+ sources = [factory.Source(), factory.Source(), factory.Source(), factory.Source()]
mv.source_list = mocker.MagicMock()
- mv.empty_conversation_view = mocker.MagicMock()
+ mv.source_list.count = mocker.MagicMock(return_value=len(sources))
+ mv.source_list.getSelectedItems = mocker.MagicMock(return_value=[])
mv.source_list.update_sources = mocker.MagicMock(return_value=[])
mv.delete_conversation = mocker.MagicMock()
- mv.show_sources([1, 2, 3, 4])
+ mv.show_sources(sources)
- mv.source_list.update_sources = mocker.MagicMock(return_value=[4])
+ mv.source_list.update_sources = mocker.MagicMock(return_value=[sources[-1]])
- mv.show_sources([1, 2, 3])
+ mv.show_sources(sources[:-1])
- mv.delete_conversation.assert_called_once_with(4)
+ mv.delete_conversation.assert_called_once_with(sources[-1])
def test_MainView_delete_conversation_when_conv_wrapper_exists(mocker):
@@ -635,17 +638,66 @@ def test_MainView_on_source_changed(mocker):
"""
mv = MainView(None)
mv.set_conversation = mocker.MagicMock()
+ source = factory.Source()
mv.source_list = mocker.MagicMock()
- mv.source_list.get_selected_source = mocker.MagicMock(return_value=factory.Source())
+ mv.source_list.get_selected_source = mocker.MagicMock(return_value=source)
+ mv.source_list.selectedItems = mocker.MagicMock(return_value=[source])
+ mv.source_list.count = mocker.MagicMock(return_value=3)
mv.controller = mocker.MagicMock(is_authenticated=True)
mocker.patch("securedrop_client.gui.widgets.source_exists", return_value=True)
- scw = mocker.MagicMock()
- mocker.patch("securedrop_client.gui.widgets.SourceConversationWrapper", return_value=scw)
-
mv.on_source_changed()
mv.source_list.get_selected_source.assert_called_once_with()
- mv.set_conversation.assert_called_once_with(scw)
+ mv.set_conversation.assert_called_once()
+ assert mv.set_conversation.call_args[0][0].source == source
+
+
+def test_MainView_on_source_changed_shows_correct_context(mocker, homedir, session, session_maker):
+ """
+ Ensure correct context presented based on number of sources selected.
+ """
+ # Build sourcelist
+ sources = []
+ for i in range(10):
+ s = factory.Source()
+ sources.append(s)
+ session.add(s)
+
+ session.commit()
+
+ mv = MainView(None)
+
+ mock_gui = mocker.MagicMock()
+ controller = logic.Controller("http://localhost", mock_gui, session_maker, homedir, None)
+ controller.api = mocker.MagicMock()
+ controller.session.refresh = mocker.MagicMock()
+
+ mv.setup(controller)
+ mv.show()
+
+ assert mv.view_layout.currentIndex() == mv.NO_SOURCES_INDEX
+
+ mv.source_list.update_sources(sources)
+ mv.show_sources(sources)
+
+ assert mv.view_layout.currentIndex() == mv.NOTHING_SELECTED_INDEX
+
+ # Select a source, ensure the correct view context is shown
+ mv.source_list.setCurrentRow(0)
+ assert mv.view_layout.currentIndex() == mv.CONVERSATION_INDEX
+
+ mv.source_list.setCurrentRow(1)
+
+ assert mv.view_layout.currentIndex() == mv.CONVERSATION_INDEX
+ assert (
+ mv.controller.session.refresh.call_args[0][0]
+ == mv.source_list.itemWidget(mv.source_list.item(1)).source
+ )
+
+ # Now ensure the "multiple sources selected" view is shown
+ mv.source_list.selectAll()
+
+ assert mv.view_layout.currentIndex() == mv.MULTI_SELECTED_INDEX
def test_MainView_on_source_changed_does_not_raise_InvalidRequestError(mocker):
@@ -655,21 +707,24 @@ def test_MainView_on_source_changed_does_not_raise_InvalidRequestError(mocker):
"""
mv = MainView(None)
mv.set_conversation = mocker.MagicMock()
- mv.source_list = mocker.MagicMock()
- mv.source_list.get_selected_source = mocker.MagicMock(return_value=factory.Source())
+ source = factory.Source()
+ mv.source_list.count = mocker.MagicMock(return_value=1)
+ mv.source_list.get_selected_source = mocker.MagicMock(return_value=source)
+ mv.source_list.selectedItems = mocker.MagicMock(return_value=[source])
mv.controller = mocker.MagicMock(is_authenticated=True)
ex = sqlalchemy.exc.InvalidRequestError()
- mv.controller.session.refresh.side_effect = ex
+ mv.controller.session = mocker.MagicMock()
+ mv.controller.session.refresh = mocker.MagicMock(side_effect=ex)
mocker.patch("securedrop_client.gui.widgets.source_exists", return_value=True)
- scw = mocker.MagicMock()
- mocker.patch("securedrop_client.gui.widgets.SourceConversationWrapper", return_value=scw)
mock_logger = mocker.MagicMock()
+
mocker.patch("securedrop_client.gui.widgets.logger", mock_logger)
mv.on_source_changed()
+ mv.controller.session.refresh.assert_called()
- assert mock_logger.debug.call_count == 1
+ assert mock_logger.debug.call_count == 1, mock_logger.debug.call_args
def test_MainView_on_source_changed_when_source_no_longer_exists(mocker):
@@ -679,11 +734,12 @@ def test_MainView_on_source_changed_when_source_no_longer_exists(mocker):
mv = MainView(None)
mv.set_conversation = mocker.MagicMock()
mv.source_list = mocker.MagicMock()
+ mv.source_list.selectedItems = mocker.MagicMock()
mv.source_list.get_selected_source = mocker.MagicMock(return_value=None)
mv.on_source_changed()
- mv.source_list.get_selected_source.assert_called_once_with()
+ assert mv.view_layout.currentIndex() == mv.NOTHING_SELECTED_INDEX
mv.set_conversation.assert_not_called()
@@ -692,9 +748,13 @@ def test_MainView_on_source_changed_updates_conversation_view(mocker, session):
Test that the source collection is displayed in the conversation view.
"""
mv = MainView(None)
+ source = factory.Source()
+
# mv.source_list = mocker.MagicMock()
+ mv.source_list.count = mocker.MagicMock(return_value=1)
+ mv.source_list.selectedItems = mocker.MagicMock(return_value=[source])
+ mv.source_list.get_selected_source = mocker.MagicMock(return_value=source)
mv.controller = mocker.MagicMock(is_authenticated=True)
- source = factory.Source()
session.add(source)
file = factory.File(source=source, filename="0-mock-doc.gpg")
message = factory.Message(source=source, filename="0-mock-msg.gpg")
@@ -704,9 +764,6 @@ def test_MainView_on_source_changed_updates_conversation_view(mocker, session):
session.add(reply)
session.commit()
source_selected = mocker.patch("securedrop_client.gui.widgets.SourceList.source_selected")
- mocker.patch(
- "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source
- )
add_message_fn = mocker.patch(
"securedrop_client.gui.widgets.ConversationView.add_message", new=mocker.Mock()
)
@@ -732,56 +789,54 @@ def test_MainView_on_source_changed_SourceConversationWrapper_is_preserved(mocke
SourceConversationWrapper when we click away from a given source. We should create it the
first time, and then it should persist.
"""
- mv = MainView(None)
- mv.set_conversation = mocker.MagicMock()
- source_selected = mocker.patch("securedrop_client.gui.widgets.SourceList.source_selected")
- mv.controller = mocker.MagicMock(is_authenticated=True)
source = factory.Source()
source2 = factory.Source()
session.add(source)
session.add(source2)
session.commit()
- source_conversation_init = mocker.patch(
- "securedrop_client.gui.widgets.SourceConversationWrapper.__init__", return_value=None
- )
+ mv = MainView(None)
+ mv.source_list.count = mocker.MagicMock(return_value=2)
+ mv.source_list.source_selected = mocker.MagicMock()
- # We expect on the first call, SourceConversationWrapper.__init__ should be called.
- mocker.patch(
- "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source
+ # Side effect == first one return value, then the second return value.
+ # Simulate repeated calls
+ mv.source_list.get_selected_source = mocker.MagicMock(side_effect=[source, source2, source])
+
+ # Called twice per redraw event
+ mv.source_list.selectedItems = mocker.MagicMock(
+ side_effect=[[source], [source], [source2], [source2], [source], [source]]
)
+ mv.set_conversation = mocker.MagicMock(wraps=mv.set_conversation)
+
+ mv.controller = mocker.MagicMock(is_authenticated=True)
+
mv.on_source_changed()
assert mv.set_conversation.call_count == 1
- assert source_conversation_init.call_count == 1
- source_selected.emit.assert_called_once_with(source.uuid)
+ assert source.uuid in mv.source_conversations
+ # We haven't created this widget yet since the conversation hasn't been clicked yet
+ assert source2.uuid not in mv.source_conversations
+ mv.source_list.source_selected.emit.assert_called_once_with(source.uuid)
# Reset mocked objects for the next call of on_source_changed.
- source_conversation_init.reset_mock()
mv.set_conversation.reset_mock()
- source_selected.reset_mock()
+ mv.source_list.source_selected.reset_mock()
- # Now click on another source (source2). Since this is the first time we have clicked
- # on source2, we expect on the first call, SourceConversationWrapper.__init__ should be
- # called.
- mocker.patch(
- "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source2
- )
+ # Now click on another source (source2) ensure correct widget is constructed.
mv.on_source_changed()
assert mv.set_conversation.call_count == 1
- assert source_conversation_init.call_count == 1
- source_selected.emit.assert_called_once_with(source2.uuid)
+ assert source.uuid in mv.source_conversations
+ assert source2.uuid in mv.source_conversations
+ mv.source_list.source_selected.emit.assert_called_once_with(source2.uuid)
# Reset mocked objects for the next call of on_source_changed.
- source_conversation_init.reset_mock()
mv.set_conversation.reset_mock()
- source_selected.reset_mock()
+ mv.source_list.source_selected.reset_mock()
# But if we click back (call on_source_changed again) to the source,
# its SourceConversationWrapper should _not_ be recreated.
- mocker.patch(
- "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source
- )
conversation_wrapper = mv.source_conversations[source.uuid]
+ assert conversation_wrapper is not None
conversation_wrapper.conversation_view = mocker.MagicMock()
conversation_wrapper.conversation_view.update_conversation = mocker.MagicMock()
@@ -791,8 +846,7 @@ def test_MainView_on_source_changed_SourceConversationWrapper_is_preserved(mocke
# Conversation should be redrawn even for existing source (bug #467).
assert conversation_wrapper.conversation_view.update_conversation.call_count == 1
- assert source_conversation_init.call_count == 0
- source_selected.emit.assert_called_once_with(source.uuid)
+ mv.source_list.source_selected.emit.assert_called_once_with(source.uuid)
def test_MainView_refresh_source_conversations(homedir, mocker, qtbot, session_maker, session):
@@ -802,7 +856,8 @@ def test_MainView_refresh_source_conversations(homedir, mocker, qtbot, session_m
source1 = factory.Source(uuid="rsc-123")
session.add(source1)
- source2 = factory.Source(uuid="rsc-456")
+ # Less recent update time (default is datetime.now())
+ source2 = factory.Source(uuid="rsc-456", last_updated=(datetime.now() - timedelta(days=1)))
session.add(source2)
session.commit()
@@ -819,29 +874,32 @@ def test_MainView_refresh_source_conversations(homedir, mocker, qtbot, session_m
mv.source_list.update_sources(sources)
mv.show()
- # get the conversations created
- mocker.patch(
- "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source1
+ # Inspect
+ mv.on_source_changed = mocker.MagicMock(wraps=mv.on_source_changed)
+ mv.source_list.itemSelectionChanged = mocker.MagicMock(
+ wraps=mv.source_list.itemSelectionChanged
)
- mv.on_source_changed()
- mocker.patch(
- "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source2
- )
- mv.on_source_changed()
+ # Select one source, then another
+ mv.source_list.setCurrentRow(0)
+
+ assert mv.source_list.get_selected_source() == source1
+ # assert mv.on_source_changed.call_count == 1
+ mv.source_list.setCurrentRow(1)
+ assert mv.source_list.get_selected_source() == source2
+ # assert mv.on_source_changed.call_count == 2
assert len(mv.source_conversations) == 2
+ # Nothing selected
+ mv.source_list.setCurrentRow(-1)
+
# refresh with no source selected
- mocker.patch("securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=None)
mv.refresh_source_conversations()
+ assert mv.view_layout.currentIndex() == mv.NOTHING_SELECTED_INDEX
- # refresh with source1 selected while its conversation is being deleted
- mocker.patch(
- "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source1
- )
- mv.on_source_changed()
-
+ # # refresh with source1 selected while its conversation is being deleted
+ mv.source_list.setCurrentRow(0)
assert len(mv.source_list.source_items) == 2
scw1 = mv.source_conversations[source1.uuid]
@@ -931,31 +989,58 @@ def test_MainView_set_conversation(mocker):
(i.e. that area of the screen on the right hand side).
"""
mv = MainView(None)
- mv.view_layout = mocker.MagicMock()
- mock_widget = mocker.MagicMock()
- mv.set_conversation(mock_widget)
+ scw = SourceConversationWrapper(factory.Source(), mocker.MagicMock())
+ mv.set_conversation(scw)
- mv.view_layout.takeAt.assert_called_once_with(0)
- mv.view_layout.addWidget.assert_called_once_with(mock_widget)
+ assert mv.view_layout.widget(mv.CONVERSATION_INDEX) == scw
-def test_EmptyConversationView_show_no_sources_message(mocker):
- ecv = EmptyConversationView()
+def test_EmptyConversationView(mocker):
+ mv = MainView(None)
+ mv.source_list = mocker.MagicMock()
+ mv.source_list.count = mocker.MagicMock(return_value=0)
+ mv.source_list.selectedItems = mocker.MagicMock(return_value=[])
+ mv.show()
+ assert mv.view_layout.count() == 4 # Sanity - are all the pages there?
+ mv.show_sources([])
+ assert mv.view_layout.currentIndex() == mv.NO_SOURCES_INDEX
+ assert isinstance(mv.view_layout.widget(mv.view_layout.currentIndex()), EmptyConversationView)
- ecv.show_no_sources_message()
- assert not ecv.no_sources.isHidden()
- assert ecv.no_source_selected.isHidden()
+def test_NothingSelectedView(mocker):
+ mv = MainView(None)
+ mv.show()
+ mv.source_list = mocker.MagicMock()
+ mv.source_list.count = mocker.MagicMock(return_value=4)
+ mv.source_list.selectedItems = mocker.MagicMock(return_value=[])
+
+ # Sanity check - make sure that all base QStackedWidget pages
+ # (Empty, NothingSelected, MultiSelected, Conversation) are rendered
+ assert mv.view_layout.count() == 4
+
+ mv.show_sources([factory.Source(), factory.Source(), factory.Source()])
+ assert isinstance(mv.view_layout.widget(mv.view_layout.currentIndex()), NothingSelectedView)
+ assert mv.view_layout.currentIndex() == mv.NOTHING_SELECTED_INDEX
-def test_EmptyConversationView_show_no_source_selected_message(mocker):
- ecv = EmptyConversationView()
+def test_MultiSelectedView(mocker):
+ mv = MainView(None)
+ sources = [factory.Source(), factory.Source(), factory.Source()]
+ mv.source_list = mocker.MagicMock()
+ mv.source_list.count = mocker.MagicMock(return_value=len(sources))
+ mv.source_list.selectedItems = mocker.MagicMock(return_value=sources[:-1])
+ mv._on_update_conversation_context = mocker.MagicMock(wraps=mv._on_update_conversation_context)
+ mv.show_sources(sources)
- ecv.show_no_source_selected_message()
+ mv.show()
- assert ecv.no_sources.isHidden()
- assert not ecv.no_source_selected.isHidden()
+ # Sanity check - make sure that all base QStackedWidget pages
+ # (Empty, NothingSelected, MultiSelected, Conversation) are rendered
+ assert mv.view_layout.count() == 4
+ mv._on_update_conversation_context.assert_called()
+ assert isinstance(mv.view_layout.widget(mv.view_layout.currentIndex()), MultiSelectView)
+ assert mv.view_layout.currentIndex() == mv.MULTI_SELECTED_INDEX
def test_SourceList_get_selected_source(mocker):
@@ -3791,14 +3876,12 @@ def test_SourceConversationWrapper_on_source_deleted(mocker):
mv.source_list = mocker.MagicMock()
mv.source_list.get_selected_source = mocker.MagicMock(return_value=source)
mv.controller = mocker.MagicMock(is_authenticated=True)
- mv.show()
+
+ # Detached sourceconversationwrapper, just for unit testing
scw = SourceConversationWrapper(source, mv.controller, None)
- mocker.patch("securedrop_client.gui.widgets.SourceConversationWrapper", return_value=scw)
mv.on_source_changed()
scw.on_source_deleted("123")
- assert mv.isVisible()
- assert scw.isVisible()
assert not scw.conversation_title_bar.isHidden()
assert not scw.reply_box.isHidden()
assert not scw.reply_box.text_edit.isEnabled()
@@ -3847,15 +3930,13 @@ def test_SourceConversationWrapper_on_conversation_deleted(mocker):
mv.source_list = mocker.MagicMock()
mv.source_list.get_selected_source = mocker.MagicMock(return_value=source)
mv.controller = mocker.MagicMock(is_authenticated=True)
+ mocker.patch("securedrop_client.gui.widgets.source_exists", return_value=True)
mv.show()
scw = SourceConversationWrapper(source, mv.controller, None)
- mocker.patch("securedrop_client.gui.widgets.SourceConversationWrapper", return_value=scw)
mv.on_source_changed()
scw.on_conversation_deleted("123")
- assert mv.isVisible()
- assert scw.isVisible()
assert not scw.conversation_title_bar.isHidden()
assert not scw.reply_box.isHidden()
assert not scw.reply_box.text_edit.isEnabled()
diff --git a/client/tests/integration/test_placeholder.py b/client/tests/integration/test_placeholder.py
index 6d0c69b6c..747e0bb38 100644
--- a/client/tests/integration/test_placeholder.py
+++ b/client/tests/integration/test_placeholder.py
@@ -2,7 +2,7 @@
def test_styles_for_placeholder(main_window):
- wrapper = main_window.main_view.view_layout.itemAt(0).widget()
+ wrapper = main_window.main_view.view_layout.widget(main_window.main_view.CONVERSATION_INDEX)
reply_box = wrapper.reply_box
reply_text_edit = reply_box.text_edit
@@ -34,7 +34,9 @@ def test_styles_for_placeholder(main_window):
def test_styles_for_placeholder_no_key(main_window_no_key):
- wrapper = main_window_no_key.main_view.view_layout.itemAt(0).widget()
+ wrapper = main_window_no_key.main_view.view_layout.itemAt(
+ main_window_no_key.main_view.CONVERSATION_INDEX
+ ).widget()
reply_box = wrapper.reply_box
reply_text_edit = reply_box.text_edit
diff --git a/client/tests/integration/test_styles_file_download_button.py b/client/tests/integration/test_styles_file_download_button.py
index e4e99d1d9..58ca631fc 100644
--- a/client/tests/integration/test_styles_file_download_button.py
+++ b/client/tests/integration/test_styles_file_download_button.py
@@ -5,7 +5,7 @@
def test_styles(mocker, main_window):
- wrapper = main_window.main_view.view_layout.itemAt(0).widget()
+ wrapper = main_window.main_view.view_layout.widget(main_window.main_view.CONVERSATION_INDEX)
conversation_scrollarea = wrapper.conversation_view._scroll
file_widget = conversation_scrollarea.widget().layout().itemAt(0).widget()
download_button = file_widget.download_button
@@ -28,7 +28,7 @@ def test_styles(mocker, main_window):
def test_styles_animated(mocker, main_window):
- wrapper = main_window.main_view.view_layout.itemAt(0).widget()
+ wrapper = main_window.main_view.view_layout.widget(main_window.main_view.CONVERSATION_INDEX)
conversation_scrollarea = wrapper.conversation_view._scroll
file_widget = conversation_scrollarea.widget().layout().itemAt(0).widget()
download_button = file_widget.download_button
diff --git a/client/tests/integration/test_styles_reply_status_bar.py b/client/tests/integration/test_styles_reply_status_bar.py
index 2d7d8f458..a5f8bf742 100644
--- a/client/tests/integration/test_styles_reply_status_bar.py
+++ b/client/tests/integration/test_styles_reply_status_bar.py
@@ -2,7 +2,7 @@
def test_styles(mocker, main_window):
- wrapper = main_window.main_view.view_layout.itemAt(0).widget()
+ wrapper = main_window.main_view.view_layout.widget(main_window.main_view.CONVERSATION_INDEX)
conversation_scrollarea = wrapper.conversation_view._scroll
reply_widget = conversation_scrollarea.widget().layout().itemAt(2).widget()
reply_widget.sender_is_current_user = False
@@ -25,7 +25,7 @@ def test_styles(mocker, main_window):
def test_styles_for_replies_from_authenticated_user(mocker, main_window):
- wrapper = main_window.main_view.view_layout.itemAt(0).widget()
+ wrapper = main_window.main_view.view_layout.widget(main_window.main_view.CONVERSATION_INDEX)
conversation_scrollarea = wrapper.conversation_view._scroll
reply_widget = conversation_scrollarea.widget().layout().itemAt(2).widget()
reply_widget.sender_is_current_user = True
diff --git a/client/tests/integration/test_styles_sdclient.py b/client/tests/integration/test_styles_sdclient.py
index cab18faa8..299b99ca8 100644
--- a/client/tests/integration/test_styles_sdclient.py
+++ b/client/tests/integration/test_styles_sdclient.py
@@ -64,11 +64,18 @@ def test_class_name_matches_css_object_name(mocker, main_window):
assert main_view.__class__.__name__ == "MainView"
assert main_view.objectName() == "MainView"
assert "MainView" in main_view.view_holder.objectName()
- empty_conversation_view = main_view.empty_conversation_view
- empty_conversation_view.__class__.__name__ == "EmptyConversationView"
- empty_conversation_view.objectName() == "EmptyConversationView"
- "EmptyConversationView" in empty_conversation_view.no_sources.objectName()
- "EmptyConversationView" in empty_conversation_view.no_source_selected.objectName()
+
+ # All views use the "EmptyConversationView" styling
+ for index in [
+ main_view.NO_SOURCES_INDEX,
+ main_view.NOTHING_SELECTED_INDEX,
+ main_view.MULTI_SELECTED_INDEX,
+ ]:
+ view = main_view.view_layout.widget(index)
+ view.objectName() == "EmptyConversationView"
+ "EmptyConversationView" in view.objectName()
+ "EmptyConversationView" in view.objectName()
+
source_list = main_view.source_list
source_widget = source_list.itemWidget(source_list.item(0))
@@ -83,7 +90,7 @@ def test_class_name_matches_css_object_name(mocker, main_window):
assert star.__class__.__name__ == "StarToggleButton"
assert "StarToggleButton" in star.objectName()
- wrapper = main_view.view_layout.itemAt(0).widget()
+ wrapper = main_view.view_layout.itemAt(main_view.CONVERSATION_INDEX).widget()
assert wrapper.__class__.__name__ == "SourceConversationWrapper"
assert wrapper.deletion_indicator.objectName() == "SourceDeletionIndicator"
reply_box = wrapper.reply_box
@@ -229,14 +236,12 @@ def test_styles_for_main_view(mocker, main_window):
main_view = main_window.main_view
assert main_view.minimumSize().height() == 558
assert main_view.view_holder.palette().color(QPalette.Background).name() == "#f9f9ff"
-
- assert main_view.empty_conversation_view.minimumSize().width() == 640
- no_sources = main_view.empty_conversation_view.no_sources
+ no_sources = main_view.view_layout.widget(0)
assert no_sources.layout().count() == 5
no_sources_instructions = no_sources.layout().itemAt(0).widget()
assert no_sources_instructions.font().family() == "Montserrat"
assert QFont.DemiBold - 1 == no_sources_instructions.font().weight()
- assert no_sources_instructions.font().pixelSize() == 35
+ assert no_sources_instructions.font().pixelSize() == 30
assert no_sources_instructions.palette().color(QPalette.Foreground).name() == "#a5b3e9"
no_sources_spacer1 = no_sources.layout().itemAt(1)
assert no_sources_spacer1.minimumSize().height() == 35
@@ -244,7 +249,7 @@ def test_styles_for_main_view(mocker, main_window):
no_sources_instruction_details1 = no_sources.layout().itemAt(2).widget()
assert no_sources_instruction_details1.font().family() == "Montserrat"
assert QFont.Normal == no_sources_instruction_details1.font().weight()
- assert no_sources_instruction_details1.font().pixelSize() == 35
+ assert no_sources_instruction_details1.font().pixelSize() == 30
assert no_sources_instruction_details1.palette().color(QPalette.Foreground).name() == "#a5b3e9"
no_sources_spacer2 = no_sources.layout().itemAt(3)
assert no_sources_spacer2.minimumSize().height() == 35
@@ -252,34 +257,34 @@ def test_styles_for_main_view(mocker, main_window):
no_sources_instruction_details2 = no_sources.layout().itemAt(4).widget()
assert no_sources_instruction_details2.font().family() == "Montserrat"
assert QFont.Normal == no_sources_instruction_details2.font().weight()
- assert no_sources_instruction_details2.font().pixelSize() == 35
+ assert no_sources_instruction_details2.font().pixelSize() == 30
assert no_sources_instruction_details2.palette().color(QPalette.Foreground).name() == "#a5b3e9"
- no_source_selected = main_view.empty_conversation_view.no_source_selected
- assert no_source_selected.layout().count() == 6
+ no_source_selected = main_view.view_layout.widget(1)
+ assert no_source_selected.layout().count() == 8
no_source_selected_instructions = no_source_selected.layout().itemAt(0).widget()
assert no_source_selected_instructions.font().family() == "Montserrat"
assert QFont.DemiBold - 1 == no_source_selected_instructions.font().weight()
- assert no_source_selected_instructions.font().pixelSize() == 35
+ assert no_source_selected_instructions.font().pixelSize() == 30
assert no_source_selected_instructions.palette().color(QPalette.Foreground).name() == "#a5b3e9"
no_source_selected_spacer1 = no_source_selected.layout().itemAt(1)
assert no_source_selected_spacer1.minimumSize().height() == 35
assert no_source_selected_spacer1.maximumSize().height() == 35
bullet1_bullet = no_source_selected.layout().itemAt(2).widget().layout().itemAt(0).widget()
assert bullet1_bullet.getContentsMargins() == (0, 4, 0, 0)
- bullet1_bullet.font().pixelSize() == 35
+ bullet1_bullet.font().pixelSize() == 30
QFont.Bold == bullet1_bullet.font().weight()
assert bullet1_bullet.font().family() == "Montserrat"
assert bullet1_bullet.palette().color(QPalette.Foreground).name() == "#a5b3e9"
bullet2_bullet = no_source_selected.layout().itemAt(3).widget().layout().itemAt(0).widget()
assert bullet2_bullet.getContentsMargins() == (0, 4, 0, 0)
- bullet2_bullet.font().pixelSize() == 35
+ bullet2_bullet.font().pixelSize() == 30
QFont.Bold == bullet2_bullet.font().weight()
assert bullet2_bullet.font().family() == "Montserrat"
assert bullet2_bullet.palette().color(QPalette.Foreground).name() == "#a5b3e9"
bullet3_bullet = no_source_selected.layout().itemAt(4).widget().layout().itemAt(0).widget()
assert bullet3_bullet.getContentsMargins() == (0, 4, 0, 0)
- bullet3_bullet.font().pixelSize() == 35
+ bullet3_bullet.font().pixelSize() == 30
QFont.Bold == bullet3_bullet.font().weight()
assert bullet3_bullet.font().family() == "Montserrat"
assert bullet3_bullet.palette().color(QPalette.Foreground).name() == "#a5b3e9"
@@ -314,7 +319,7 @@ def test_styles_source_list(mocker, main_window):
def test_styles_for_conversation_view(mocker, main_window):
- wrapper = main_window.main_view.view_layout.itemAt(0).widget()
+ wrapper = main_window.main_view.view_layout.widget(main_window.main_view.CONVERSATION_INDEX)
deletion_indicator = wrapper.deletion_indicator
assert deletion_indicator.deletion_message.font().family() == "Montserrat"
assert QFont.Normal == deletion_indicator.deletion_message.font().weight()
diff --git a/client/tests/integration/test_styles_speech_bubble_message.py b/client/tests/integration/test_styles_speech_bubble_message.py
index c1d1bfc7e..35b015056 100644
--- a/client/tests/integration/test_styles_speech_bubble_message.py
+++ b/client/tests/integration/test_styles_speech_bubble_message.py
@@ -2,7 +2,7 @@
def test_styles(mocker, main_window):
- wrapper = main_window.main_view.view_layout.itemAt(0).widget()
+ wrapper = main_window.main_view.view_layout.widget(main_window.main_view.CONVERSATION_INDEX)
conversation_scrollarea = wrapper.conversation_view._scroll
speech_bubble = conversation_scrollarea.widget().layout().itemAt(1).widget()
diff --git a/client/tests/integration/test_styles_speech_bubble_status_bar.py b/client/tests/integration/test_styles_speech_bubble_status_bar.py
index 0930db3f7..aece7cc4a 100644
--- a/client/tests/integration/test_styles_speech_bubble_status_bar.py
+++ b/client/tests/integration/test_styles_speech_bubble_status_bar.py
@@ -2,7 +2,7 @@
def test_styles(mocker, main_window):
- wrapper = main_window.main_view.view_layout.itemAt(0).widget()
+ wrapper = main_window.main_view.view_layout.widget(main_window.main_view.CONVERSATION_INDEX)
conversation_scrollarea = wrapper.conversation_view._scroll
speech_bubble = conversation_scrollarea.widget().layout().itemAt(1).widget()
diff --git a/client/tests/test_logic.py b/client/tests/test_logic.py
index 4c03b4fa9..ec7f65fa7 100644
--- a/client/tests/test_logic.py
+++ b/client/tests/test_logic.py
@@ -1919,13 +1919,13 @@ def test_Controller_delete_source_not_logged_in(homedir, config, mocker, session
source_db_object = mocker.MagicMock()
co.on_action_requiring_login = mocker.MagicMock()
co.api = None
- co.delete_source(source_db_object)
+ co.delete_sources([source_db_object])
co.on_action_requiring_login.assert_called_with()
def test_Controller_delete_source(homedir, config, mocker, session_maker, session):
"""
- Check that a DeleteSourceJob is submitted when delete_source is called.
+ Check that a DeleteSourceJob is submitted when delete_sources is called.
"""
mock_gui = mocker.MagicMock()
co = Controller("http://localhost", mock_gui, session_maker, homedir, None)
@@ -1945,7 +1945,7 @@ def test_Controller_delete_source(homedir, config, mocker, session_maker, sessio
session.add(source)
session.commit()
- co.delete_source(source)
+ co.delete_sources([source])
assert len(source_deleted_emissions) == 1
assert source_deleted_emissions[0] == [source.uuid]