Skip to content
Open
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
25 changes: 25 additions & 0 deletions ts/components/LeftPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,29 @@ export function LeftPane({
[showConversation]
);

const handleLeftPaneClick = useCallback(
(event: React.MouseEvent) => {
const target = event.target as HTMLElement;

// Only deselect if clicking on empty areas, not on interactive elements
if (
selectedConversationId &&
!target.closest(
'button, input, [role="button"], .module-conversation-list-item, .module-search-results, .module-left-pane-dialog'
)
) {
event.preventDefault();
event.stopPropagation();

showConversation({
conversationId: undefined,
messageId: undefined,
});
}
},
[selectedConversationId, showConversation]
);

// We ensure that the listKey differs between some modes (e.g. inbox/archived), ensuring
// that AutoSizer properly detects the new size of its slot in the flexbox. The
// archive explainer text at the top of the archive view causes problems otherwise.
Expand Down Expand Up @@ -833,13 +856,15 @@ export function LeftPane({
{preRowsNode && <React.Fragment key={0}>{preRowsNode}</React.Fragment>}
<div className="module-left-pane__list--measure" ref={measureRef}>
<div className="module-left-pane__list--wrapper">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
aria-live="polite"
className="module-left-pane__list"
data-supertab
key={listKey}
role="presentation"
tabIndex={-1}
onClick={handleLeftPaneClick}
>
<ConversationList
key={modeSpecificProps.mode}
Expand Down
135 changes: 135 additions & 0 deletions ts/test-electron/components/LeftPane_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only

import { assert } from 'chai';
import * as sinon from 'sinon';
import { v4 as uuid } from 'uuid';

describe('LeftPane click-to-deselect functionality', () => {
let sandbox: sinon.SinonSandbox;

beforeEach(() => {
sandbox = sinon.createSandbox();
});

afterEach(() => {
sandbox.restore();
});

describe('click handler logic', () => {
it('should identify empty areas correctly', () => {
// Create empty area elements
const emptyDiv = document.createElement('div');
emptyDiv.className = 'module-left-pane__list';
document.body.appendChild(emptyDiv);

// Test empty area detection
const isInteractive = emptyDiv.closest(
'button, input, [role="button"], .module-conversation-list-item, .module-search-results, .module-left-pane-dialog'
);

assert.isNull(
isInteractive,
'Empty areas should not be detected as interactive'
);

document.body.removeChild(emptyDiv);
});

it('should identify interactive elements correctly', () => {
// Create interactive element
const button = document.createElement('button');
const spanInsideButton = document.createElement('span');
button.appendChild(spanInsideButton);
document.body.appendChild(button);

// Test interactive element detection
const isInteractive = spanInsideButton.closest(
'button, input, [role="button"], .module-conversation-list-item, .module-search-results, .module-left-pane-dialog'
);

assert.isNotNull(
isInteractive,
'Elements inside buttons should be detected as interactive'
);

document.body.removeChild(button);
});

it('should identify conversation list items as interactive', () => {
// Create conversation list item
const listItem = document.createElement('div');
listItem.className = 'module-conversation-list-item';
const childElement = document.createElement('span');
listItem.appendChild(childElement);
document.body.appendChild(listItem);

// Test conversation list item detection
const isInteractive = childElement.closest(
'button, input, [role="button"], .module-conversation-list-item, .module-search-results, .module-left-pane-dialog'
);

assert.isNotNull(
isInteractive,
'Elements inside conversation list items should be detected as interactive'
);

document.body.removeChild(listItem);
});
});

describe('deselection conditions', () => {
it('should require both selected conversation and empty area for deselection', () => {
const conversationId = uuid();

// Test case 1: Has conversation, empty area - should deselect
const hasConversation = !!conversationId;
const isEmptyArea = true;
const shouldDeselect1 = hasConversation && isEmptyArea;
assert.isTrue(
shouldDeselect1,
'Should deselect when conversation selected and clicking empty area'
);

// Test case 2: Has conversation, interactive area - should NOT deselect
const isInteractiveArea = true;
const shouldDeselect2 = hasConversation && !isInteractiveArea;
assert.isFalse(
shouldDeselect2,
'Should NOT deselect when clicking interactive area'
);

// Test case 3: No conversation, empty area - should NOT deselect
const noConversation = false;
const shouldDeselect3 = noConversation && isEmptyArea;
assert.isFalse(
shouldDeselect3,
'Should NOT deselect when no conversation is selected'
);
});
});

describe('showConversation call format', () => {
it('should call showConversation with correct parameters for deselection', () => {
const mockShowConversation = sandbox.spy();

// Simulate the deselection call
mockShowConversation({
conversationId: undefined,
messageId: undefined,
});

assert.isTrue(
mockShowConversation.calledOnce,
'showConversation should be called once'
);
assert.isTrue(
mockShowConversation.calledWithExactly({
conversationId: undefined,
messageId: undefined,
}),
'showConversation should be called with undefined conversationId and messageId'
);
});
});
});