TeaWeb/shared/js/events.ts
2021-01-22 13:34:43 +01:00

633 lines
No EOL
22 KiB
TypeScript

import {LogCategory, logTrace} from "./log";
import {guid} from "./crypto/uid";
import * as React from "react";
import {useEffect} from "react";
import {unstable_batchedUpdates} from "react-dom";
import { tr } from "./i18n/localize";
export type EventPayloadObject = {
[key: string]: EventPayload
} | {
[key: number]: EventPayload
};
export type EventPayload = string | number | bigint | null | undefined | EventPayloadObject;
export type EventMap<P> = {
[K in keyof P]: EventPayloadObject & {
/* prohibit the type attribute on the highest layer (used to identify the event type) */
type?: never
}
};
export type Event<P extends EventMap<P>, T extends keyof P> = {
readonly type: T,
as<S extends T>(target: S) : Event<P, S>;
asUnchecked<S extends T>(target: S) : Event<P, S>;
asAnyUnchecked<S extends keyof P>(target: S) : Event<P, S>;
/**
* 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<P extends EventMap<P>, T extends keyof P>(type: T, payload?: P[T]) : Event<P, T> {
if(payload) {
let event = payload as any as Event<P, T>;
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<Events extends { [key: string]: any } = { [key: string]: any }> {
fire<T extends keyof Events>(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<T extends keyof Events>(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<T extends keyof Events>(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<Events extends { [key: string]: any } = { [key: string]: any }> implements EventSender<Events> {
protected readonly registryUniqueId;
protected persistentEventHandler: { [key: string]: ((event) => void)[] } = {};
protected oneShotEventHandler: { [key: string]: ((event) => void)[] } = {};
protected genericEventHandler: ((event) => void)[] = [];
protected consumer: EventConsumer[] = [];
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;
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);
}
enableDebug(prefix: string) { this.debugPrefix = prefix || "---"; }
disableDebug() { this.debugPrefix = undefined; }
enableWarnUnhandledEvents() { this.warnUnhandledEvents = true; }
disableWarnUnhandledEvents() { this.warnUnhandledEvents = false; }
fire<T extends keyof Events>(eventType: T, data?: Events[T], overrideTypeKey?: boolean) {
if(this.debugPrefix) {
logTrace(LogCategory.EVENT_REGISTRY, tr("[%s] Trigger event: %s"), this.debugPrefix, eventType);
}
if(typeof data === "object" && 'type' in data && !overrideTypeKey) {
if((data as any).type !== eventType) {
debugger;
throw tr("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<T extends keyof Events>(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<T extends keyof Events>(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<T extends keyof Events>(event: T | T[], handler: (event: Event<Events, T>) => 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<T extends keyof Events>(event: T | T[], handler: (event: Event<Events, T>) => 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<Events, keyof Events>) => void);
off<T extends keyof Events>(events: T | T[], handler: (event: Event<Events, T>) => 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<Events, keyof Events>) => void): () => void {
this.genericEventHandler.push(handler);
return () => this.genericEventHandler.remove(handler);
}
offAll(handler: (event: Event<Events, keyof Events>) => 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
* @param reactEffectDependencies
*/
reactUse<T extends keyof Events>(event: T, handler: (event?: Events[T] & Event<Events, T>) => void, condition?: boolean, reactEffectDependencies?: any[]) {
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.findIndex(handler);
if(index !== -1) {
handlers.splice(index, 1);
}
};
}, reactEffectDependencies);
}
private doInvokeEvent(event: Event<Events, keyof Events>) {
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, tr("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, handler);
}
}
}
registerConsumer(consumer: EventConsumer) : () => void {
const allConsumer = this.consumer;
allConsumer.push(consumer);
return () => allConsumer.remove(consumer);
}
unregisterConsumer(consumer: EventConsumer) {
this.consumer.remove(consumer);
}
}
export type RegistryMap = {[key: string]: any /* can't use Registry here since the template parameter is missing */ };
export function EventHandler<EventTypes>(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<ObjectClass = React.Component<any, any>, EventTypes = any>(registry_callback: (object: ObjectClass) => Registry<EventTypes>) {
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 namespace modal {
export namespace settings {
export type ProfileInfo = {
id: string,
name: string,
nickname: string,
identity_type: "teaforo" | "teamspeak" | "nickname",
identity_forum?: {
valid: boolean,
fallback_name: string
},
identity_nickname?: {
name: string,
fallback_name: string
},
identity_teamspeak?: {
unique_id: string,
fallback_name: string
}
}
export interface profiles {
"reload-profile": { profile_id?: string },
"select-profile": { profile_id: string },
"query-profile-list": { },
"query-profile-list-result": {
status: "error" | "success" | "timeout",
error?: string;
profiles?: ProfileInfo[]
}
"query-profile": { profile_id: string },
"query-profile-result": {
status: "error" | "success" | "timeout",
profile_id: string,
error?: string;
info?: ProfileInfo
},
"select-identity-type": {
profile_id: string,
identity_type: "teamspeak" | "teaforo" | "nickname" | "unset"
},
"query-profile-validity": { profile_id: string },
"query-profile-validity-result": {
profile_id: string,
status: "error" | "success" | "timeout",
error?: string,
valid?: boolean
}
"create-profile": { name: string },
"create-profile-result": {
status: "error" | "success" | "timeout",
name: string;
profile_id?: string;
error?: string;
},
"delete-profile": { profile_id: string },
"delete-profile-result": {
status: "error" | "success" | "timeout",
profile_id: string,
error?: string
}
"set-default-profile": { profile_id: string },
"set-default-profile-result": {
status: "error" | "success" | "timeout",
/* the profile which now has the id "default" */
old_profile_id: string,
/* the "default" profile which now has a new id */
new_profile_id?: string
error?: string;
}
/* profile name events */
"set-profile-name": {
profile_id: string,
name: string
},
"set-profile-name-result": {
status: "error" | "success" | "timeout",
profile_id: string,
name?: string
},
/* profile nickname events */
"set-default-name": {
profile_id: string,
name: string | null
},
"set-default-name-result": {
status: "error" | "success" | "timeout",
profile_id: string,
name?: string | null
},
"query-identity-teamspeak": { profile_id: string },
"query-identity-teamspeak-result": {
status: "error" | "success" | "timeout",
profile_id: string,
error?: string,
level?: number
}
"set-identity-name-name": { profile_id: string, name: string },
"set-identity-name-name-result": {
status: "error" | "success" | "timeout",
profile_id: string,
error?: string,
name?: string
},
"generate-identity-teamspeak": { profile_id: string },
"generate-identity-teamspeak-result": {
profile_id: string,
status: "error" | "success" | "timeout",
error?: string,
level?: number
unique_id?: string
},
"improve-identity-teamspeak-level": { profile_id: string },
"improve-identity-teamspeak-level-update": {
profile_id: string,
new_level: number
},
"import-identity-teamspeak": { profile_id: string },
"import-identity-teamspeak-result": {
profile_id: string,
level?: number
unique_id?: string
}
"export-identity-teamspeak": {
profile_id: string,
filename: string
},
"setup-forum-connection": {}
}
}
}