diff --git a/apps/webapp/src/script/eventListener/deterministicEventListenerService.test.ts b/apps/webapp/src/script/eventListener/deterministicEventListenerService.test.ts new file mode 100644 index 00000000000..6f87a24949c --- /dev/null +++ b/apps/webapp/src/script/eventListener/deterministicEventListenerService.test.ts @@ -0,0 +1,171 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {createDeterministicEventListenerService} from './deterministicEventListenerService'; + +describe('createDeterministicEventListenerService', () => { + it('dispatches an event to a registered listener', () => { + const service = createDeterministicEventListenerService(); + const listener = jest.fn(); + const event = new Event('click'); + + service.addEventListener('click', listener); + service.dispatch('click', event); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(event); + }); + + it('does not dispatch to listeners of a different event type', () => { + const service = createDeterministicEventListenerService(); + const clickListener = jest.fn(); + const keydownListener = jest.fn(); + + service.addEventListener('click', clickListener); + service.addEventListener('keydown', keydownListener); + service.dispatch('click', new Event('click')); + + expect(clickListener).toHaveBeenCalledTimes(1); + expect(keydownListener).not.toHaveBeenCalled(); + }); + + it('dispatches to all listeners registered for the same type in registration order', () => { + const service = createDeterministicEventListenerService(); + const executionOrder: string[] = []; + + service.addEventListener('click', () => executionOrder.push('first')); + service.addEventListener('click', () => executionOrder.push('second')); + service.addEventListener('click', () => executionOrder.push('third')); + service.dispatch('click', new Event('click')); + + expect(executionOrder).toEqual(['first', 'second', 'third']); + }); + + it('does not dispatch after removeEventListener', () => { + const service = createDeterministicEventListenerService(); + const listener = jest.fn(); + + service.addEventListener('click', listener); + service.removeEventListener('click', listener); + service.dispatch('click', new Event('click')); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('only removes the listener matching the provided capture flag', () => { + const service = createDeterministicEventListenerService(); + const listener = jest.fn(); + + service.addEventListener('click', listener, {capture: false}); + service.removeEventListener('click', listener, {capture: true}); + service.dispatch('click', new Event('click')); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('removes only the first matching listener when duplicates are registered', () => { + const service = createDeterministicEventListenerService(); + const listener = jest.fn(); + + service.addEventListener('click', listener); + service.addEventListener('click', listener); + service.removeEventListener('click', listener); + service.dispatch('click', new Event('click')); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('does nothing when dispatching a type with no registered listeners', () => { + const service = createDeterministicEventListenerService(); + + expect(() => service.dispatch('click', new Event('click'))).not.toThrow(); + }); + + it('does nothing when removing a listener for a type that was never registered', () => { + const service = createDeterministicEventListenerService(); + const listener = jest.fn(); + + expect(() => service.removeEventListener('click', listener)).not.toThrow(); + }); + + it('ignores null listeners in addEventListener', () => { + const service = createDeterministicEventListenerService(); + + expect(() => service.addEventListener('click', null)).not.toThrow(); + expect(() => service.dispatch('click', new Event('click'))).not.toThrow(); + }); + + it('ignores null listeners in removeEventListener', () => { + const service = createDeterministicEventListenerService(); + + expect(() => service.removeEventListener('click', null)).not.toThrow(); + }); + + it('calls handleEvent on an EventListenerObject', () => { + const service = createDeterministicEventListenerService(); + const event = new Event('click'); + const listenerObject: EventListenerObject = {handleEvent: jest.fn()}; + + service.addEventListener('click', listenerObject); + service.dispatch('click', event); + + expect(listenerObject.handleEvent).toHaveBeenCalledTimes(1); + expect(listenerObject.handleEvent).toHaveBeenCalledWith(event); + }); + + it('fires a once listener only once then auto-removes it', () => { + const service = createDeterministicEventListenerService(); + const listener = jest.fn(); + + service.addEventListener('click', listener, {once: true}); + service.dispatch('click', new Event('click')); + service.dispatch('click', new Event('click')); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('keeps non-once listeners after dispatch when a once listener is also registered', () => { + const service = createDeterministicEventListenerService(); + const onceListener = jest.fn(); + const persistentListener = jest.fn(); + + service.addEventListener('click', onceListener, {once: true}); + service.addEventListener('click', persistentListener); + service.dispatch('click', new Event('click')); + service.dispatch('click', new Event('click')); + + expect(onceListener).toHaveBeenCalledTimes(1); + expect(persistentListener).toHaveBeenCalledTimes(1); + }); + + it('treats boolean capture option as the capture flag', () => { + const service = createDeterministicEventListenerService(); + const captureListener = jest.fn(); + const bubbleListener = jest.fn(); + + service.addEventListener('click', captureListener, true); + service.addEventListener('click', bubbleListener, false); + + service.removeEventListener('click', captureListener, true); + service.dispatch('click', new Event('click')); + + expect(captureListener).not.toHaveBeenCalled(); + expect(bubbleListener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/webapp/src/script/eventListener/deterministicEventListenerService.ts b/apps/webapp/src/script/eventListener/deterministicEventListenerService.ts new file mode 100644 index 00000000000..c19415bc1a7 --- /dev/null +++ b/apps/webapp/src/script/eventListener/deterministicEventListenerService.ts @@ -0,0 +1,119 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import is from '@sindresorhus/is'; + +import {EventListenerService} from './eventListenerService'; + +type ListenerEntry = { + readonly listener: EventListenerOrEventListenerObject; + readonly capture: boolean; + readonly once: boolean; +}; + +export type DeterministicEventListenerService = EventListenerService & { + dispatch(type: string, event: Event): void; +}; + +function normalizeCapture(options?: boolean | AddEventListenerOptions | EventListenerOptions): boolean { + if (is.boolean(options)) { + return options; + } + + return options?.capture ?? false; +} + +function normalizeOnce(options?: boolean | AddEventListenerOptions): boolean { + if (is.boolean(options)) { + return false; + } + + return options?.once ?? false; +} + +export function createDeterministicEventListenerService(): DeterministicEventListenerService { + const listenerMap = new Map(); + + return { + addEventListener(type, listener, options) { + if (is.nullOrUndefined(listener)) { + return; + } + + const entries = listenerMap.get(type) ?? []; + + if (!listenerMap.has(type)) { + listenerMap.set(type, entries); + } + + entries.push({ + capture: normalizeCapture(options), + listener, + once: normalizeOnce(options), + }); + }, + + removeEventListener(type, listener, options) { + if (is.nullOrUndefined(listener)) { + return; + } + + const capture = normalizeCapture(options); + const entries = listenerMap.get(type); + + if (is.undefined(entries)) { + return; + } + + const index = entries.findIndex(entry => entry.listener === listener && entry.capture === capture); + + if (index !== -1) { + entries.splice(index, 1); + } + }, + + dispatch(type, event) { + const entries = listenerMap.get(type); + + if (is.undefined(entries)) { + return; + } + + for (const entry of entries) { + if (entry.once) { + const current = listenerMap.get(type); + + if (!is.undefined(current)) { + const index = current.indexOf(entry); + + if (index !== -1) { + current.splice(index, 1); + } + } + } + + if (is.function_(entry.listener)) { + entry.listener(event); + } else { + entry.listener.handleEvent(event); + } + } + }, + }; +} diff --git a/apps/webapp/src/script/eventListener/eventListenerService.test.ts b/apps/webapp/src/script/eventListener/eventListenerService.test.ts new file mode 100644 index 00000000000..289b3b638d7 --- /dev/null +++ b/apps/webapp/src/script/eventListener/eventListenerService.test.ts @@ -0,0 +1,117 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {createEventListenerService} from './eventListenerService'; + +describe('event listener service', () => { + it('binds addEventListener to globalThis', () => { + const originalAddEventListener = globalThis.addEventListener; + const addEventListenerInvocationContexts: unknown[] = []; + const addEventListenerArguments: unknown[][] = []; + + function addEventListenerStub(this: unknown, ...args: unknown[]) { + addEventListenerInvocationContexts.push(this); + addEventListenerArguments.push(args); + } + + globalThis.addEventListener = addEventListenerStub as unknown as typeof globalThis.addEventListener; + + try { + const eventListenerService = createEventListenerService(); + const listener: () => undefined = () => undefined; + + eventListenerService.addEventListener('click', listener); + + expect(addEventListenerInvocationContexts[0]).toBe(globalThis); + expect(addEventListenerArguments[0]).toEqual(['click', listener, undefined]); + } finally { + globalThis.addEventListener = originalAddEventListener; + } + }); + + it('binds removeEventListener to globalThis', () => { + const originalRemoveEventListener = globalThis.removeEventListener; + const removeEventListenerInvocationContexts: unknown[] = []; + const removeEventListenerArguments: unknown[][] = []; + + function removeEventListenerStub(this: unknown, ...args: unknown[]) { + removeEventListenerInvocationContexts.push(this); + removeEventListenerArguments.push(args); + } + + globalThis.removeEventListener = removeEventListenerStub as unknown as typeof globalThis.removeEventListener; + + try { + const eventListenerService = createEventListenerService(); + const listener: () => undefined = () => undefined; + + eventListenerService.removeEventListener('click', listener); + + expect(removeEventListenerInvocationContexts[0]).toBe(globalThis); + expect(removeEventListenerArguments[0]).toEqual(['click', listener, undefined]); + } finally { + globalThis.removeEventListener = originalRemoveEventListener; + } + }); + + it('forwards options to addEventListener', () => { + const originalAddEventListener = globalThis.addEventListener; + const addEventListenerArguments: unknown[][] = []; + + function addEventListenerStub(this: unknown, ...args: unknown[]) { + addEventListenerArguments.push(args); + } + + globalThis.addEventListener = addEventListenerStub as unknown as typeof globalThis.addEventListener; + + try { + const eventListenerService = createEventListenerService(); + const listener: () => undefined = () => undefined; + const options: AddEventListenerOptions = {capture: true, once: true, passive: false}; + + eventListenerService.addEventListener('keydown', listener, options); + + expect(addEventListenerArguments[0]).toEqual(['keydown', listener, options]); + } finally { + globalThis.addEventListener = originalAddEventListener; + } + }); + + it('forwards options to removeEventListener', () => { + const originalRemoveEventListener = globalThis.removeEventListener; + const removeEventListenerArguments: unknown[][] = []; + + function removeEventListenerStub(this: unknown, ...args: unknown[]) { + removeEventListenerArguments.push(args); + } + + globalThis.removeEventListener = removeEventListenerStub as unknown as typeof globalThis.removeEventListener; + + try { + const eventListenerService = createEventListenerService(); + const listener: () => undefined = () => undefined; + + eventListenerService.removeEventListener('keydown', listener, true); + + expect(removeEventListenerArguments[0]).toEqual(['keydown', listener, true]); + } finally { + globalThis.removeEventListener = originalRemoveEventListener; + } + }); +}); diff --git a/apps/webapp/src/script/eventListener/eventListenerService.ts b/apps/webapp/src/script/eventListener/eventListenerService.ts new file mode 100644 index 00000000000..6aa28761678 --- /dev/null +++ b/apps/webapp/src/script/eventListener/eventListenerService.ts @@ -0,0 +1,38 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export type EventListenerService = { + readonly addEventListener: ( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ) => void; + readonly removeEventListener: ( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions, + ) => void; +}; + +export function createEventListenerService(): EventListenerService { + return { + addEventListener: globalThis.addEventListener.bind(globalThis), + removeEventListener: globalThis.removeEventListener.bind(globalThis), + }; +}