import {LogCategory, logTrace} from "./log"; import {guid} from "./crypto/uid"; import {useEffect} from "react"; import {unstable_batchedUpdates} from "react-dom"; import * as React from "react"; /* export type EventPayloadObject = { [key: string]: EventPayload } | { [key: number]: EventPayload }; export type EventPayload = string | number | bigint | null | undefined | EventPayloadObject; */ export type EventPayloadObject = any; export type EventMap

= { [K in keyof P]: EventPayloadObject & { /* prohibit the type attribute on the highest layer (used to identify the event type) */ type?: never } }; export type Event

, T extends keyof P> = { readonly type: T, as(target: S) : Event; asUnchecked(target: S) : Event; asAnyUnchecked(target: S) : Event; /** * Return an object containing only the event payload specific key value pairs. */ extractPayload() : P[T]; } & P[T]; namespace EventHelper { /** * Turn the payload object into a bus event object * @param payload */ /* May inline this somehow? A function call seems to be 3% slower */ export function createEvent

, T extends keyof P>(type: T, payload?: P[T]) : Event { if(payload) { (payload as any).type = type; let event = payload as any as Event; event.as = as; event.asUnchecked = asUnchecked; event.asAnyUnchecked = asUnchecked; event.extractPayload = extractPayload; return event; } else { return { type, as, asUnchecked, asAnyUnchecked: asUnchecked, extractPayload } as any; } } function extractPayload() { const result = Object.assign({}, this); delete result["as"]; delete result["asUnchecked"]; delete result["asAnyUnchecked"]; delete result["extractPayload"]; return result; } function as(target) { if(this.type !== target) { throw "Mismatching event type. Expected: " + target + ", Got: " + this.type; } return this; } function asUnchecked() { return this; } } export interface EventSender = EventMap> { fire(event_type: T, data?: Events[T], overrideTypeKey?: boolean); /** * Fire an event later by using setTimeout(..) * @param event_type The target event to be fired * @param data The payload of the event * @param callback The callback will be called after the event has been successfully dispatched */ fire_later(event_type: T, data?: Events[T], callback?: () => void); /** * Fire an event, which will be delayed until the next animation frame. * This ensures that all react components have been successfully mounted/unmounted. * @param event_type The target event to be fired * @param data The payload of the event * @param callback The callback will be called after the event has been successfully dispatched */ fire_react(event_type: T, data?: Events[T], callback?: () => void); } export type EventDispatchType = "sync" | "later" | "react"; export interface EventConsumer { handleEvent(mode: EventDispatchType, type: string, data: any); } interface EventHandlerRegisterData { registeredHandler: {[key: string]: ((event) => void)[]} } const kEventAnnotationKey = guid(); export class Registry = EventMap> implements EventSender { protected readonly registryUniqueId; protected persistentEventHandler: { [key: string]: ((event) => void)[] } = {}; protected oneShotEventHandler: { [key: string]: ((event) => void)[] } = {}; protected genericEventHandler: ((event) => void)[] = []; protected consumer: EventConsumer[] = []; private ipcConsumer: IpcEventBridge; private debugPrefix = undefined; private warnUnhandledEvents = false; private pendingAsyncCallbacks: { type: any, data: any, callback: () => void }[]; private pendingAsyncCallbacksTimeout: number = 0; private pendingReactCallbacks: { type: any, data: any, callback: () => void }[]; private pendingReactCallbacksFrame: number = 0; static fromIpcDescription = EventMap>(description: IpcRegistryDescription) : Registry { const registry = new Registry(); registry.ipcConsumer = new IpcEventBridge(registry as any, description.ipcChannelId); registry.registerConsumer(registry.ipcConsumer); return registry; } constructor() { this.registryUniqueId = "evreg_data_" + guid(); } destroy() { Object.values(this.persistentEventHandler).forEach(handlers => handlers.splice(0, handlers.length)); Object.values(this.oneShotEventHandler).forEach(handlers => handlers.splice(0, handlers.length)); this.genericEventHandler.splice(0, this.genericEventHandler.length); this.consumer.splice(0, this.consumer.length); this.ipcConsumer?.destroy(); this.ipcConsumer = undefined; } enableDebug(prefix: string) { this.debugPrefix = prefix || "---"; } disableDebug() { this.debugPrefix = undefined; } enableWarnUnhandledEvents() { this.warnUnhandledEvents = true; } disableWarnUnhandledEvents() { this.warnUnhandledEvents = false; } fire(eventType: T, data?: Events[T], overrideTypeKey?: boolean) { if(this.debugPrefix) { logTrace(LogCategory.EVENT_REGISTRY, "[%s] Trigger event: %s", this.debugPrefix, eventType); } if(typeof data === "object" && 'type' in data && !overrideTypeKey) { if((data as any).type !== eventType) { debugger; throw "The keyword 'type' is reserved for the event type and should not be passed as argument"; } } for(const consumer of this.consumer) { consumer.handleEvent("sync", eventType as string, data); } this.doInvokeEvent(EventHelper.createEvent(eventType, data)); } fire_later(eventType: T, data?: Events[T], callback?: () => void) { if(!this.pendingAsyncCallbacksTimeout) { this.pendingAsyncCallbacksTimeout = setTimeout(() => this.invokeAsyncCallbacks()); this.pendingAsyncCallbacks = []; } this.pendingAsyncCallbacks.push({ type: eventType, data: data, callback: callback }); for(const consumer of this.consumer) { consumer.handleEvent("later", eventType as string, data); } } fire_react(eventType: T, data?: Events[T], callback?: () => void) { if(!this.pendingReactCallbacks) { this.pendingReactCallbacksFrame = requestAnimationFrame(() => this.invokeReactCallbacks()); this.pendingReactCallbacks = []; } this.pendingReactCallbacks.push({ type: eventType, data: data, callback: callback }); for(const consumer of this.consumer) { consumer.handleEvent("react", eventType as string, data); } } on(event: T | T[], handler: (event: Event) => void) : () => void; on(events, handler) : () => void { if(!Array.isArray(events)) { events = [events]; } for(const event of events as string[]) { const persistentHandler = this.persistentEventHandler[event] || (this.persistentEventHandler[event] = []); persistentHandler.push(handler); } return () => this.off(events, handler); } one(event: T | T[], handler: (event: Event) => void) : () => void; one(events, handler) : () => void { if(!Array.isArray(events)) { events = [events]; } for(const event of events as string[]) { const persistentHandler = this.oneShotEventHandler[event] || (this.oneShotEventHandler[event] = []); persistentHandler.push(handler); } return () => this.off(events, handler); } off(handler: (event: Event) => void); off(events: T | T[], handler: (event: Event) => void); off(handlerOrEvents, handler?) { if(typeof handlerOrEvents === "function") { this.offAll(handler); } else if(typeof handlerOrEvents === "string") { if(this.persistentEventHandler[handlerOrEvents]) { this.persistentEventHandler[handlerOrEvents].remove(handler); } if(this.oneShotEventHandler[handlerOrEvents]) { this.oneShotEventHandler[handlerOrEvents].remove(handler); } } else if(Array.isArray(handlerOrEvents)) { handlerOrEvents.forEach(handler_or_event => this.off(handler_or_event, handler)); } } onAll(handler: (event: Event) => void): () => void { this.genericEventHandler.push(handler); return () => this.genericEventHandler.remove(handler); } offAll(handler: (event: Event) => void) { Object.values(this.persistentEventHandler).forEach(persistentHandler => persistentHandler.remove(handler)); Object.values(this.oneShotEventHandler).forEach(oneShotHandler => oneShotHandler.remove(handler)); this.genericEventHandler.remove(handler); } /** * @param event * @param handler * @param condition If a boolean the event handler will only be registered if the condition is true * @param reactEffectDependencies */ reactUse(event: T | T[], handler: (event: Event) => void, condition?: boolean, reactEffectDependencies?: any[]); reactUse(event, handler, condition?, reactEffectDependencies?) { if(typeof condition === "boolean" && !condition) { useEffect(() => {}); return; } const handlers = this.persistentEventHandler[event as any] || (this.persistentEventHandler[event as any] = []); useEffect(() => { handlers.push(handler); return () => { const index = handlers.indexOf(handler); if(index !== -1) { handlers.splice(index, 1); } }; }, reactEffectDependencies); } private doInvokeEvent(event: Event) { const oneShotHandler = this.oneShotEventHandler[event.type]; if(oneShotHandler) { delete this.oneShotEventHandler[event.type]; for(const handler of oneShotHandler) { handler(event); } } for(const handler of this.persistentEventHandler[event.type] || []) { handler(event); } for(const handler of this.genericEventHandler) { handler(event); } /* let invokeCount = 0; if(this.warnUnhandledEvents && invokeCount === 0) { logWarn(LogCategory.EVENT_REGISTRY, "Event handler (%s) triggered event %s which has no consumers.", this.debugPrefix, event.type); } */ } private invokeAsyncCallbacks() { const callbacks = this.pendingAsyncCallbacks; this.pendingAsyncCallbacksTimeout = 0; this.pendingAsyncCallbacks = undefined; let index = 0; while(index < callbacks.length) { this.fire(callbacks[index].type, callbacks[index].data); try { if(callbacks[index].callback) { callbacks[index].callback(); } } catch (error) { console.error(error); /* TODO: Improve error logging? */ } index++; } } private invokeReactCallbacks() { const callbacks = this.pendingReactCallbacks; this.pendingReactCallbacksFrame = 0; this.pendingReactCallbacks = undefined; /* run this after the requestAnimationFrame has been finished since else it might be fired instantly */ setTimeout(() => { /* batch all react updates */ unstable_batchedUpdates(() => { let index = 0; while(index < callbacks.length) { this.fire(callbacks[index].type, callbacks[index].data); try { if(callbacks[index].callback) { callbacks[index].callback(); } } catch (error) { console.error(error); /* TODO: Improve error logging? */ } index++; } }); }); } registerHandler(handler: any, parentClasses?: boolean) { if(typeof handler !== "object") { throw "event handler must be an object"; } if(typeof handler[this.registryUniqueId] !== "undefined") { throw "event handler already registered"; } const prototype = Object.getPrototypeOf(handler); if(typeof prototype !== "object") { throw "event handler must have a prototype"; } const data = handler[this.registryUniqueId] = { registeredHandler: {} } as EventHandlerRegisterData; let currentPrototype = prototype; do { Object.getOwnPropertyNames(currentPrototype).forEach(functionName => { if(functionName === "constructor") { return; } if(typeof prototype[functionName] !== "function") { return; } if(typeof prototype[functionName][kEventAnnotationKey] !== "object") { return; } const eventData = prototype[functionName][kEventAnnotationKey]; const eventHandler = event => prototype[functionName].call(handler, event); for(const event of eventData.events) { const registeredHandler = data.registeredHandler[event] || (data.registeredHandler[event] = []); registeredHandler.push(eventHandler); this.on(event, eventHandler); } }); if(!parentClasses) { break; } } while ((currentPrototype = Object.getPrototypeOf(currentPrototype))); } unregisterHandler(handler: any) { if(typeof handler !== "object") { throw "event handler must be an object"; } if(typeof handler[this.registryUniqueId] === "undefined") { throw "event handler not registered"; } const data = handler[this.registryUniqueId] as EventHandlerRegisterData; delete handler[this.registryUniqueId]; for(const event of Object.keys(data.registeredHandler)) { for(const handler of data.registeredHandler[event]) { this.off(event as any, handler); } } } registerConsumer(consumer: EventConsumer) : () => void { const allConsumer = this.consumer; allConsumer.push(consumer); return () => allConsumer.remove(consumer); } unregisterConsumer(consumer: EventConsumer) { this.consumer.remove(consumer); } generateIpcDescription() : IpcRegistryDescription { if(!this.ipcConsumer) { this.ipcConsumer = new IpcEventBridge(this as any, undefined); this.registerConsumer(this.ipcConsumer); } return { ipcChannelId: this.ipcConsumer.ipcChannelId }; } } export type RegistryMap = {[key: string]: any /* can't use Registry here since the template parameter is missing */ }; export function EventHandler(events: (keyof EventTypes) | (keyof EventTypes)[]) { return function (target: any, propertyKey: string, _descriptor: PropertyDescriptor) { if(typeof target[propertyKey] !== "function") throw "Invalid event handler annotation. Expected to be on a function type."; target[propertyKey][kEventAnnotationKey] = { events: Array.isArray(events) ? events : [events] }; } } export function ReactEventHandler, Events = any>(registry_callback: (object: ObjectClass) => Registry) { return function (constructor: Function) { if(!React.Component.prototype.isPrototypeOf(constructor.prototype)) throw "Class/object isn't an instance of React.Component"; const didMount = constructor.prototype.componentDidMount; constructor.prototype.componentDidMount = function() { const registry = registry_callback(this); if(!registry) throw "Event registry returned for an event object is invalid"; registry.registerHandler(this); if(typeof didMount === "function") { didMount.call(this, arguments); } }; const willUnmount = constructor.prototype.componentWillUnmount; constructor.prototype.componentWillUnmount = function () { const registry = registry_callback(this); if(!registry) throw "Event registry returned for an event object is invalid"; try { registry.unregisterHandler(this); } catch (error) { console.warn("Failed to unregister event handler: %o", error); } if(typeof willUnmount === "function") { willUnmount.call(this, arguments); } }; } } export type IpcRegistryDescription = EventMap> = { ipcChannelId: string } class IpcEventBridge implements EventConsumer { readonly registry: Registry; readonly ipcChannelId: string; private readonly ownBridgeId: string; private broadcastChannel: BroadcastChannel; constructor(registry: Registry, ipcChannelId: string | undefined) { this.registry = registry; this.ownBridgeId = guid(); this.ipcChannelId = ipcChannelId || ("teaspeak-ipc-events-" + guid()); this.broadcastChannel = new BroadcastChannel(this.ipcChannelId); this.broadcastChannel.onmessage = event => this.handleIpcMessage(event.data, event.source, event.origin); } destroy() { if(this.broadcastChannel) { this.broadcastChannel.onmessage = undefined; this.broadcastChannel.onmessageerror = undefined; this.broadcastChannel.close(); } this.broadcastChannel = undefined; } handleEvent(dispatchType: EventDispatchType, eventType: string, eventPayload: any) { if(eventPayload && eventPayload[this.ownBridgeId]) { return; } this.broadcastChannel.postMessage({ type: "event", source: this.ownBridgeId, dispatchType, eventType, eventPayload, }); } private handleIpcMessage(message: any, _source: MessageEventSource | null, _origin: string) { if(message.source === this.ownBridgeId) { /* It's our own event */ return; } if(message.type === "event") { const payload = message.eventPayload || {}; payload[this.ownBridgeId] = true; switch(message.dispatchType as EventDispatchType) { case "sync": this.registry.fire(message.eventType, payload); break; case "react": this.registry.fire_react(message.eventType, payload); break; case "later": this.registry.fire_later(message.eventType, payload); break; } } } }