diff --git a/ts/components/Lightbox.dom.tsx b/ts/components/Lightbox.dom.tsx index 9a9297c8236..97437c75b08 100644 --- a/ts/components/Lightbox.dom.tsx +++ b/ts/components/Lightbox.dom.tsx @@ -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, @@ -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) => { + event.stopPropagation(); + }, + [] + ); + const onClose = (event: ReactMouseEvent) => { event.stopPropagation(); event.preventDefault(); @@ -725,6 +751,7 @@ export function Lightbox({ closeLightbox(); }} + onKeyDown={onLightboxKeyDown} ref={containerRef} role="presentation" > diff --git a/ts/hooks/useKeyboardShortcuts.dom.tsx b/ts/hooks/useKeyboardShortcuts.dom.tsx index 114ba7aa468..651289226c3 100644 --- a/ts/hooks/useKeyboardShortcuts.dom.tsx +++ b/ts/hooks/useKeyboardShortcuts.dom.tsx @@ -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; @@ -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 { diff --git a/ts/services/addGlobalKeyboardShortcuts.preload.ts b/ts/services/addGlobalKeyboardShortcuts.preload.ts index dd60fd4e658..d0a4e9af00d 100644 --- a/ts/services/addGlobalKeyboardShortcuts.preload.ts +++ b/ts/services/addGlobalKeyboardShortcuts.preload.ts @@ -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'); @@ -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 diff --git a/ts/test-electron/components/Lightbox_test.dom.ts b/ts/test-electron/components/Lightbox_test.dom.ts new file mode 100644 index 00000000000..701310c5040 --- /dev/null +++ b/ts/test-electron/components/Lightbox_test.dom.ts @@ -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('', () => { + 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)); + }); + }); +}); diff --git a/ts/test-mock/messaging/lightbox_test.node.ts b/ts/test-mock/messaging/lightbox_test.node.ts index f7e2619516d..50bf1c4e0f8 100644 --- a/ts/test-mock/messaging/lightbox_test.node.ts +++ b/ts/test-mock/messaging/lightbox_test.node.ts @@ -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'; @@ -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(); + }); });