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
27 changes: 27 additions & 0 deletions ts/components/Lightbox.dom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ const THUMBNAIL_WIDTH = 44;
const THUMBNAIL_PADDING = 8;
const THUMBNAIL_FULL_WIDTH = THUMBNAIL_WIDTH + THUMBNAIL_PADDING;

export function _shouldBlockInteractionBehindLightbox(
target: EventTarget | null,
container: HTMLElement | null
): boolean {
return (
target instanceof Node && container != null && !container.contains(target)
);
}

export function Lightbox({
children,
closeLightbox,
Expand Down Expand Up @@ -300,10 +309,27 @@ export function Lightbox({

default:
}

if (
_shouldBlockInteractionBehindLightbox(
event.target,
containerRef.current
)
) {
event.preventDefault();
event.stopPropagation();
}
},
[closeLightbox, onNext, onPrevious, handleSave]
);

const onLightboxKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
event.stopPropagation();
},
[]
);

const onClose = (event: ReactMouseEvent<HTMLElement>) => {
event.stopPropagation();
event.preventDefault();
Expand Down Expand Up @@ -725,6 +751,7 @@ export function Lightbox({

closeLightbox();
}}
onKeyDown={onLightboxKeyDown}
ref={containerRef}
role="presentation"
>
Expand Down
8 changes: 7 additions & 1 deletion ts/hooks/useKeyboardShortcuts.dom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as KeyboardLayout from '../services/keyboardLayout.dom.ts';
import { getHasPanelOpen } from '../state/selectors/nav.std.ts';
import { isShowingAnyModal } from '../state/selectors/globalModals.std.ts';
import { getIsInFullScreenCall } from '../state/selectors/isInFullScreenCall.std.ts';
import { shouldShowLightbox } from '../state/selectors/lightbox.std.ts';

const { get } = lodash;

Expand Down Expand Up @@ -92,12 +93,17 @@ function useHasCalling(): boolean {
return useSelector(getIsInFullScreenCall);
}

function useHasLightbox(): boolean {
return useSelector(shouldShowLightbox);
}

function useHasAnyOverlay(): boolean {
const panels = useHasPanels();
const globalModal = useHasGlobalModal();
const calling = useHasCalling();
const lightbox = useHasLightbox();

return panels || globalModal || calling;
return panels || globalModal || calling || lightbox;
}

export function isKeyboardActivation(event: KeyboardEvent): boolean {
Expand Down
5 changes: 5 additions & 0 deletions ts/services/addGlobalKeyboardShortcuts.preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getQuotedMessageSelector } from '../state/selectors/composer.preload.ts
import { removeLinkPreview } from './LinkPreview.preload.ts';
import { ForwardMessagesModalType } from '../components/ForwardMessagesModal.dom.tsx';
import { getSelectedConversationId } from '../state/selectors/nav.std.ts';
import { shouldShowLightbox } from '../state/selectors/lightbox.std.ts';
import { strictAssert } from '../util/assert.std.ts';

const log = createLogger('addGlobalKeyboardShortcuts');
Expand All @@ -33,6 +34,10 @@ export function addGlobalKeyboardShortcuts(): void {

const key = KeyboardLayout.lookup(event);

if (shouldShowLightbox(state)) {
return;
}

// NAVIGATION

// Show keyboard shortcuts - handled by Electron-managed keyboard shortcuts
Expand Down
40 changes: 40 additions & 0 deletions ts/test-electron/components/Lightbox_test.dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only

import { assert } from 'chai';

import { _shouldBlockInteractionBehindLightbox } from '../../components/Lightbox.dom.tsx';

describe('<Lightbox>', () => {
describe('_shouldBlockInteractionBehindLightbox', () => {
let container: HTMLDivElement;
let child: HTMLButtonElement;
let outside: HTMLTextAreaElement;

beforeEach(() => {
container = document.createElement('div');
child = document.createElement('button');
outside = document.createElement('textarea');

container.appendChild(child);
document.body.append(container, outside);
});

afterEach(() => {
container.remove();
outside.remove();
});

it('blocks key events from elements behind the lightbox', () => {
assert.isTrue(_shouldBlockInteractionBehindLightbox(outside, container));
});

it('allows key events from elements inside the lightbox', () => {
assert.isFalse(_shouldBlockInteractionBehindLightbox(child, container));
});

it('allows key events before the lightbox container is ready', () => {
assert.isFalse(_shouldBlockInteractionBehindLightbox(outside, null));
});
});
});
77 changes: 77 additions & 0 deletions ts/test-mock/messaging/lightbox_test.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
getTimelineMessageWithText,
sendMessageWithAttachments,
sendTextMessage,
waitForEnabledComposer,
} from '../helpers.node.ts';
import * as durations from '../../util/durations/index.std.ts';
import { strictAssert } from '../../util/assert.std.ts';
Expand Down Expand Up @@ -182,4 +183,80 @@ describe('lightbox', function (this: Mocha.Suite) {
await expectLightboxImage(attachment);
}
});

it('does not let composer shortcuts focus the hidden composer', async () => {
const page = await app.getWindow();

await page.getByTestId(pinned.device.aci).click();

const fixturesDir = path.join(__dirname, '..', '..', '..', 'fixtures');
const imageCat = path.join(fixturesDir, 'cat-screenshot.png');

await sendMessageWithAttachments(page, pinned, 'Message with image', [
imageCat,
]);

const Message = getTimelineMessageWithText(page, 'Message with image');
const FirstImage = Message.locator('.module-image').nth(0);

await FirstImage.click();

const Lightbox = page.locator('.Lightbox');
await expect(Lightbox).toBeVisible();

const composer = await waitForEnabledComposer(page);
const commandOrCtrl = await page.evaluate(() =>
Reflect.get(globalThis, 'platform') === 'darwin' ? 'Meta' : 'Control'
);
await page.keyboard.press(`${commandOrCtrl}+Shift+T`);
await page.keyboard.type('This should not send from behind Lightbox');
await page.keyboard.press('Enter');

const isComposerFocused = await composer.evaluate(el => {
const { activeElement } = el.ownerDocument;
return el === activeElement || el.contains(activeElement);
});

assert.isFalse(isComposerFocused);
await expect(
getTimelineMessageWithText(
page,
'This should not send from behind Lightbox'
)
).not.toBeVisible();
await expect(Lightbox).toBeVisible();
});

it('does not let lightbox focus trigger shortcuts behind it', async () => {
const page = await app.getWindow();

await page.getByTestId(pinned.device.aci).click();

const fixturesDir = path.join(__dirname, '..', '..', '..', 'fixtures');
const imageCat = path.join(fixturesDir, 'cat-screenshot.png');

await sendMessageWithAttachments(page, pinned, 'Message with image', [
imageCat,
]);

const Message = getTimelineMessageWithText(page, 'Message with image');
const FirstImage = Message.locator('.module-image').nth(0);

await FirstImage.click();

const Lightbox = page.locator('.Lightbox');
await expect(Lightbox).toBeVisible();

await Lightbox.locator('.Lightbox__button--close').focus();

const commandOrCtrl = await page.evaluate(() =>
Reflect.get(globalThis, 'platform') === 'darwin' ? 'Meta' : 'Control'
);
await page.keyboard.press(`${commandOrCtrl}+Shift+J`);

await expect(
page.getByRole('dialog', { name: 'Add an Emoji, Sticker, or GIF' })
).not.toBeVisible();
await expect(Lightbox).toBeVisible();
});
});