diff --git a/ChangeLog.md b/ChangeLog.md index a736d917..164f2b8f 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,4 +1,19 @@ # Changelog: +* **22.01.21** + - Allowing the user to easily change the channel name mode + - Fixed channel name mode parsing + - Improved the modal algorithms as preparation for easier popoutable modals + +* **16.01.21** + - Various bugfixes (Thanks to Vafin) + +* **15.01.21** + - Fixed the history toggle (Thanks to Vafin) + +* **12.01.21** + - Fixed bug where the quick video select popup did not start the video broadcasting + - Fixed a bug where an invalid H264 codec may caused video connection setup failure + * **09.01.21** - The connect modal now connects when pressing `Enter` on the address line diff --git a/loader/app/loader/script_loader.ts b/loader/app/loader/script_loader.ts index eb16ad31..a78fc8aa 100644 --- a/loader/app/loader/script_loader.ts +++ b/loader/app/loader/script_loader.ts @@ -33,7 +33,7 @@ function load_script_url(url: string) : Promise { const timeout_handle = setTimeout(() => { cleanup(); reject("timeout"); - }, 15 * 1000); + }, 120 * 1000); script_tag.type = "application/javascript"; script_tag.async = true; script_tag.defer = true; diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index 85c0ccc7..19bd34cd 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -41,6 +41,9 @@ import {ServerEventLog} from "tc-shared/connectionlog/ServerEventLog"; import {PlaylistManager} from "tc-shared/music/PlaylistManager"; import {connectionHistory} from "tc-shared/connectionlog/History"; import {ConnectParameters} from "tc-shared/ui/modal/connect/Controller"; +import {assertMainApplication} from "tc-shared/ui/utils"; + +assertMainApplication(); export enum InputHardwareState { MISSING, @@ -240,7 +243,7 @@ export class ConnectionHandler { this.localClient = new LocalClientEntry(this); this.localClient.channelTree = this.channelTree; - this.events_.register_handler(this); + this.events_.registerHandler(this); this.pluginCmdRegistry.registerHandler(new W2GPluginCmdHandler()); this.events_.fire("notify_handler_initialized"); @@ -1073,7 +1076,7 @@ export class ConnectionHandler { } destroy() { - this.events_.unregister_handler(this); + this.events_.unregisterHandler(this); this.cancelAutoReconnect(true); this.pluginCmdRegistry?.destroy(); diff --git a/shared/js/ConnectionManager.ts b/shared/js/ConnectionManager.ts index f86c448b..5ef4f6a9 100644 --- a/shared/js/ConnectionManager.ts +++ b/shared/js/ConnectionManager.ts @@ -2,6 +2,9 @@ import {ConnectionHandler, DisconnectReason} from "./ConnectionHandler"; import {Registry} from "./events"; import {Stage} from "tc-loader"; import * as loader from "tc-loader"; +import {assertMainApplication} from "tc-shared/ui/utils"; + +assertMainApplication(); export interface ConnectionManagerEvents { notify_handler_created: { diff --git a/shared/js/clientservice/index.ts b/shared/js/clientservice/index.ts index 010a60be..23104b84 100644 --- a/shared/js/clientservice/index.ts +++ b/shared/js/clientservice/index.ts @@ -74,6 +74,7 @@ class ClientServiceConnection { let address; address = "client-services.teaspeak.de:27791"; //address = "localhost:1244"; + //address = "192.168.40.135:1244"; this.connection = new WebSocket(`wss://${address}/ws-api/v${kApiVersion}`); this.connection.onclose = event => { @@ -375,7 +376,7 @@ export class ClientServices { } else { const os = window.detectedBrowser.os; const osParts = os.split(" "); - if(osParts.last().match(/^[0-9]+$/)) { + if(osParts.last().match(/^[0-9\.]+$/)) { payload.platform_version = osParts.last(); osParts.splice(osParts.length - 1, 1); } diff --git a/shared/js/connection/ServerFeatures.ts b/shared/js/connection/ServerFeatures.ts index a793fda9..be24b1db 100644 --- a/shared/js/connection/ServerFeatures.ts +++ b/shared/js/connection/ServerFeatures.ts @@ -29,7 +29,7 @@ export class ServerFeatures { readonly events: Registry; private readonly connection: ConnectionHandler; private readonly explicitCommandHandler: ExplicitCommandHandler; - private readonly stateChangeListener: (event: ConnectionEvents["notify_connection_state_changed"]) => void; + private readonly stateChangeListener: () => void; private featureAwait: Promise; private featureAwaitCallback: (success: boolean) => void; @@ -68,7 +68,7 @@ export class ServerFeatures { } }); - this.connection.events().on("notify_connection_state_changed", this.stateChangeListener = event => { + this.stateChangeListener = this.connection.events().on("notify_connection_state_changed", event => { if(event.newState === ConnectionState.CONNECTED) { this.connection.getServerConnection().send_command("listfeaturesupport").catch(error => { this.disableAllFeatures(); @@ -95,7 +95,7 @@ export class ServerFeatures { } destroy() { - this.connection.events().off(this.stateChangeListener); + this.stateChangeListener(); this.connection.getServerConnection()?.command_handler_boss()?.unregister_explicit_handler("notifyfeaturesupport", this.explicitCommandHandler); if(this.featureAwaitCallback) { diff --git a/shared/js/connection/rtc/Connection.ts b/shared/js/connection/rtc/Connection.ts index ed9775b2..53fc1179 100644 --- a/shared/js/connection/rtc/Connection.ts +++ b/shared/js/connection/rtc/Connection.ts @@ -63,7 +63,6 @@ class RetryTimeCalculator { } calculateRetryTime() { - return 0; if(this.retryCount >= 5) { /* no more retries */ return 0; @@ -542,6 +541,8 @@ export class RTCConnection { this.reset(true); this.connection.events.on("notify_connection_state_changed", event => this.handleConnectionStateChanged(event)); + + (window as any).rtp = this; } destroy() { diff --git a/shared/js/connection/rtc/SdpUtils.ts b/shared/js/connection/rtc/SdpUtils.ts index aa3ae2a0..0787d249 100644 --- a/shared/js/connection/rtc/SdpUtils.ts +++ b/shared/js/connection/rtc/SdpUtils.ts @@ -48,7 +48,7 @@ export class SdpProcessor { rate: 90000, rtcpFb: [ "nack", "nack pli", "ccm fir", "transport-cc" ], }, - { + window.detectedBrowser.name.indexOf("ios") === -1 && window.detectedBrowser.name !== "safari" ? { payload: H264_PAYLOAD_TYPE, codec: "H264", rate: 90000, @@ -57,10 +57,10 @@ export class SdpProcessor { fmtp: { "level-asymmetry-allowed": 1, "packetization-mode": 1, - "profile-level-id": "4d0028", + "profile-level-id": "42001f", "max-fr": 30, } - }, + } : undefined, ]; private rtpRemoteChannelMapping: {[key: string]: number}; @@ -150,7 +150,11 @@ export class SdpProcessor { media.rtcpFb = []; media.rtcpFbTrrInt = []; - for(let codec of (media.type === "audio" ? this.kAudioCodecs : this.kVideoCodecs)) { + for(const codec of (media.type === "audio" ? this.kAudioCodecs : this.kVideoCodecs)) { + if(!codec) { + continue; + } + media.rtp.push({ payload: codec.payload, codec: codec.codec, diff --git a/shared/js/events.ts b/shared/js/events.ts index b9d6dbe8..8a9002bb 100644 --- a/shared/js/events.ts +++ b/shared/js/events.ts @@ -1,28 +1,89 @@ -import {LogCategory, logTrace, logWarn} from "./log"; +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"; +import * as React from "react"; -export interface Event { - readonly type: T; - as() : Events[T]; +/* +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 SingletonEvents { - "singletone-instance": never; -} - -export class SingletonEvent implements Event { - static readonly instance = new SingletonEvent(); - - readonly type = "singletone-instance"; - private constructor() { } - as() : SingletonEvents[T] { return; } -} - -export interface EventReceiver { +export interface EventSender = EventMap> { fire(event_type: T, data?: Events[T], overrideTypeKey?: boolean); /** @@ -43,16 +104,27 @@ export interface EventReceiver(event_type: T, data?: Events[T], callback?: () => void); } -const event_annotation_key = guid(); -export class Registry implements EventReceiver { - private readonly registryUuid; +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 handler: {[key: string]: ((event) => void)[]} = {}; - private connections: {[key: string]: EventReceiver[]} = {}; - private eventHandlerObjects: { - object: any, - handlers: {[key: string]: ((event) => void)[]} - }[] = []; private debugPrefix = undefined; private warnUnhandledEvents = false; @@ -62,183 +134,183 @@ export class Registry void }[]; private pendingReactCallbacksFrame: number = 0; - constructor() { - this.registryUuid = "evreg_data_" + guid(); + 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; } - enable_warn_unhandled_events() { this.warnUnhandledEvents = true; } - disable_warn_unhandled_events() { this.warnUnhandledEvents = false; } + enableWarnUnhandledEvents() { this.warnUnhandledEvents = true; } + disableWarnUnhandledEvents() { this.warnUnhandledEvents = false; } - on(event: T, handler: (event?: Events[T] & Event) => void) : () => void; - on(events: (keyof Events)[], handler: (event?: Event) => void) : () => void; - on(events, handler) : () => void { - if(!Array.isArray(events)) - events = [events]; - - handler[this.registryUuid] = { - singleshot: false - }; - for(const event of events) { - const handlers = this.handler[event] || (this.handler[event] = []); - handlers.push(handler); + fire(eventType: T, data?: Events[T], overrideTypeKey?: boolean) { + if(this.debugPrefix) { + logTrace(LogCategory.EVENT_REGISTRY, "[%s] Trigger event: %s", this.debugPrefix, eventType); } - return () => this.off(events, handler); - } - - onAll(handler: (event?: Event) => void) : () => void { - handler[this.registryUuid] = { - singleshot: false - }; - (this.handler[null as any] || (this.handler[null as any] = [])).push(handler); - return () => this.offAll(handler); - } - - /* one */ - one(event: T, handler: (event?: Events[T] & Event) => void) : () => void; - one(events: (keyof Events)[], handler: (event?: Event) => void) : () => void; - one(events, handler) : () => void { - if(!Array.isArray(events)) - events = [events]; - - for(const event of events) { - const handlers = this.handler[event] || (this.handler[event] = []); - - handler[this.registryUuid] = { singleshot: true }; - handlers.push(handler); - } - return () => this.off(events, handler); - } - - off(handler: (event?) => void); - off(event: T, handler: (event?: Events[T] & Event) => void); - off(event: (keyof Events)[], handler: (event?: Event) => void); - off(handler_or_events, handler?) { - if(typeof handler_or_events === "function") { - for(const key of Object.keys(this.handler)) - this.handler[key].remove(handler_or_events); - } else { - if(!Array.isArray(handler_or_events)) - handler_or_events = [handler_or_events]; - - for(const event of handler_or_events) { - const handlers = this.handler[event]; - if(handlers) handlers.remove(handler); - } - } - } - - offAll(handler: (event?: Event) => void) { - (this.handler[null as any] || []).remove(handler); - } - - - /* special helper methods for react components */ - /** - * @param event - * @param handler - * @param condition - * @param reactEffectDependencies - */ - reactUse(event: T, handler: (event?: Events[T] & Event) => void, condition?: boolean, reactEffectDependencies?: any[]) { - if(typeof condition === "boolean" && !condition) { - useEffect(() => {}); - return; - } - const handlers = this.handler[event as any] || (this.handler[event as any] = []); - - useEffect(() => { - handlers.push(handler); - return () => { - handlers.remove(handler); - }; - }, reactEffectDependencies); - } - - connectAll(target: EventReceiver) { - (this.connections[null as any] || (this.connections[null as any] = [])).push(target as any); - } - - connect(events: T | T[], target: EventReceiver) { - for(const event of Array.isArray(events) ? events : [events]) - (this.connections[event as string] || (this.connections[event as string] = [])).push(target as any); - } - - disconnect(events: T | T[], target: EventReceiver) { - for(const event of Array.isArray(events) ? events : [events]) - (this.connections[event as string] || []).remove(target as any); - } - - disconnectAll(target: EventReceiver) { - this.connections[null as any]?.remove(target as any); - for(const event of Object.keys(this.connections)) - this.connections[event].remove(target as any); - } - - fire(event_type: T, data?: Events[T], overrideTypeKey?: boolean) { - if(this.debugPrefix) - logTrace(LogCategory.EVENT_REGISTRY, tr("[%s] Trigger event: %s"), this.debugPrefix, event_type); if(typeof data === "object" && 'type' in data && !overrideTypeKey) { - if((data as any).type !== event_type) { + 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"); + throw "The keyword 'type' is reserved for the event type and should not be passed as argument"; } } - const event = Object.assign(typeof data === "undefined" ? SingletonEvent.instance : data, { - type: event_type, - as: function () { return this; } - }); - this.fire_event(event_type, event); + for(const consumer of this.consumer) { + consumer.handleEvent("sync", eventType as string, data); + } + + this.doInvokeEvent(EventHelper.createEvent(eventType, data)); } - private fire_event(type: keyof Events, data: any) { - let invokeCount = 0; - - const typedHandler = this.handler[type as string] || []; - const generalHandler = this.handler[null as string] || []; - for(const handler of [...generalHandler, ...typedHandler]) { - handler(data); - invokeCount++; - - const regData = handler[this.registryUuid]; - if(typeof regData === "object" && regData.singleshot) - this.handler[type as string].remove(handler); /* FIXME: General single shot? */ - } - - const typedConnections = this.connections[type as string] || []; - const generalConnections = this.connections[null as string] || []; - for(const evhandler of [...generalConnections, ...typedConnections]) { - if('fire_event' in evhandler) - /* evhandler is an event registry as well. We don't have to check for any inappropriate keys */ - (evhandler as any).fire_event(type, data); - else - evhandler.fire(type, data); - invokeCount++; - } - if(this.warnUnhandledEvents && invokeCount === 0) { - logWarn(LogCategory.EVENT_REGISTRY, tr("Event handler (%s) triggered event %s which has no consumers."), this.debugPrefix, type); - } - } - - fire_later(event_type: T, data?: Events[T], callback?: () => void) { + fire_later(eventType: T, data?: Events[T], callback?: () => void) { if(!this.pendingAsyncCallbacksTimeout) { this.pendingAsyncCallbacksTimeout = setTimeout(() => this.invokeAsyncCallbacks()); this.pendingAsyncCallbacks = []; } - this.pendingAsyncCallbacks.push({ type: event_type, data: data, callback: callback }); + this.pendingAsyncCallbacks.push({ type: eventType, data: data, callback: callback }); + + for(const consumer of this.consumer) { + consumer.handleEvent("later", eventType as string, data); + } } - fire_react(event_type: T, data?: Events[T], callback?: () => void) { + fire_react(eventType: T, data?: Events[T], callback?: () => void) { if(!this.pendingReactCallbacks) { this.pendingReactCallbacksFrame = requestAnimationFrame(() => this.invokeReactCallbacks()); this.pendingReactCallbacks = []; } - this.pendingReactCallbacks.push({ type: event_type, data: data, callback: callback }); + + 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() { @@ -266,7 +338,7 @@ export class Registry { /* batch all react updates */ unstable_batchedUpdates(() => { @@ -287,66 +359,94 @@ export class Registry { - if(function_name === "constructor") + Object.getOwnPropertyNames(currentPrototype).forEach(functionName => { + if(functionName === "constructor") { return; + } - if(typeof proto[function_name] !== "function") + if(typeof prototype[functionName] !== "function") { return; + } - if(typeof proto[function_name][event_annotation_key] !== "object") + if(typeof prototype[functionName][kEventAnnotationKey] !== "object") { return; + } - const event_data = proto[function_name][event_annotation_key]; - const ev_handler = event => proto[function_name].call(handler, event); - for(const event of event_data.events) { - registered_events[event] = registered_events[event] || []; - registered_events[event].push(ev_handler); - this.on(event, ev_handler); + 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) + if(!parentClasses) { break; + } } while ((currentPrototype = Object.getPrototypeOf(currentPrototype))); - if(Object.keys(registered_events).length === 0) { - logWarn(LogCategory.EVENT_REGISTRY, tr("No events found in event handler which has been registered.")); - return; - } - - this.eventHandlerObjects.push({ - handlers: registered_events, - object: handler - }); } - unregister_handler(handler: any) { - const data = this.eventHandlerObjects.find(e => e.object === handler); - if(!data) return; - - this.eventHandlerObjects.remove(data); - - for(const key of Object.keys(data.handlers)) { - for(const evhandler of data.handlers[key]) - this.off(evhandler); + 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 + }; } } @@ -355,17 +455,17 @@ export type RegistryMap = {[key: string]: any /* can't use Registry here since t export function EventHandler(events: (keyof EventTypes) | (keyof EventTypes)[]) { return function (target: any, propertyKey: string, - descriptor: PropertyDescriptor) { + _descriptor: PropertyDescriptor) { if(typeof target[propertyKey] !== "function") throw "Invalid event handler annotation. Expected to be on a function type."; - target[propertyKey][event_annotation_key] = { + target[propertyKey][kEventAnnotationKey] = { events: Array.isArray(events) ? events : [events] }; } } -export function ReactEventHandler, EventTypes = any>(registry_callback: (object: ObjectClass) => Registry) { +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"; @@ -374,10 +474,11 @@ export function ReactEventHandler, Event constructor.prototype.componentDidMount = function() { const registry = registry_callback(this); if(!registry) throw "Event registry returned for an event object is invalid"; - registry.register_handler(this); + registry.registerHandler(this); - if(typeof didMount === "function") + if(typeof didMount === "function") { didMount.call(this, arguments); + } }; const willUnmount = constructor.prototype.componentWillUnmount; @@ -385,396 +486,84 @@ export function ReactEventHandler, Event const registry = registry_callback(this); if(!registry) throw "Event registry returned for an event object is invalid"; try { - registry.unregister_handler(this); + registry.unregisterHandler(this); } catch (error) { console.warn("Failed to unregister event handler: %o", error); } - if(typeof willUnmount === "function") + if(typeof willUnmount === "function") { willUnmount.call(this, arguments); + } }; } } -export namespace modal { - export type BotStatusType = "name" | "description" | "volume" | "country_code" | "channel_commander" | "priority_speaker"; - export type PlaylistStatusType = "replay_mode" | "finished" | "delete_played" | "max_size" | "notify_song_change"; - export interface music_manage { - show_container: { container: "settings" | "permissions"; }; +export type IpcRegistryDescription = EventMap> = { + ipcChannelId: string +} - /* setting relevant */ - query_bot_status: {}, - bot_status: { - status: "success" | "error"; - error_msg?: string; - data?: { - name: string, - description: string, - volume: number, +class IpcEventBridge implements EventConsumer { + readonly registry: Registry; + readonly ipcChannelId: string; + private readonly ownBridgeId: string; + private broadcastChannel: BroadcastChannel; - country_code: string, - default_country_code: string, + constructor(registry: Registry, ipcChannelId: string | undefined) { + this.registry = registry; + this.ownBridgeId = guid(); - channel_commander: boolean, - priority_speaker: boolean, - - client_version: string, - client_platform: string, - - uptime_mode: number, - bot_type: number - } - }, - set_bot_status: { - key: BotStatusType, - value: any - }, - set_bot_status_result: { - key: BotStatusType, - status: "success" | "error" | "timeout", - error_msg?: string, - value?: any - } - - query_playlist_status: {}, - playlist_status: { - status: "success" | "error", - error_msg?: string, - data?: { - replay_mode: number, - finished: boolean, - delete_played: boolean, - max_size: number, - notify_song_change: boolean - } - }, - set_playlist_status: { - key: PlaylistStatusType, - value: any - }, - set_playlist_status_result: { - key: PlaylistStatusType, - status: "success" | "error" | "timeout", - error_msg?: string, - value?: any - } - - /* permission relevant */ - show_client_list: {}, - hide_client_list: {}, - - filter_client_list: { filter: string | undefined }, - - "refresh_permissions": {}, - - query_special_clients: {}, - special_client_list: { - status: "success" | "error" | "error-permission", - error_msg?: string, - clients?: { - name: string, - unique_id: string, - database_id: number - }[] - }, - - search_client: { text: string }, - search_client_result: { - status: "error" | "timeout" | "empty" | "success", - error_msg?: string, - client?: { - name: string, - unique_id: string, - database_id: number - } - }, - - /* sets a client to set the permission for */ - special_client_set: { - client?: { - name: string, - unique_id: string, - database_id: number - } - }, - - "query_general_permissions": {}, - "general_permissions": { - status: "error" | "timeout" | "success", - error_msg?: string, - permissions?: {[key: string]:number} - }, - "set_general_permission_result": { - status: "error" | "success", - key: string, - value?: number, - error_msg?: string - }, - "set_general_permission": { /* try to change a permission for the server */ - key: string, - value: number - }, - - - "query_client_permissions": { client_database_id: number }, - "client_permissions": { - status: "error" | "timeout" | "success", - client_database_id: number, - error_msg?: string, - permissions?: {[key: string]:number} - }, - "set_client_permission_result": { - status: "error" | "success", - client_database_id: number, - key: string, - value?: number, - error_msg?: string - }, - "set_client_permission": { /* try to change a permission for the server */ - client_database_id: number, - key: string, - value: number - }, - - "query_group_permissions": { permission_name: string }, - "group_permissions": { - permission_name: string; - status: "error" | "timeout" | "success" - groups?: { - name: string, - value: number, - id: number - }[], - error_msg?: string - } + 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); } - 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 - } + destroy() { + if(this.broadcastChannel) { + this.broadcastChannel.onmessage = undefined; + this.broadcastChannel.onmessageerror = undefined; + this.broadcastChannel.close(); } - export interface profiles { - "reload-profile": { profile_id?: string }, - "select-profile": { profile_id: string }, + this.broadcastChannel = undefined; + } - "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": {} + handleEvent(dispatchType: EventDispatchType, eventType: string, eventPayload: any) { + if(eventPayload && eventPayload[this.ownBridgeId]) { + return; } - export type MicrophoneSettings = "volume" | "vad-type" | "ppt-key" | "ppt-release-delay" | "ppt-release-delay-active" | "threshold-threshold"; - export interface microphone { - "query-devices": { refresh_list: boolean }, - "query-device-result": { - status: "success" | "error" | "timeout", + this.broadcastChannel.postMessage({ + type: "event", + source: this.ownBridgeId, - error?: string, - devices?: { - id: string, - name: string, - driver: string - }[] - active_device?: string; - }, + dispatchType, + eventType, + eventPayload, + }); + } - "query-settings": {}, - "query-settings-result": { - status: "success" | "error" | "timeout", + private handleIpcMessage(message: any, _source: MessageEventSource | null, _origin: string) { + if(message.source === this.ownBridgeId) { + /* It's our own event */ + return; + } - error?: string, - info?: { - volume: number, - vad_type: string, + 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; - vad_ppt: { - key: any, /* ppt.KeyDescriptor */ - release_delay: number, - release_delay_active: boolean - }, - vad_threshold: { - threshold: number - } - } - }, + case "react": + this.registry.fire_react(message.eventType, payload); + break; - "set-device": { device_id: string }, - "set-device-result": { - device_id: string, - status: "success" | "error" | "timeout", - - error?: string - }, - - "set-setting": { - setting: MicrophoneSettings; - value: any; - }, - "set-setting-result": { - setting: MicrophoneSettings, - status: "success" | "error" | "timeout", - - error?: string, - value?: any - }, - - "update-device-level": { - devices: { - device_id: string, - status: "success" | "error", - - level?: number, - error?: string - }[] - }, - - "audio-initialized": {}, - "deinitialize": {} + case "later": + this.registry.fire_later(message.eventType, payload); + break; + } } } } \ No newline at end of file diff --git a/shared/js/file/LocalAvatars.ts b/shared/js/file/LocalAvatars.ts index a1a9736d..b8cddc2e 100644 --- a/shared/js/file/LocalAvatars.ts +++ b/shared/js/file/LocalAvatars.ts @@ -29,6 +29,7 @@ import {IPCChannel} from "../ipc/BrowserIPC"; import {ConnectionHandler} from "../ConnectionHandler"; import {ErrorCode} from "../connection/ErrorCode"; import {server_connections} from "tc-shared/ConnectionManager"; +import {EventDispatchType} from "tc-shared/events"; /* FIXME: Retry avatar download after some time! */ @@ -424,8 +425,10 @@ class LocalAvatarManagerFactory extends AbstractAvatarManagerFactory { subscribedAvatars.push({ avatar: avatar, remoteAvatarId: avatarId, - unregisterCallback: avatar.events.onAll(event => { - this.ipcChannel.sendMessage("avatar-event", { handlerId: handlerId, avatarId: avatarId, event: event }, remoteId); + unregisterCallback: avatar.events.registerConsumer({ + handleEvent(mode: EventDispatchType, type: string, payload: any) { + this.ipcChannel.sendMessage("avatar-event", { handlerId: handlerId, avatarId: avatarId, type, payload }, remoteId); + } }) }); diff --git a/shared/js/file/RemoteAvatars.ts b/shared/js/file/RemoteAvatars.ts index e0f5b469..8d8fd771 100644 --- a/shared/js/file/RemoteAvatars.ts +++ b/shared/js/file/RemoteAvatars.ts @@ -142,11 +142,11 @@ class RemoteAvatarManager extends AbstractAvatarManager { avatar.updateStateFromRemote(data.state, data.stateData); } - handleAvatarEvent(data: any) { - const avatar = this.knownAvatars.find(e => e.avatarId === data.avatarId); + handleAvatarEvent(type: string, payload: any) { + const avatar = this.knownAvatars.find(e => e.avatarId === payload.avatarId); if(!avatar) return; - avatar.events.fire(data.event.type, data.event, true); + avatar.events.fire(type as any, payload, true); } } @@ -211,7 +211,7 @@ class RemoteAvatarManagerFactory extends AbstractAvatarManagerFactory { manager?.handleAvatarLoadCallback(message.data); } else if(message.type === "avatar-event") { const manager = this.manager[message.data.handlerId]; - manager?.handleAvatarEvent(message.data); + manager?.handleAvatarEvent(message.data.type, message.data.payload); } } } diff --git a/shared/js/i18n/localize.ts b/shared/js/i18n/localize.ts index 74a1ac47..5d4a0aeb 100644 --- a/shared/js/i18n/localize.ts +++ b/shared/js/i18n/localize.ts @@ -1,4 +1,4 @@ -import {LogCategory, logError, logInfo, logWarn} from "../log"; +import {LogCategory, logDebug, logError, logInfo, logTrace, logWarn} from "../log"; import {guid} from "../crypto/uid"; import {Settings, StaticSettings} from "../settings"; import * as loader from "tc-loader"; @@ -48,22 +48,29 @@ export interface TranslationRepository { } let translations: Translation[] = []; -let fast_translate: { [key:string]:string; } = {}; -export function tr(message: string, key?: string) { - const sloppy = fast_translate[message]; - if(sloppy) return sloppy; +let translateCache: { [key:string]: string; } = {}; +export function tr(message: string, key?: string) : string { + const sloppy = translateCache[message]; + if(sloppy) { + return sloppy; + } - logInfo(LogCategory.I18N, "Translating \"%s\". Default: \"%s\"", key, message); + logTrace(LogCategory.I18N, "Translating \"%s\". Default: \"%s\"", key, message); - let translated = message; + let translated; for(const translation of translations) { - if(translation.key.message == message) { + if(translation.key.message === message) { translated = translation.translated; break; } } - fast_translate[message] = translated; + if(typeof translated === "string") { + translateCache[message] = translated; + } else { + logDebug(LogCategory.I18N, "Missing translation for \"%s\".", message); + translateCache[message] = translated = message; + } return translated; } @@ -316,6 +323,7 @@ export async function initialize() { if(cfg.current_translation_url) { try { await load_file(cfg.current_translation_url, cfg.current_translation_path); + translateCache = {}; } catch (error) { logError(LogCategory.I18N, tr("Failed to initialize selected translation: %o"), error); const show_error = () => { diff --git a/shared/js/main.tsx b/shared/js/main.tsx index 4826357c..141e74bc 100644 --- a/shared/js/main.tsx +++ b/shared/js/main.tsx @@ -47,6 +47,9 @@ import "./ui/elements/ContextDivider"; import "./ui/elements/Tab"; import "./clientservice"; import {initializeKeyControl} from "./KeyControl"; +import {assertMainApplication} from "tc-shared/ui/utils"; + +assertMainApplication(); let preventWelcomeUI = false; async function initialize() { diff --git a/shared/js/media/Video.ts b/shared/js/media/Video.ts index 670a4cf0..b041c5aa 100644 --- a/shared/js/media/Video.ts +++ b/shared/js/media/Video.ts @@ -248,9 +248,9 @@ export class WebVideoSource implements VideoSource { private readonly deviceId: string; private readonly displayName: string; private readonly stream: MediaStream; + private readonly initialSettings: VideoSourceInitialSettings; private referenceCount = 1; - private initialSettings: VideoSourceInitialSettings; constructor(deviceId: string, displayName: string, stream: MediaStream) { this.deviceId = deviceId; @@ -291,13 +291,13 @@ export class WebVideoSource implements VideoSource { return { minWidth: capabilities?.width?.min || 1, - maxWidth: capabilities?.width?.max || this.initialSettings.width, + maxWidth: capabilities?.width?.max || this.initialSettings.width || undefined, minHeight: capabilities?.height?.min || 1, - maxHeight: capabilities?.height?.max || this.initialSettings.height, + maxHeight: capabilities?.height?.max || this.initialSettings.height || undefined, minFrameRate: capabilities?.frameRate?.min || 1, - maxFrameRate: capabilities?.frameRate?.max || this.initialSettings.frameRate + maxFrameRate: capabilities?.frameRate?.max || this.initialSettings.frameRate || undefined }; } diff --git a/shared/js/settings.ts b/shared/js/settings.ts index fad2ab63..13615f9f 100644 --- a/shared/js/settings.ts +++ b/shared/js/settings.ts @@ -143,6 +143,7 @@ export namespace AppParameters { parseParameters(); } +(window as any).AppParameters = AppParameters; export namespace AppParameters { export const KEY_CONNECT_ADDRESS: RegistryKey = { @@ -216,6 +217,12 @@ export namespace AppParameters { description: "Address of the owner for IPC communication." }; + export const KEY_IPC_REMOTE_POPOUT_CHANNEL: RegistryKey = { + key: "ipc-channel", + valueType: "string", + description: "The channel name of the popout channel communication id" + }; + export const KEY_MODAL_TARGET: RegistryKey = { key: "modal-target", valueType: "string", @@ -644,6 +651,20 @@ export class Settings { valueType: "number", }; + static readonly KEY_VIDEO_DEFAULT_MAX_BANDWIDTH: ValuedRegistryKey = { + key: "video_default_max_bandwidth", + defaultValue: 1_600_000, + description: "The default video bandwidth to use in bits/seconds.\nA too high value might not be allowed by all server permissions.", + valueType: "number", + }; + + static readonly KEY_VIDEO_DEFAULT_KEYFRAME_INTERVAL: ValuedRegistryKey = { + key: "video_default_keyframe_interval", + defaultValue: 0, + description: "The default interval to forcibly request a keyframe from ourself in seconds. A value of zero means no such interval.", + valueType: "number", + }; + static readonly KEY_VIDEO_DYNAMIC_QUALITY: ValuedRegistryKey = { key: "video_dynamic_quality", defaultValue: true, diff --git a/shared/js/tree/Channel.ts b/shared/js/tree/Channel.ts index e2091e53..bfd39a13 100644 --- a/shared/js/tree/Channel.ts +++ b/shared/js/tree/Channel.ts @@ -114,54 +114,72 @@ export interface ChannelEvents extends ChannelTreeEntryEvents { notify_description_changed: {} } -export class ParsedChannelName { +export type ChannelNameAlignment = "center" | "right" | "left" | "normal" | "repetitive"; +export class ChannelNameParser { readonly originalName: string; - alignment: "center" | "right" | "left" | "normal" | "repetitive"; + alignment: ChannelNameAlignment; text: string; /* does not contain any alignment codes */ + uniqueId: string; constructor(name: string, hasParentChannel: boolean) { this.originalName = name; this.parse(hasParentChannel); } - private parse(has_parent_channel: boolean) { + private parse(hasParentChannel: boolean) { this.alignment = "normal"; + if(this.originalName.length < 3) { + this.text = this.originalName; + return; + } - parse_type: - if(!has_parent_channel && this.originalName.charAt(0) == '[') { + + parseType: + if(!hasParentChannel && this.originalName.charAt(0) == '[') { let end = this.originalName.indexOf(']'); - if(end === -1) break parse_type; + if(end === -1) { + break parseType; + } let options = this.originalName.substr(1, end - 1); - if(options.indexOf("spacer") === -1) break parse_type; - options = options.substr(0, options.indexOf("spacer")); + const spacerIndex = options.indexOf("spacer"); + if(spacerIndex === -1) break parseType; + this.uniqueId = options.substring(spacerIndex + 6); + options = options.substr(0, spacerIndex); - if(options.length == 0) + if(options.length == 0) { options = "l"; - else if(options.length > 1) + } else if(options.length > 1) { options = options[0]; + } switch (options) { case "r": this.alignment = "right"; break; + case "l": - this.alignment = "center"; + this.alignment = "left"; break; + case "c": this.alignment = "center"; break; + case "*": this.alignment = "repetitive"; break; + default: - break parse_type; + break parseType; } this.text = this.originalName.substr(end + 1); } - if(!this.text && this.alignment === "normal") + + if(!this.text && this.alignment === "normal") { this.text = this.originalName; + } } } @@ -177,7 +195,7 @@ export class ChannelEntry extends ChannelTreeEntry { readonly events: Registry; - parsed_channel_name: ParsedChannelName; + parsed_channel_name: ChannelNameParser; private _family_index: number = 0; @@ -206,7 +224,7 @@ export class ChannelEntry extends ChannelTreeEntry { this.properties = new ChannelProperties(); this.channelId = channelId; this.properties.channel_name = channelName; - this.parsed_channel_name = new ParsedChannelName(channelName, false); + this.parsed_channel_name = new ChannelNameParser(channelName, false); this.clientPropertyChangedListener = (event: ClientEvents["notify_properties_updated"]) => { if("client_nickname" in event.updated_properties || "client_talk_power" in event.updated_properties) { @@ -627,7 +645,7 @@ export class ChannelEntry extends ChannelTreeEntry { if(hasUpdate) { if(key == "channel_name") { - this.parsed_channel_name = new ParsedChannelName(value, this.hasParent()); + this.parsed_channel_name = new ChannelNameParser(value, this.hasParent()); } else if(key == "channel_order") { let order = this.channelTree.findChannel(this.properties.channel_order); this.channelTree.moveChannel(this, order, this.parent, false); diff --git a/shared/js/ui/frames/side/ChannelConversationController.ts b/shared/js/ui/frames/side/ChannelConversationController.ts index 7fd11ce9..9b017237 100644 --- a/shared/js/ui/frames/side/ChannelConversationController.ts +++ b/shared/js/ui/frames/side/ChannelConversationController.ts @@ -36,14 +36,14 @@ export class ChannelConversationController extends AbstractConversationControlle }); */ - this.uiEvents.register_handler(this, true); + this.uiEvents.registerHandler(this, true); } destroy() { this.connectionListener.forEach(callback => callback()); this.connectionListener = []; - this.uiEvents.unregister_handler(this); + this.uiEvents.unregisterHandler(this); super.destroy(); } diff --git a/shared/js/ui/frames/side/PopoutConversationRenderer.tsx b/shared/js/ui/frames/side/PopoutConversationRenderer.tsx index 79ff39d8..fe17eca0 100644 --- a/shared/js/ui/frames/side/PopoutConversationRenderer.tsx +++ b/shared/js/ui/frames/side/PopoutConversationRenderer.tsx @@ -23,7 +23,7 @@ class PopoutConversationRenderer extends AbstractModal { noFirstMessageOverlay={this.userData.noFirstMessageOverlay} />; } - title() { + renderTitle() { return "Conversations"; } } diff --git a/shared/js/ui/frames/side/PrivateConversationController.ts b/shared/js/ui/frames/side/PrivateConversationController.ts index 239e57bf..714b3e31 100644 --- a/shared/js/ui/frames/side/PrivateConversationController.ts +++ b/shared/js/ui/frames/side/PrivateConversationController.ts @@ -52,14 +52,14 @@ export class PrivateConversationController extends AbstractConversationControlle this.connectionListener = []; this.listenerConversation = {}; - this.uiEvents.register_handler(this, true); + this.uiEvents.registerHandler(this, true); this.uiEvents.enableDebug("private-conversations"); } destroy() { /* listenerConversation will be cleaned up via the listenerManager callbacks */ - this.uiEvents.unregister_handler(this); + this.uiEvents.unregisterHandler(this); super.destroy(); } diff --git a/shared/js/ui/modal/ModalChangeVolumeNew.tsx b/shared/js/ui/modal/ModalChangeVolumeNew.tsx index 18049aed..da04b91c 100644 --- a/shared/js/ui/modal/ModalChangeVolumeNew.tsx +++ b/shared/js/ui/modal/ModalChangeVolumeNew.tsx @@ -1,4 +1,4 @@ -import {spawnReactModal} from "tc-shared/ui/react-elements/Modal"; +import {spawnReactModal} from "tc-shared/ui/react-elements/modal"; import * as React from "react"; import {Slider} from "tc-shared/ui/react-elements/Slider"; import {Button} from "tc-shared/ui/react-elements/Button"; @@ -243,7 +243,7 @@ class VolumeChange extends InternalModal { return ; } - title() { + renderTitle() { return Change local volume; } } @@ -284,7 +284,7 @@ class VolumeChangeBot extends InternalModal { return ; } - title() { + renderTitle() { return Change remote volume; } } diff --git a/shared/js/ui/modal/ModalGroupCreate.tsx b/shared/js/ui/modal/ModalGroupCreate.tsx index 8975716b..c4254181 100644 --- a/shared/js/ui/modal/ModalGroupCreate.tsx +++ b/shared/js/ui/modal/ModalGroupCreate.tsx @@ -1,4 +1,4 @@ -import {spawnReactModal} from "tc-shared/ui/react-elements/Modal"; +import {spawnReactModal} from "tc-shared/ui/react-elements/modal"; import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {Registry} from "tc-shared/events"; import {FlatInputField, Select} from "tc-shared/ui/react-elements/InputField"; @@ -272,7 +272,7 @@ class ModalGroupCreate extends InternalModal { ; } - title() { + renderTitle() { return this.target === "server" ? Create a new server group : Create a new channel group; } diff --git a/shared/js/ui/modal/ModalGroupPermissionCopy.tsx b/shared/js/ui/modal/ModalGroupPermissionCopy.tsx index 27f141bb..a18f3500 100644 --- a/shared/js/ui/modal/ModalGroupPermissionCopy.tsx +++ b/shared/js/ui/modal/ModalGroupPermissionCopy.tsx @@ -1,4 +1,4 @@ -import {spawnReactModal} from "tc-shared/ui/react-elements/Modal"; +import {spawnReactModal} from "tc-shared/ui/react-elements/modal"; import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {Registry} from "tc-shared/events"; import * as React from "react"; @@ -161,7 +161,7 @@ class ModalGroupPermissionCopy extends InternalModal { ; } - title() { + renderTitle() { return Copy group permissions; } } diff --git a/shared/js/ui/modal/ModalMusicManage.ts b/shared/js/ui/modal/ModalMusicManage.ts index 788a283a..2227567b 100644 --- a/shared/js/ui/modal/ModalMusicManage.ts +++ b/shared/js/ui/modal/ModalMusicManage.ts @@ -1,7 +1,7 @@ import {createErrorModal, createModal} from "../../ui/elements/Modal"; import {ConnectionHandler} from "../../ConnectionHandler"; import {MusicClientEntry} from "../../tree/Client"; -import {modal, Registry} from "../../events"; +import {Registry} from "../../events"; import {CommandResult} from "../../connection/ServerConnectionDeclaration"; import {LogCategory, logError, logWarn} from "../../log"; import {tr, tra} from "../../i18n/localize"; @@ -12,8 +12,160 @@ import * as htmltags from "../../ui/htmltags"; import {ErrorCode} from "../../connection/ErrorCode"; import ServerGroup = find.ServerGroup; +type BotStatusType = "name" | "description" | "volume" | "country_code" | "channel_commander" | "priority_speaker"; +type PlaylistStatusType = "replay_mode" | "finished" | "delete_played" | "max_size" | "notify_song_change"; +interface music_manage { + show_container: { container: "settings" | "permissions"; }; + + /* setting relevant */ + query_bot_status: {}, + bot_status: { + status: "success" | "error"; + error_msg?: string; + data?: { + name: string, + description: string, + volume: number, + + country_code: string, + default_country_code: string, + + channel_commander: boolean, + priority_speaker: boolean, + + client_version: string, + client_platform: string, + + uptime_mode: number, + bot_type: number + } + }, + set_bot_status: { + key: BotStatusType, + value: any + }, + set_bot_status_result: { + key: BotStatusType, + status: "success" | "error" | "timeout", + error_msg?: string, + value?: any + } + + query_playlist_status: {}, + playlist_status: { + status: "success" | "error", + error_msg?: string, + data?: { + replay_mode: number, + finished: boolean, + delete_played: boolean, + max_size: number, + notify_song_change: boolean + } + }, + set_playlist_status: { + key: PlaylistStatusType, + value: any + }, + set_playlist_status_result: { + key: PlaylistStatusType, + status: "success" | "error" | "timeout", + error_msg?: string, + value?: any + } + + /* permission relevant */ + show_client_list: {}, + hide_client_list: {}, + + filter_client_list: { filter: string | undefined }, + + "refresh_permissions": {}, + + query_special_clients: {}, + special_client_list: { + status: "success" | "error" | "error-permission", + error_msg?: string, + clients?: { + name: string, + unique_id: string, + database_id: number + }[] + }, + + search_client: { text: string }, + search_client_result: { + status: "error" | "timeout" | "empty" | "success", + error_msg?: string, + client?: { + name: string, + unique_id: string, + database_id: number + } + }, + + /* sets a client to set the permission for */ + special_client_set: { + client?: { + name: string, + unique_id: string, + database_id: number + } + }, + + "query_general_permissions": {}, + "general_permissions": { + status: "error" | "timeout" | "success", + error_msg?: string, + permissions?: {[key: string]:number} + }, + "set_general_permission_result": { + status: "error" | "success", + key: string, + value?: number, + error_msg?: string + }, + "set_general_permission": { /* try to change a permission for the server */ + key: string, + value: number + }, + + + "query_client_permissions": { client_database_id: number }, + "client_permissions": { + status: "error" | "timeout" | "success", + client_database_id: number, + error_msg?: string, + permissions?: {[key: string]:number} + }, + "set_client_permission_result": { + status: "error" | "success", + client_database_id: number, + key: string, + value?: number, + error_msg?: string + }, + "set_client_permission": { /* try to change a permission for the server */ + client_database_id: number, + key: string, + value: number + }, + + "query_group_permissions": { permission_name: string }, + "group_permissions": { + permission_name: string; + status: "error" | "timeout" | "success" + groups?: { + name: string, + value: number, + id: number + }[], + error_msg?: string + } +} + export function openMusicManage(client: ConnectionHandler, bot: MusicClientEntry) { - const ev_registry = new Registry(); + const ev_registry = new Registry(); ev_registry.enableDebug("music-manage"); //dummy_controller(ev_registry); permission_controller(ev_registry, bot, client); @@ -36,7 +188,7 @@ export function openMusicManage(client: ConnectionHandler, bot: MusicClientEntry modal.open(); } -function permission_controller(event_registry: Registry, bot: MusicClientEntry, client: ConnectionHandler) { +function permission_controller(event_registry: Registry, bot: MusicClientEntry, client: ConnectionHandler) { const error_msg = error => { if (error instanceof CommandResult) { if (error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) { @@ -380,7 +532,7 @@ function permission_controller(event_registry: Registry, bot } } -function dummy_controller(event_registry: Registry) { +function dummy_controller(event_registry: Registry) { /* settings */ { event_registry.on("query_bot_status", event => { @@ -510,7 +662,7 @@ function dummy_controller(event_registry: Registry) { } -function build_modal(event_registry: Registry): JQuery { +function build_modal(event_registry: Registry): JQuery { const tag = $("#tmpl_music_manage").renderTag(); const container_settings = tag.find(".body > .category-settings"); @@ -563,7 +715,7 @@ function build_modal(event_registry: Registry): JQuery, tag: JQuery) { +function build_settings_container(event_registry: Registry, tag: JQuery) { const show_change_error = (header, message) => { createErrorModal(tr("Failed to change value"), header + "
" + message).open(); }; @@ -1169,7 +1321,7 @@ function build_settings_container(event_registry: Registry, } } -function build_permission_container(event_registry: Registry, tag: JQuery) { +function build_permission_container(event_registry: Registry, tag: JQuery) { /* client search mechanism */ { const container = tag.find(".table-head .column-client-specific .client-select"); diff --git a/shared/js/ui/modal/ModalNewcomer.tsx b/shared/js/ui/modal/ModalNewcomer.tsx index dae4a624..ca625b47 100644 --- a/shared/js/ui/modal/ModalNewcomer.tsx +++ b/shared/js/ui/modal/ModalNewcomer.tsx @@ -1,7 +1,7 @@ import {createModal, Modal} from "tc-shared/ui/elements/Modal"; import {tra} from "tc-shared/i18n/localize"; -import {modal as emodal, Registry} from "tc-shared/events"; -import {modal_settings} from "tc-shared/ui/modal/ModalSettings"; +import {Registry} from "tc-shared/events"; +import {modal_settings, SettingProfileEvents} from "tc-shared/ui/modal/ModalSettings"; import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo"; import {initialize_audio_microphone_controller, MicrophoneSettingsEvents} from "tc-shared/ui/modal/settings/Microphone"; import {MicrophoneSettings} from "tc-shared/ui/modal/settings/MicrophoneRenderer"; @@ -151,7 +151,7 @@ function initializeStepFinish(tag: JQuery, event_registry: Registry) { - const profile_events = new Registry(); + const profile_events = new Registry(); profile_events.enableDebug("settings-identity"); modal_settings.initialize_identity_profiles_controller(profile_events); modal_settings.initialize_identity_profiles_view(tag, profile_events, {forum_setuppable: false}); diff --git a/shared/js/ui/modal/ModalSettings.tsx b/shared/js/ui/modal/ModalSettings.tsx index 7f6f0224..ebda0a72 100644 --- a/shared/js/ui/modal/ModalSettings.tsx +++ b/shared/js/ui/modal/ModalSettings.tsx @@ -28,6 +28,164 @@ import {NotificationSettings} from "tc-shared/ui/modal/settings/Notifications"; import {initialize_audio_microphone_controller, MicrophoneSettingsEvents} from "tc-shared/ui/modal/settings/Microphone"; import {MicrophoneSettings} from "tc-shared/ui/modal/settings/MicrophoneRenderer"; +type ProfileInfoEvent = { + 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 SettingProfileEvents { + "reload-profile": { profile_id?: string }, + "select-profile": { profile_id: string }, + + "query-profile-list": { }, + "query-profile-list-result": { + status: "error" | "success" | "timeout", + + error?: string; + profiles?: ProfileInfoEvent[] + } + + "query-profile": { profile_id: string }, + "query-profile-result": { + status: "error" | "success" | "timeout", + profile_id: string, + + error?: string; + info?: ProfileInfoEvent + }, + + "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": {} +} + export function spawnSettingsModal(default_page?: string): Modal { let modal: Modal; modal = createModal({ @@ -449,7 +607,7 @@ function settings_audio_microphone(container: JQuery, modal: Modal) { } function settings_identity_profiles(container: JQuery, modal: Modal) { - const registry = new Registry(); + const registry = new Registry(); //registry.enable_debug("settings-identity"); modal_settings.initialize_identity_profiles_controller(registry); modal_settings.initialize_identity_profiles_view(container, registry, { @@ -689,7 +847,7 @@ export namespace modal_settings { forum_setuppable: boolean } - export function initialize_identity_profiles_controller(event_registry: Registry) { + export function initialize_identity_profiles_controller(event_registry: Registry) { const send_error = (event, profile, text) => event_registry.fire_react(event, { status: "error", profile_id: profile, @@ -997,7 +1155,7 @@ export namespace modal_settings { }); } - export function initialize_identity_profiles_view(container: JQuery, event_registry: Registry, settings: ProfileViewSettings) { + export function initialize_identity_profiles_view(container: JQuery, event_registry: Registry, settings: ProfileViewSettings) { /* profile list */ { const container_profiles = container.find(".container-profiles"); @@ -1007,7 +1165,7 @@ export namespace modal_settings { const overlay_timeout = container_profiles.find(".overlay-timeout"); const overlay_empty = container_profiles.find(".overlay-empty"); - const build_profile = (profile: events.modal.settings.ProfileInfo, selected: boolean) => { + const build_profile = (profile: ProfileInfoEvent, selected: boolean) => { let tag_avatar: JQuery, tag_default: JQuery; let tag = $.spawn("div").addClass("profile").attr("profile-id", profile.id).append( tag_avatar = $.spawn("div").addClass("container-avatar"), @@ -1693,7 +1851,7 @@ export namespace modal_settings { }); } - const create_standard_timeout = (event: keyof events.modal.settings.profiles, response_event: keyof events.modal.settings.profiles, key: string) => { + const create_standard_timeout = (event: keyof SettingProfileEvents, response_event: keyof SettingProfileEvents, key: string) => { const timeouts = {}; event_registry.on(event, event => { clearTimeout(timeouts[event[key]]); diff --git a/shared/js/ui/modal/channel-edit/Controller.ts b/shared/js/ui/modal/channel-edit/Controller.ts index dbe9d45f..402c87ca 100644 --- a/shared/js/ui/modal/channel-edit/Controller.ts +++ b/shared/js/ui/modal/channel-edit/Controller.ts @@ -10,8 +10,7 @@ import {Registry} from "tc-shared/events"; import {ChannelPropertyProviders} from "tc-shared/ui/modal/channel-edit/ControllerProperties"; import {LogCategory, logDebug, logError} from "tc-shared/log"; import {ChannelPropertyPermissionsProviders} from "tc-shared/ui/modal/channel-edit/ControllerPermissions"; -import {spawnReactModal} from "tc-shared/ui/react-elements/Modal"; -import {ChannelEditModal} from "tc-shared/ui/modal/channel-edit/Renderer"; +import {spawnModal} from "tc-shared/ui/react-elements/modal"; import {PermissionValue} from "tc-shared/permission/PermissionManager"; import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; import PermissionType from "tc-shared/permission/PermissionType"; @@ -25,10 +24,13 @@ export type ChannelEditChangedPermission = { permission: PermissionType, value: export const spawnChannelEditNew = (connection: ConnectionHandler, channel: ChannelEntry | undefined, parent: ChannelEntry | undefined, callback: ChannelEditCallback) => { const controller = new ChannelEditController(connection, channel, parent); - const modal = spawnReactModal(ChannelEditModal, controller.uiEvents, typeof channel !== "object"); + const modal = spawnModal("channel-edit", [controller.uiEvents.generateIpcDescription(), typeof channel !== "object"], { + popedOut: false, + popoutable: true + }); modal.show().then(undefined); - modal.events.on("destroy", () => { + modal.getEvents().on("destroy", () => { controller.destroy(); }); diff --git a/shared/js/ui/modal/channel-edit/ControllerProperties.ts b/shared/js/ui/modal/channel-edit/ControllerProperties.ts index 722904fe..a57ccf40 100644 --- a/shared/js/ui/modal/channel-edit/ControllerProperties.ts +++ b/shared/js/ui/modal/channel-edit/ControllerProperties.ts @@ -1,4 +1,4 @@ -import {ChannelEntry, ChannelProperties, ChannelSidebarMode} from "tc-shared/tree/Channel"; +import {ChannelEntry, ChannelNameParser, ChannelProperties, ChannelSidebarMode} from "tc-shared/tree/Channel"; import {ChannelEditableProperty} from "tc-shared/ui/modal/channel-edit/Definitions"; import {ChannelTree} from "tc-shared/tree/ChannelTree"; import {ServerFeature} from "tc-shared/connection/ServerFeatures"; @@ -17,7 +17,41 @@ const SimplePropertyProvider =

(channelProper }; } -ChannelPropertyProviders["name"] = SimplePropertyProvider("channel_name", ""); +ChannelPropertyProviders["name"] = { + provider: async (properties, channel, parentChannel, channelTree) => { + let spacerUniqueId = 0; + const hasParent = !!(channel?.hasParent() || parentChannel); + if(!hasParent) { + const channels = channelTree.rootChannel(); + while(true) { + let matchFound = false; + for(const channel of channels) { + if(channel.parsed_channel_name.uniqueId === spacerUniqueId.toString()) { + matchFound = true; + break; + } + } + + if(!matchFound) { + break; + } + + spacerUniqueId++; + } + } + + const parsed = new ChannelNameParser(properties.channel_name, hasParent); + return { + rawName: properties.channel_name, + spacerUniqueId: parsed.uniqueId || spacerUniqueId.toString(), + hasParent, + maxNameLength: 30 - (parsed.originalName.length - parsed.text.length), + parsedAlignment: parsed.alignment, + parsedName: parsed.text + } + }, + applier: (value, properties) => properties.channel_name = value.rawName +} ChannelPropertyProviders["phoneticName"] = SimplePropertyProvider("channel_name_phonetic", ""); ChannelPropertyProviders["icon"] = { provider: async (properties, _channel, _parentChannel, channelTree) => { diff --git a/shared/js/ui/modal/channel-edit/Definitions.ts b/shared/js/ui/modal/channel-edit/Definitions.ts index 6ae893f7..c0f12afa 100644 --- a/shared/js/ui/modal/channel-edit/Definitions.ts +++ b/shared/js/ui/modal/channel-edit/Definitions.ts @@ -32,7 +32,14 @@ export type ChannelEditPermissionsState = { }; export interface ChannelEditableProperty { - "name": string, + "name": { + rawName: string, + parsedName?: string, + parsedAlignment?: "center" | "right" | "left" | "normal" | "repetitive", + maxNameLength?: number, + hasParent?: boolean, + spacerUniqueId?: string + }, "phoneticName": string, "icon": { diff --git a/shared/js/ui/modal/channel-edit/Renderer.scss b/shared/js/ui/modal/channel-edit/Renderer.scss index bf348106..f96f7418 100644 --- a/shared/js/ui/modal/channel-edit/Renderer.scss +++ b/shared/js/ui/modal/channel-edit/Renderer.scss @@ -40,6 +40,26 @@ } } +.channelName { + display: flex; + flex-direction: row; + justify-content: stretch; + + width: 100%; + min-width: 10em; + + .select { + margin-right: 1em; + width: 10em; + } + + &.hasParent { + .select { + display: none; + } + } +} + .buttons { margin-top: 1em; diff --git a/shared/js/ui/modal/channel-edit/Renderer.tsx b/shared/js/ui/modal/channel-edit/Renderer.tsx index 32c7bcb9..761316d1 100644 --- a/shared/js/ui/modal/channel-edit/Renderer.tsx +++ b/shared/js/ui/modal/channel-edit/Renderer.tsx @@ -1,8 +1,7 @@ -import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller"; import * as React from "react"; import {useContext, useEffect, useRef, useState} from "react"; import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n"; -import {Registry} from "tc-shared/events"; +import {IpcRegistryDescription, Registry} from "tc-shared/events"; import { ChannelEditablePermissions, ChannelEditablePermissionValue, @@ -16,13 +15,15 @@ import {Switch} from "tc-shared/ui/react-elements/Switch"; import {Button} from "tc-shared/ui/react-elements/Button"; import {Tab, TabEntry} from "tc-shared/ui/react-elements/Tab"; import {Settings, settings} from "tc-shared/settings"; -import {useTr} from "tc-shared/ui/react-elements/Helper"; +import {joinClassList, useTr} from "tc-shared/ui/react-elements/Helper"; import {IconTooltip} from "tc-shared/ui/react-elements/Tooltip"; import {RadioButton} from "tc-shared/ui/react-elements/RadioButton"; import {Slider} from "tc-shared/ui/react-elements/Slider"; import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; import {RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon"; import {getIconManager} from "tc-shared/file/Icons"; +import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions"; +import {ChannelNameAlignment, ChannelNameParser} from "tc-shared/tree/Channel"; const cssStyle = require("./Renderer.scss"); @@ -121,6 +122,10 @@ function useValidationState(property: T return valid; } +const ChannelNameType = (props: { selected: ChannelNameAlignment }) => { + +} + const ChannelName = React.memo(() => { const modalType = useContext(ModalTypeContext); @@ -128,16 +133,73 @@ const ChannelName = React.memo(() => { const editable = usePropertyPermission("name", modalType === "channel-create"); const valid = useValidationState("name"); + const refSelect = useRef(); + + const setValue = (text: string | undefined, localOnly: boolean) => { + let rawName; + switch(propertyValue.hasParent ? "normal" : refSelect.current.value) { + case "center": + rawName = "[cspacer" + propertyValue.spacerUniqueId + "]" + text; + break; + + case "left": + rawName = "[lspacer" + propertyValue.spacerUniqueId + "]" + text; + break; + + case "right": + rawName = "[rspacer" + propertyValue.spacerUniqueId + "]" + text; + break; + + case "repetitive": + rawName ="[*spacer" + propertyValue.spacerUniqueId + "]" + text; + break; + + default: + case "normal": + rawName = text; + break; + } + + setPropertyValue({ + rawName, + parsedName: text, + + hasParent: propertyValue.hasParent, + spacerUniqueId: propertyValue.spacerUniqueId, + maxNameLength: propertyValue.maxNameLength, + parsedAlignment: propertyValue.parsedAlignment + }, localOnly); + } + return ( - setPropertyValue(value, true)} - onChange={value => setPropertyValue(value)} - isInvalid={!valid} - /> +

+ + setValue(value, true)} + onChange={value => setValue(value, false)} + isInvalid={!valid} + maxLength={propertyValue?.maxNameLength} + /> +
); }); @@ -159,7 +221,7 @@ const ChannelIcon = () => {
-
enabled && events.fire("action_icon_select")}>Edit icon
+
enabled && events.fire("action_icon_select")}>Edit icon
{ if(!enabled) { return; @@ -173,7 +235,7 @@ const ChannelIcon = () => { serverUniqueId: propertyValue.remoteIcon.serverUniqueId } }); - }}>Remove icon
+ }}>Remove icon
@@ -351,31 +413,31 @@ const SidebarType = React.memo(() => { ); }); -type SimpleCodecQualityTemplate = { id: string, codec: number, quality: number, name: string }; +type SimpleCodecQualityTemplate = { id: string, codec: number, quality: number, name: () => string }; const kCodecTemplates: SimpleCodecQualityTemplate[] = [ { id: "mobile", codec: 4, quality: 4, - name: useTr("Mobile") + name: () => useTr("Mobile") }, { id: "voice", codec: 4, quality: 6, - name: useTr("Voice") + name: () => useTr("Voice") }, { id: "music", codec: 5, quality: 6, - name: useTr("Music") + name: () => useTr("Music") }, { id: "loading", codec: undefined, quality: undefined, - name: useTr("loading") + name: () => useTr("loading") } ]; @@ -404,7 +466,7 @@ const SimpleCodecQuality = React.memo(() => { > { kCodecTemplates.map(template => ( - + )) } @@ -431,7 +493,7 @@ const AdvancedCodecPresets = React.memo(() => { disabled={!hasPermission(template) || propertyState !== "normal"} onChange={() => setPropertyValue({ quality: template.quality, type: template.codec})} > -
{template.name}
+
{template.name()}
)) } @@ -1138,13 +1200,13 @@ const Buttons = React.memo(() => { ) }); -export class ChannelEditModal extends InternalModal { +class ChannelEditModal extends AbstractModal { private readonly events: Registry; private readonly isChannelCreate: boolean; - constructor(events: Registry, isChannelCreate: boolean) { + constructor(events: IpcRegistryDescription, isChannelCreate: boolean) { super(); - this.events = events; + this.events = Registry.fromIpcDescription(events); this.isChannelCreate = isChannelCreate; } @@ -1162,7 +1224,7 @@ export class ChannelEditModal extends InternalModal { ); } - title(): string | React.ReactElement { + renderTitle(): string | React.ReactElement { if(this.isChannelCreate) { return Create channel; } else { @@ -1174,4 +1236,6 @@ export class ChannelEditModal extends InternalModal { color(): "none" | "blue" { return "blue"; } -} \ No newline at end of file +} + +export = ChannelEditModal; \ No newline at end of file diff --git a/shared/js/ui/modal/connect/Controller.ts b/shared/js/ui/modal/connect/Controller.ts index 1da7f9d1..45c2c08e 100644 --- a/shared/js/ui/modal/connect/Controller.ts +++ b/shared/js/ui/modal/connect/Controller.ts @@ -1,7 +1,8 @@ import {Registry} from "tc-shared/events"; -import {ConnectProperties, ConnectUiEvents, PropertyValidState} from "tc-shared/ui/modal/connect/Definitions"; -import {spawnReactModal} from "tc-shared/ui/react-elements/Modal"; -import {ConnectModal} from "tc-shared/ui/modal/connect/Renderer"; +import { + ConnectUiEvents, + ConnectUiVariables, +} from "tc-shared/ui/modal/connect/Definitions"; import {LogCategory, logError, logWarn} from "tc-shared/log"; import { availableConnectProfiles, @@ -17,7 +18,9 @@ import {server_connections} from "tc-shared/ConnectionManager"; import {parseServerAddress} from "tc-shared/tree/Server"; import {spawnSettingsModal} from "tc-shared/ui/modal/ModalSettings"; import * as ipRegex from "ip-regex"; -import _ = require("lodash"); +import {UiVariableProvider} from "tc-shared/ui/utils/Variable"; +import {createIpcUiVariableProvider} from "tc-shared/ui/utils/IpcVariable"; +import {spawnModal} from "tc-shared/ui/react-elements/modal"; const kRegexDomain = /^(localhost|((([a-zA-Z0-9_-]{0,63}\.){0,253})?[a-zA-Z0-9_-]{0,63}\.[a-zA-Z]{2,64}))$/i; @@ -37,19 +40,11 @@ export type ConnectParameters = { defaultChannelPassword?: string, } -type ValidityStates = {[T in keyof PropertyValidState]: boolean}; -const kDefaultValidityStates: ValidityStates = { - address: false, - nickname: false, - password: false, - profile: false -} - class ConnectController { readonly uiEvents: Registry; + readonly uiVariables: UiVariableProvider; private readonly defaultAddress: string; - private readonly propertyProvider: {[K in keyof ConnectProperties]?: () => Promise} = {}; private historyShown: boolean; @@ -59,32 +54,22 @@ class ConnectController { private currentPasswordHashed: boolean; private currentProfile: ConnectionProfile | undefined; - private addressChanged: boolean; - private nicknameChanged: boolean; - private selectedHistoryId: number; private history: ConnectionHistoryEntry[]; - private validStates: {[T in keyof PropertyValidState]: boolean} = { - address: false, - nickname: false, - password: false, - profile: false - }; + private validateNickname: boolean; + private validateAddress: boolean; - private validateStates: {[T in keyof PropertyValidState]: boolean} = { - profile: false, - password: false, - nickname: false, - address: false - }; - - constructor() { + constructor(uiVariables: UiVariableProvider) {7 this.uiEvents = new Registry(); this.uiEvents.enableDebug("modal-connect"); + this.uiVariables = uiVariables; this.history = undefined; + this.validateNickname = false; + this.validateAddress = false; + this.defaultAddress = "ts.teaspeak.de"; this.historyShown = settings.getValue(Settings.KEY_CONNECT_SHOW_HISTORY); @@ -92,35 +77,117 @@ class ConnectController { this.currentProfile = findConnectProfile(settings.getValue(Settings.KEY_CONNECT_PROFILE)) || defaultConnectProfile(); this.currentNickname = settings.getValue(Settings.KEY_CONNECT_USERNAME); - this.addressChanged = false; - this.nicknameChanged = false; + this.uiEvents.on("action_delete_history", event => { + connectionHistory.deleteConnectionAttempts(event.target, event.targetType).then(() => { + this.history = undefined; + this.uiVariables.sendVariable("history"); + }).catch(error => { + logWarn(LogCategory.GENERAL, tr("Failed to delete connection attempts: %o"), error); + }) + }); - this.propertyProvider["nickname"] = async () => ({ + this.uiEvents.on("action_manage_profiles", () => { + /* TODO: This is more a hack. Proper solution is that the connection profiles fire events if they've been changed... */ + const modal = spawnSettingsModal("identity-profiles"); + modal.close_listener.push(() => { + this.uiVariables.sendVariable("profiles", undefined); + }); + }); + + this.uiEvents.on("action_select_history", event => this.setSelectedHistoryId(event.id)); + + this.uiEvents.on("action_connect", () => { + this.validateNickname = true; + this.validateAddress = true; + this.updateValidityStates(); + }); + + this.uiVariables.setVariableProvider("server_address", () => ({ + currentAddress: this.currentAddress, + defaultAddress: this.defaultAddress + })); + + this.uiVariables.setVariableProvider("server_address_valid", () => { + if(this.validateAddress) { + const address = this.currentAddress || this.defaultAddress || ""; + const parsedAddress = parseServerAddress(address); + + if(parsedAddress) { + kRegexDomain.lastIndex = 0; + return kRegexDomain.test(parsedAddress.host) || ipRegex({ exact: true }).test(parsedAddress.host); + } else { + return false; + } + } else { + return true; + } + }); + + this.uiVariables.setVariableEditor("server_address", newValue => { + if(this.currentAddress === newValue.currentAddress) { + return false; + } + + this.setSelectedAddress(newValue.currentAddress, true, false); + return true; + }); + + this.uiVariables.setVariableProvider("nickname", () => ({ defaultNickname: this.currentProfile?.connectUsername(), currentNickname: this.currentNickname, + })); + + this.uiVariables.setVariableProvider("nickname_valid", () => { + if(this.validateNickname) { + const nickname = this.currentNickname || this.currentProfile?.connectUsername() || ""; + return nickname.length >= 3 && nickname.length <= 30; + } else { + return true; + } }); - this.propertyProvider["address"] = async () => ({ - currentAddress: this.currentAddress, - defaultAddress: this.defaultAddress, + this.uiVariables.setVariableEditor("nickname", newValue => { + if(this.currentNickname === newValue.currentNickname) { + return false; + } + + this.currentNickname = newValue.currentNickname; + settings.setValue(Settings.KEY_CONNECT_USERNAME, this.currentNickname); + + this.validateNickname = true; + this.uiVariables.sendVariable("nickname_valid"); + return true; }); - this.propertyProvider["password"] = async () => this.currentPassword ? ({ - hashed: this.currentPasswordHashed, - password: this.currentPassword - }) : undefined; + this.uiVariables.setVariableProvider("password", () => ({ + password: this.currentPassword, + hashed: this.currentPasswordHashed + })); - this.propertyProvider["profiles"] = async () => ({ - selected: this.currentProfile?.id, - profiles: availableConnectProfiles().map(profile => ({ - id: profile.id, - valid: profile.valid(), - name: profile.profileName - })) + this.uiVariables.setVariableEditor("password", newValue => { + if(this.currentPassword === newValue.password) { + return false; + } + + this.currentPassword = newValue.password; + this.currentPasswordHashed = newValue.hashed; + return true; }); - this.propertyProvider["historyShown"] = async () => this.historyShown; - this.propertyProvider["history"] = async () => { + this.uiVariables.setVariableProvider("profile_valid", () => !!this.currentProfile?.valid()); + + this.uiVariables.setVariableProvider("historyShown", () => this.historyShown); + this.uiVariables.setVariableEditor("historyShown", newValue => { + if(this.historyShown === newValue) { + return false; + } + + this.historyShown = newValue; + settings.setValue(Settings.KEY_CONNECT_SHOW_HISTORY, newValue); + return true; + }); + + this.uiVariables.setVariableProvider("history",async () => { if(!this.history) { this.history = await connectionHistory.lastConnectedServers(10); } @@ -133,130 +200,67 @@ class ConnectController { uniqueServerId: entry.serverUniqueId })) }; - }; + }); - this.uiEvents.on("query_property", event => this.sendProperty(event.property)); - this.uiEvents.on("query_property_valid", event => this.uiEvents.fire_react("notify_property_valid", { property: event.property, value: this.validStates[event.property] })); - this.uiEvents.on("query_history_connections", event => { - connectionHistory.countConnectCount(event.target, event.targetType).catch(async error => { - logError(LogCategory.GENERAL, tr("Failed to query the connect count for %s (%s): %o"), event.target, event.targetType, error); + this.uiVariables.setVariableProvider("history_entry", async customData => { + const info = await connectionHistory.queryServerInfo(customData.serverUniqueId); + return { + icon: { + iconId: info.iconId, + serverUniqueId: customData.serverUniqueId, + handlerId: undefined + }, + name: info.name, + password: info.passwordProtected, + country: info.country, + clients: info.clientsOnline, + maxClients: info.clientsMax + }; + }); + + this.uiVariables.setVariableProvider("history_connections", async customData => { + return await connectionHistory.countConnectCount(customData.target, customData.targetType).catch(async error => { + logError(LogCategory.GENERAL, tr("Failed to query the connect count for %s (%s): %o"), customData.target, customData.targetType, error); return -1; - }).then(count => { - this.uiEvents.fire_react("notify_history_connections", { - target: event.target, - targetType: event.targetType, - value: count - }); }); - }); - this.uiEvents.on("query_history_entry", event => { - connectionHistory.queryServerInfo(event.serverUniqueId).then(info => { - this.uiEvents.fire_react("notify_history_entry", { - serverUniqueId: event.serverUniqueId, - info: { - icon: { - iconId: info.iconId, - serverUniqueId: event.serverUniqueId, - handlerId: undefined - }, - name: info.name, - password: info.passwordProtected, - country: info.country, - clients: info.clientsOnline, - maxClients: info.clientsMax - } - }); - }).catch(async error => { - logError(LogCategory.GENERAL, tr("Failed to query the history server info for %s: %o"), event.serverUniqueId, error); - }); - }); + }) - this.uiEvents.on("action_toggle_history", event => { - if(this.historyShown === event.enabled) { - return; - } + this.uiVariables.setVariableProvider("profiles", () => ({ + selected: this.currentProfile?.id, + profiles: availableConnectProfiles().map(profile => ({ + id: profile.id, + valid: profile.valid(), + name: profile.profileName + })) + })); - this.historyShown = event.enabled; - this.sendProperty("historyShown").then(undefined); - settings.setValue(Settings.KEY_CONNECT_SHOW_HISTORY, event.enabled); - }); - - - this.uiEvents.on("action_delete_history", event => { - connectionHistory.deleteConnectionAttempts(event.target, event.targetType).then(() => { - this.history = undefined; - this.sendProperty("history").then(undefined); - }).catch(error => { - logWarn(LogCategory.GENERAL, tr("Failed to delete connection attempts: %o"), error); - }) - }); - - this.uiEvents.on("action_manage_profiles", () => { - /* TODO: This is more a hack. Proper solution is that the connection profiles fire events if they've been changed... */ - const modal = spawnSettingsModal("identity-profiles"); - modal.close_listener.push(() => { - this.sendProperty("profiles").then(undefined); - }); - }); - - this.uiEvents.on("action_select_profile", event => { - const profile = findConnectProfile(event.id); + this.uiVariables.setVariableEditor("profiles", newValue => { + const profile = findConnectProfile(newValue.selected); if(!profile) { createErrorModal(tr("Invalid profile"), tr("Target connect profile is missing.")).open(); - return; + return false; } this.setSelectedProfile(profile); + return; /* No need to update anything. The ui should received the values needed already */ }); - - this.uiEvents.on("action_set_address", event => this.setSelectedAddress(event.address, event.validate, event.updateUi)); - - this.uiEvents.on("action_set_nickname", event => { - if(this.currentNickname !== event.nickname) { - this.currentNickname = event.nickname; - settings.setValue(Settings.KEY_CONNECT_USERNAME, event.nickname); - - if(event.updateUi) { - this.sendProperty("nickname").then(undefined); - } - } - - this.validateStates["nickname"] = event.validate; - this.updateValidityStates(); - }); - - this.uiEvents.on("action_set_password", event => { - if(this.currentPassword === event.password) { - return; - } - - this.currentPassword = event.password; - this.currentPasswordHashed = event.hashed; - if(event.updateUi) { - this.sendProperty("password").then(undefined); - } - - this.validateStates["password"] = true; - this.updateValidityStates(); - }); - - this.uiEvents.on("action_select_history", event => this.setSelectedHistoryId(event.id)); - - this.uiEvents.on("action_connect", () => { - Object.keys(this.validateStates).forEach(key => this.validateStates[key] = true); - this.updateValidityStates(); - }); - - this.updateValidityStates(); } destroy() { - Object.keys(this.propertyProvider).forEach(key => delete this.propertyProvider[key]); this.uiEvents.destroy(); + this.uiVariables.destroy(); } generateConnectParameters() : ConnectParameters | undefined { - if(Object.keys(this.validStates).findIndex(key => this.validStates[key] === false) !== -1) { + if(!this.uiVariables.getVariableSync("nickname_valid", undefined, true)) { + return undefined; + } + + if(!this.uiVariables.getVariableSync("server_address_valid", undefined, true)) { + return undefined; + } + + if(!this.uiVariables.getVariableSync("profile_valid", undefined, true)) { return undefined; } @@ -279,7 +283,7 @@ class ConnectController { } this.selectedHistoryId = id; - this.sendProperty("history").then(undefined); + this.uiVariables.sendVariable("history"); const historyEntry = this.history?.find(entry => entry.id === id); if(!historyEntry) { return; } @@ -289,9 +293,9 @@ class ConnectController { this.currentPassword = historyEntry.hashedPassword; this.currentPasswordHashed = true; - this.sendProperty("address").then(undefined); - this.sendProperty("password").then(undefined); - this.sendProperty("nickname").then(undefined); + this.uiVariables.sendVariable("server_address"); + this.uiVariables.sendVariable("password"); + this.uiVariables.sendVariable("nickname"); } setSelectedAddress(address: string | undefined, validate: boolean, updateUi: boolean) { @@ -301,12 +305,12 @@ class ConnectController { this.setSelectedHistoryId(-1); if(updateUi) { - this.sendProperty("address").then(undefined); + this.uiVariables.sendVariable("server_address"); } } - this.validateStates["address"] = validate; - this.updateValidityStates(); + this.validateAddress = true; + this.uiVariables.sendVariable("server_address_valid"); } setSelectedProfile(profile: ConnectionProfile | undefined) { @@ -315,62 +319,19 @@ class ConnectController { } this.currentProfile = profile; - this.sendProperty("profiles").then(undefined); + this.uiVariables.sendVariable("profile_valid"); + this.uiVariables.sendVariable("profiles"); settings.setValue(Settings.KEY_CONNECT_PROFILE, profile.id); /* Clear out the nickname on profile switch and use the default nickname */ - this.uiEvents.fire("action_set_nickname", { nickname: undefined, validate: true, updateUi: true }); - - this.validateStates["profile"] = true; - this.updateValidityStates(); + this.currentNickname = undefined; + this.uiVariables.sendVariable("nickname"); } private updateValidityStates() { - const newStates = Object.assign({}, kDefaultValidityStates); - if(this.validateStates["nickname"]) { - const nickname = this.currentNickname || this.currentProfile?.connectUsername() || ""; - newStates["nickname"] = nickname.length >= 3 && nickname.length <= 30; - } else { - newStates["nickname"] = true; - } - - if(this.validateStates["address"]) { - const address = this.currentAddress || this.defaultAddress || ""; - const parsedAddress = parseServerAddress(address); - - if(parsedAddress) { - kRegexDomain.lastIndex = 0; - newStates["address"] = kRegexDomain.test(parsedAddress.host) || ipRegex({ exact: true }).test(parsedAddress.host); - } else { - newStates["address"] = false; - } - } else { - newStates["address"] = true; - } - - newStates["profile"] = !!this.currentProfile?.valid(); - newStates["password"] = true; - - for(const key of Object.keys(newStates)) { - if(_.isEqual(this.validStates[key], newStates[key])) { - continue; - } - - this.validStates[key] = newStates[key]; - this.uiEvents.fire_react("notify_property_valid", { property: key as any, value: this.validStates[key] }); - } - } - - private async sendProperty(property: keyof ConnectProperties) { - if(!this.propertyProvider[property]) { - logWarn(LogCategory.GENERAL, tr("Tried to send a property where we don't have a provider for")); - return; - } - - this.uiEvents.fire_react("notify_property", { - property: property, - value: await this.propertyProvider[property]() - }); + this.uiVariables.sendVariable("server_address_valid"); + this.uiVariables.sendVariable("nickname_valid"); + this.uiVariables.sendVariable("profile_valid"); } } @@ -382,7 +343,8 @@ export type ConnectModalOptions = { } export function spawnConnectModalNew(options: ConnectModalOptions) { - const controller = new ConnectController(); + const variableProvider = createIpcUiVariableProvider(); + const controller = new ConnectController(variableProvider); if(typeof options.selectedAddress === "string") { controller.setSelectedAddress(options.selectedAddress, false, true); @@ -392,10 +354,9 @@ export function spawnConnectModalNew(options: ConnectModalOptions) { controller.setSelectedProfile(options.selectedProfile); } - const modal = spawnReactModal(ConnectModal, controller.uiEvents, options.connectInANewTab || false); + const modal = spawnModal("modal-connect", [controller.uiEvents.generateIpcDescription(), variableProvider.generateConsumerDescription(), options.connectInANewTab || false]); modal.show(); - - modal.events.one("destroy", () => { + modal.getEvents().one("destroy", () => { controller.destroy(); }); diff --git a/shared/js/ui/modal/connect/Definitions.ts b/shared/js/ui/modal/connect/Definitions.ts index 31ec994d..41907faa 100644 --- a/shared/js/ui/modal/connect/Definitions.ts +++ b/shared/js/ui/modal/connect/Definitions.ts @@ -1,6 +1,6 @@ -import {kUnknownHistoryServerUniqueId} from "tc-shared/connectionlog/History"; import {RemoteIconInfo} from "tc-shared/file/Icons"; +export const kUnknownHistoryServerUniqueId = "unknown"; export type ConnectProfileEntry = { id: string, name: string, @@ -23,81 +23,46 @@ export type ConnectHistoryServerInfo = { maxClients: number | -1 } -export interface ConnectProperties { - address: { +export interface ConnectUiVariables { + "server_address": { currentAddress: string, - defaultAddress: string, + defaultAddress?: string, }, - nickname: { + readonly "server_address_valid": boolean, + + "nickname": { currentNickname: string | undefined, - defaultNickname: string | undefined, + defaultNickname?: string, }, - password: { + readonly "nickname_valid": boolean, + + "password": { password: string, hashed: boolean } | undefined, - profiles: { - profiles: ConnectProfileEntry[], + + "profiles": { + profiles?: ConnectProfileEntry[], selected: string }, - historyShown: boolean, - history: { + readonly "profile_valid": boolean, + + "historyShown": boolean, + readonly "history": { history: ConnectHistoryEntry[], selected: number | -1, }, -} -export interface PropertyValidState { - address: boolean, - nickname: boolean, - password: boolean, - profile: boolean + readonly "history_entry": ConnectHistoryServerInfo, + readonly "history_connections": number } -type IAccess = { - property: T, - value: I[T] -}; - export interface ConnectUiEvents { action_manage_profiles: {}, - action_select_profile: { id: string }, action_select_history: { id: number }, action_connect: { newTab: boolean }, - action_toggle_history: { enabled: boolean } action_delete_history: { target: string, targetType: "address" | "server-unique-id" }, - action_set_nickname: { nickname: string, validate: boolean, updateUi: boolean }, - action_set_address: { address: string, validate: boolean, updateUi: boolean }, - action_set_password: { password: string, hashed: boolean, updateUi: boolean }, - - query_property: { - property: keyof ConnectProperties - }, - query_property_valid: { - property: keyof PropertyValidState - }, - - notify_property: IAccess, - notify_property_valid: IAccess, - - query_history_entry: { - serverUniqueId: string - }, - query_history_connections: { - target: string, - targetType: "address" | "server-unique-id" - } - - notify_history_entry: { - serverUniqueId: string, - info: ConnectHistoryServerInfo - }, - notify_history_connections: { - target: string, - targetType: "address" | "server-unique-id", - value: number - } } \ No newline at end of file diff --git a/shared/js/ui/modal/connect/Renderer.scss b/shared/js/ui/modal/connect/Renderer.scss index 08a13d2b..f92f4614 100644 --- a/shared/js/ui/modal/connect/Renderer.scss +++ b/shared/js/ui/modal/connect/Renderer.scss @@ -106,7 +106,8 @@ .buttonShowHistory { .containerText { display: inline-block; - width: 10em; + min-width: 10em; + flex-shrink: 0; } .containerArrow { @@ -239,6 +240,8 @@ max-width: 100%; text-overflow: ellipsis; overflow: hidden; + + @include text-dotdotdot(); } } diff --git a/shared/js/ui/modal/connect/Renderer.tsx b/shared/js/ui/modal/connect/Renderer.tsx index cf74ca7d..0eac8c5e 100644 --- a/shared/js/ui/modal/connect/Renderer.tsx +++ b/shared/js/ui/modal/connect/Renderer.tsx @@ -1,141 +1,109 @@ import { ConnectHistoryEntry, - ConnectHistoryServerInfo, - ConnectProperties, - ConnectUiEvents, - PropertyValidState + ConnectUiEvents, ConnectUiVariables, kUnknownHistoryServerUniqueId, } from "tc-shared/ui/modal/connect/Definitions"; import * as React from "react"; -import {useContext, useState} from "react"; -import {Registry} from "tc-shared/events"; -import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller"; -import {Translatable} from "tc-shared/ui/react-elements/i18n"; -import {ControlledFlatInputField, ControlledSelect, FlatInputField} from "tc-shared/ui/react-elements/InputField"; +import {useContext} from "react"; +import {IpcRegistryDescription, Registry} from "tc-shared/events"; import {joinClassList, useTr} from "tc-shared/ui/react-elements/Helper"; +import {Translatable} from "tc-shared/ui/react-elements/i18n"; import {Button} from "tc-shared/ui/react-elements/Button"; -import {kUnknownHistoryServerUniqueId} from "tc-shared/connectionlog/History"; +import {ControlledFlatInputField, ControlledSelect, FlatInputField} from "tc-shared/ui/react-elements/InputField"; import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons"; import {ClientIcon} from "svg-sprites/client-icons"; import * as i18n from "../../../i18n/country"; import {getIconManager} from "tc-shared/file/Icons"; import {RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon"; +import {UiVariableConsumer} from "tc-shared/ui/utils/Variable"; +import {createIpcUiVariableConsumer, IpcVariableDescriptor} from "tc-shared/ui/utils/IpcVariable"; +import {AbstractModal} from "tc-shared/ui/react-elements/ModalDefinitions"; const EventContext = React.createContext>(undefined); +const VariablesContext = React.createContext>(undefined); + const ConnectDefaultNewTabContext = React.createContext(false); const cssStyle = require("./Renderer.scss"); -function useProperty(key: T, defaultValue: V) : [ConnectProperties[T] | V, (value: ConnectProperties[T]) => void] { +const InputServerAddress = React.memo(() => { const events = useContext(EventContext); - const [ value, setValue ] = useState(() => { - events.fire("query_property", { property: key }); - return defaultValue; - }); - events.reactUse("notify_property", event => event.property === key && setValue(event.value as any)); - - return [value, setValue]; -} - -function usePropertyValid(key: T, defaultValue: PropertyValidState[T]) : PropertyValidState[T] { - const events = useContext(EventContext); - const [ value, setValue ] = useState(() => { - events.fire("query_property_valid", { property: key }); - return defaultValue; - }); - events.reactUse("notify_property_valid", event => event.property === key && setValue(event.value as any)); - - return value; -} - -const InputServerAddress = () => { - const events = useContext(EventContext); - const [address, setAddress] = useProperty("address", undefined); - const valid = usePropertyValid("address", true); const newTab = useContext(ConnectDefaultNewTabContext); + const variables = useContext(VariablesContext); + const address = variables.useVariable("server_address"); + const addressValid = variables.useReadOnly("server_address_valid", undefined, true) || address.localValue !== address.remoteValue; + return ( Server address} labelType={"static"} - invalid={valid ? undefined : Please enter a valid server address} + invalid={addressValid ? undefined : Please enter a valid server address} + editable={address.status === "loaded"} - onInput={value => { - setAddress({ currentAddress: value, defaultAddress: address.defaultAddress }); - events.fire("action_set_address", { address: value, validate: true, updateUi: false }); - }} - onBlur={() => { - events.fire("action_set_address", { address: address?.currentAddress, validate: true, updateUi: true }); - }} + onInput={value => address.setValue({ currentAddress: value }, true)} + onBlur={() => address.setValue({ currentAddress: address.localValue?.currentAddress })} onEnter={() => { /* Setting the address just to ensure */ - events.fire("action_set_address", { address: address?.currentAddress, validate: true, updateUi: true }); + address.setValue({ currentAddress: address.localValue?.currentAddress }); events.fire("action_connect", { newTab }); }} /> ) -} +}); const InputServerPassword = () => { - const events = useContext(EventContext); - const [password, setPassword] = useProperty("password", undefined); + const variables = useContext(VariablesContext); + const password = variables.useVariable("password"); return ( Server password} - labelType={password?.hashed ? "static" : "floating"} - onInput={value => { - setPassword({ password: value, hashed: false }); - events.fire("action_set_password", { password: value, hashed: false, updateUi: false }); - }} - onBlur={() => { - if(password) { - events.fire("action_set_password", { password: password.password, hashed: password.hashed, updateUi: true }); - } - }} + labelType={password.localValue?.hashed ? "static" : "floating"} + onInput={value => password.setValue({ password: value, hashed: false }, true)} + onBlur={() => password.setValue(password.localValue)} /> ) } const InputNickname = () => { - const events = useContext(EventContext); - const [nickname, setNickname] = useProperty("nickname", undefined); - const valid = usePropertyValid("nickname", true); + const variables = useContext(VariablesContext); + + const nickname = variables.useVariable("nickname"); + const validState = variables.useReadOnly("nickname_valid", undefined, true) || nickname.localValue !== nickname.remoteValue; return ( Nickname} labelType={"static"} - invalid={valid ? undefined : Nickname too short or too long} - onInput={value => { - setNickname({ currentNickname: value, defaultNickname: nickname.defaultNickname }); - events.fire("action_set_nickname", { nickname: value, validate: true, updateUi: false }); - }} - onBlur={() => events.fire("action_set_nickname", { nickname: nickname?.currentNickname, validate: true, updateUi: true })} + invalid={validState ? undefined : Nickname too short or too long} + onInput={value => nickname.setValue({ currentNickname: value }, true)} + onBlur={() => nickname.setValue({ currentNickname: nickname.localValue?.currentNickname })} /> ); } const InputProfile = () => { const events = useContext(EventContext); - const [profiles] = useProperty("profiles", undefined); - const selectedProfile = profiles?.profiles.find(profile => profile.id === profiles?.selected); + const variables = useContext(VariablesContext); + const profiles = variables.useVariable("profiles"); + const selectedProfile = profiles.remoteValue?.profiles.find(profile => profile.id === profiles.remoteValue?.selected); let invalidMarker; if(profiles) { - if(!profiles.selected) { + if(!profiles.remoteValue?.selected) { /* We have to select a profile. */ /* TODO: Only show if we've tried to press connect */ //invalidMarker = Please select a profile; @@ -150,19 +118,23 @@ const InputProfile = () => {
Connect profile} invalid={invalidMarker} invalidClassName={cssStyle.invalidFeedback} - onChange={event => events.fire("action_select_profile", { id: event.target.value })} + onChange={event => profiles.setValue({ selected: event.target.value })} > - - - - {profiles?.profiles.map(profile => ( - - ))} + + + + + { + profiles.remoteValue?.profiles.map(profile => ( + + )) + } + @@ -281,35 +254,24 @@ const HistoryTableEntryConnectCount = React.memo((props: { entry: ConnectHistory const targetType = props.entry.uniqueServerId === kUnknownHistoryServerUniqueId ? "address" : "server-unique-id"; const target = targetType === "address" ? props.entry.targetAddress : props.entry.uniqueServerId; - const events = useContext(EventContext); - const [ amount, setAmount ] = useState(() => { - events.fire("query_history_connections", { - target, - targetType - }); - return -1; - }); + const value = useContext(VariablesContext).useReadOnly("history_connections", { + target, + targetType + }, -1); - events.reactUse("notify_history_connections", event => event.targetType === targetType && event.target === target && setAmount(event.value)); - - if(amount >= 0) { - return {amount}; + if(value >= 0) { + return {value}; } else { return null; } }); const HistoryTableEntry = React.memo((props: { entry: ConnectHistoryEntry, selected: boolean }) => { - const connectNewTab = useContext(ConnectDefaultNewTabContext); const events = useContext(EventContext); - const [ info, setInfo ] = useState(() => { - if(props.entry.uniqueServerId !== kUnknownHistoryServerUniqueId) { - events.fire("query_history_entry", { serverUniqueId: props.entry.uniqueServerId }); - } - return undefined; - }); - events.reactUse("notify_history_entry", event => event.serverUniqueId === props.entry.uniqueServerId && setInfo(event.info)); + const connectNewTab = useContext(ConnectDefaultNewTabContext); + const variables = useContext(VariablesContext); + const info = variables.useReadOnly("history_entry", { serverUniqueId: props.entry.uniqueServerId }, undefined); const icon = getIconManager().resolveIcon(info ? info.icon.iconId : 0, info?.icon.serverUniqueId, info?.icon.handlerId); return ( @@ -364,9 +326,9 @@ const HistoryTableEntry = React.memo((props: { entry: ConnectHistoryEntry, selec }); const HistoryTable = () => { - const [history] = useProperty("history", undefined); - let body; + const history = useContext(VariablesContext).useReadOnly("history", undefined, undefined); + let body; if(history) { if(history.history.length > 0) { body = history.history.map(entry => ); @@ -385,22 +347,22 @@ const HistoryTable = () => {
- Name + Name
- Address + Address
- Password + Password
- Country + Country
- Clients + Clients
- Connections + Connections
@@ -411,7 +373,8 @@ const HistoryTable = () => { } const HistoryContainer = () => { - const historyShown = useProperty("historyShown", false); + const variables = useContext(VariablesContext); + const historyShown = variables.useReadOnly("historyShown", undefined, false); return (
@@ -420,32 +383,42 @@ const HistoryContainer = () => { ) } -export class ConnectModal extends InternalModal { +class ConnectModal extends AbstractModal { private readonly events: Registry; + private readonly variables: UiVariableConsumer; private readonly connectNewTabByDefault: boolean; - constructor(events: Registry, connectNewTabByDefault: boolean) { + constructor(events: IpcRegistryDescription, variables: IpcVariableDescriptor, connectNewTabByDefault: boolean) { super(); - this.events = events; + this.variables = createIpcUiVariableConsumer(variables); + this.events = Registry.fromIpcDescription(events); this.connectNewTabByDefault = connectNewTabByDefault; } + protected onDestroy() { + super.onDestroy(); + + this.variables.destroy(); + } + renderBody(): React.ReactElement { return ( - -
- - - -
-
+ + +
+ + + +
+
+
); } - title(): string | React.ReactElement { + renderTitle(): string | React.ReactElement { return Connect to a server; } @@ -456,4 +429,5 @@ export class ConnectModal extends InternalModal { verticalAlignment(): "top" | "center" | "bottom" { return "top"; } -} \ No newline at end of file +} +export = ConnectModal; \ No newline at end of file diff --git a/shared/js/ui/modal/css-editor/Controller.ts b/shared/js/ui/modal/css-editor/Controller.ts index 5a1654b8..c1a6e583 100644 --- a/shared/js/ui/modal/css-editor/Controller.ts +++ b/shared/js/ui/modal/css-editor/Controller.ts @@ -1,10 +1,10 @@ import * as loader from "tc-loader"; import {Stage} from "tc-loader"; import {CssEditorEvents, CssVariable} from "../../../ui/modal/css-editor/Definitions"; -import {spawnExternalModal} from "../../../ui/react-elements/external-modal"; import {Registry} from "../../../events"; import {LogCategory, logWarn} from "../../../log"; import {tr} from "tc-shared/i18n/localize"; +import {spawnModal} from "tc-shared/ui/react-elements/modal"; interface CustomVariable { name: string; @@ -172,7 +172,7 @@ export function spawnModalCssVariableEditor() { const events = new Registry(); cssVariableEditorController(events); - const modal = spawnExternalModal("css-editor", { default: events }, {}); + const modal = spawnModal("css-editor", [ events.generateIpcDescription() ], { popedOut: true }); modal.show(); } diff --git a/shared/js/ui/modal/css-editor/Definitions.ts b/shared/js/ui/modal/css-editor/Definitions.ts index 10866edf..859e5bd2 100644 --- a/shared/js/ui/modal/css-editor/Definitions.ts +++ b/shared/js/ui/modal/css-editor/Definitions.ts @@ -6,10 +6,6 @@ export interface CssVariable { customValue?: string; } -export interface CssEditorUserData { - -} - export interface CssEditorEvents { action_set_filter: { filter: string | undefined }, action_select_entry: { variable: CssVariable }, diff --git a/shared/js/ui/modal/css-editor/Renderer.tsx b/shared/js/ui/modal/css-editor/Renderer.tsx index 0156e42e..ba860580 100644 --- a/shared/js/ui/modal/css-editor/Renderer.tsx +++ b/shared/js/ui/modal/css-editor/Renderer.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import {useState} from "react"; -import {CssEditorEvents, CssEditorUserData, CssVariable} from "tc-shared/ui/modal/css-editor/Definitions"; -import {Registry, RegistryMap} from "tc-shared/events"; +import {CssEditorEvents, CssVariable} from "tc-shared/ui/modal/css-editor/Definitions"; +import {IpcRegistryDescription, Registry} from "tc-shared/events"; import {Translatable} from "tc-shared/ui/react-elements/i18n"; import {BoxedInputField, FlatInputField} from "tc-shared/ui/react-elements/InputField"; import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; @@ -391,14 +391,11 @@ const requestFileAsText = async (): Promise => { class PopoutConversationUI extends AbstractModal { private readonly events: Registry; - private readonly userData: CssEditorUserData; - constructor(registryMap: RegistryMap, userData: CssEditorUserData) { + constructor(events: IpcRegistryDescription) { super(); - this.userData = userData; - this.events = registryMap["default"] as any; - + this.events = Registry.fromIpcDescription(events); this.events.on("notify_export_result", event => { createInfoModal(tr("Config exported successfully"), tr("The config has been exported successfully.")).open(); downloadTextAsFile(event.config, "teaweb-style.json"); @@ -420,8 +417,8 @@ class PopoutConversationUI extends AbstractModal { ); } - title() { - return "CSS Variable editor"; + renderTitle() { + return "CSS Variable editor"; } } diff --git a/shared/js/ui/modal/echo-test/Controller.tsx b/shared/js/ui/modal/echo-test/Controller.tsx index e9bb5a2c..faeeb904 100644 --- a/shared/js/ui/modal/echo-test/Controller.tsx +++ b/shared/js/ui/modal/echo-test/Controller.tsx @@ -1,8 +1,5 @@ -import {spawnReactModal} from "tc-shared/ui/react-elements/Modal"; -import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller"; +import {spawnModal} from "tc-shared/ui/react-elements/modal"; import * as React from "react"; -import {Translatable} from "tc-shared/ui/react-elements/i18n"; -import {EchoTestEventRegistry, EchoTestModal} from "tc-shared/ui/modal/echo-test/Renderer"; import {Registry} from "tc-shared/events"; import {EchoTestEvents, TestState} from "tc-shared/ui/modal/echo-test/Definitions"; import {ConnectionHandler} from "tc-shared/ConnectionHandler"; @@ -18,30 +15,13 @@ export function spawnEchoTestModal(connection: ConnectionHandler) { initializeController(connection, events); - const modal = spawnReactModal(class extends InternalModal { - constructor() { - super(); - } - - renderBody(): React.ReactElement { - return ( - - - - ); - } - - title(): string | React.ReactElement { - return Voice echo test; - } - }); - + const modal = spawnModal("echo-test", [ events.generateIpcDescription() ], { popedOut: false }); events.on("action_close", () => { modal.destroy(); }); - modal.events.on("close", () => events.fire_react("notify_close")); - modal.events.on("destroy", () => { + modal.getEvents().on("close", () => events.fire_react("notify_close")); + modal.getEvents().on("destroy", () => { events.fire("notify_destroy"); events.destroy(); }); diff --git a/shared/js/ui/modal/echo-test/Renderer.tsx b/shared/js/ui/modal/echo-test/Renderer.tsx index febcfd94..6ed9af4c 100644 --- a/shared/js/ui/modal/echo-test/Renderer.tsx +++ b/shared/js/ui/modal/echo-test/Renderer.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import {useContext, useState} from "react"; -import {Registry} from "tc-shared/events"; +import {IpcRegistryDescription, Registry} from "tc-shared/events"; import {EchoTestEvents, TestState, VoiceConnectionState} from "./Definitions"; import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n"; import {ClientIcon} from "svg-sprites/client-icons"; @@ -8,10 +8,11 @@ import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons"; import {Checkbox} from "tc-shared/ui/react-elements/Checkbox"; import {Button} from "tc-shared/ui/react-elements/Button"; import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; +import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions"; const cssStyle = require("./Renderer.scss"); -export const EchoTestEventRegistry = React.createContext>(undefined); +const EchoTestEventRegistry = React.createContext>(undefined); const VoiceStateOverlay = () => { const events = useContext(EchoTestEventRegistry); @@ -235,7 +236,7 @@ const TroubleshootingSoundOverlay = () => { ) } -export const TestToggle = () => { +const TestToggle = () => { const events = useContext(EchoTestEventRegistry); const [state, setState] = useState<"loading" | boolean>(() => { @@ -255,7 +256,7 @@ export const TestToggle = () => { ) } -export const EchoTestModal = () => { +const EchoTestModalRenderer = () => { const events = useContext(EchoTestEventRegistry); return ( @@ -290,4 +291,28 @@ export const EchoTestModal = () => {
); -}; \ No newline at end of file +}; + +class ModalEchoTest extends AbstractModal { + private readonly events: Registry; + + constructor(events: IpcRegistryDescription) { + super(); + + this.events = Registry.fromIpcDescription(events); + } + + renderBody(): React.ReactElement { + return ( + + + + ); + } + + renderTitle(): string | React.ReactElement { + return Voice echo test; + } +} + +export = ModalEchoTest; \ No newline at end of file diff --git a/shared/js/ui/modal/global-settings-editor/Controller.tsx b/shared/js/ui/modal/global-settings-editor/Controller.tsx index 98d872db..7e971c67 100644 --- a/shared/js/ui/modal/global-settings-editor/Controller.tsx +++ b/shared/js/ui/modal/global-settings-editor/Controller.tsx @@ -1,5 +1,4 @@ -import {spawnReactModal} from "tc-shared/ui/react-elements/Modal"; -import {ModalGlobalSettingsEditor} from "tc-shared/ui/modal/global-settings-editor/Renderer"; +import {spawnModal} from "tc-shared/ui/react-elements/modal"; import {Registry} from "tc-shared/events"; import {ModalGlobalSettingsEditorEvents, Setting} from "tc-shared/ui/modal/global-settings-editor/Definitions"; import {RegistryKey, RegistryValueType, Settings, settings} from "tc-shared/settings"; @@ -8,9 +7,9 @@ export function spawnGlobalSettingsEditor() { const events = new Registry(); initializeController(events); - const modal = spawnReactModal(ModalGlobalSettingsEditor, events); + const modal = spawnModal("global-settings-editor", [ events.generateIpcDescription() ], { popoutable: true, popedOut: false }); modal.show(); - modal.events.on("destroy", () => { + modal.getEvents().on("destroy", () => { events.fire("notify_destroy"); events.destroy(); }); diff --git a/shared/js/ui/modal/global-settings-editor/Renderer.tsx b/shared/js/ui/modal/global-settings-editor/Renderer.tsx index d2de2032..285c57ec 100644 --- a/shared/js/ui/modal/global-settings-editor/Renderer.tsx +++ b/shared/js/ui/modal/global-settings-editor/Renderer.tsx @@ -1,11 +1,11 @@ import {Translatable} from "tc-shared/ui/react-elements/i18n"; import * as React from "react"; import {createContext, useContext, useRef, useState} from "react"; -import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller"; -import {Registry} from "tc-shared/events"; +import {IpcRegistryDescription, Registry} from "tc-shared/events"; import {ModalGlobalSettingsEditorEvents, Setting} from "tc-shared/ui/modal/global-settings-editor/Definitions"; import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; import {FlatInputField} from "tc-shared/ui/react-elements/InputField"; +import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions"; const ModalEvents = createContext>(undefined); const cssStyle = require("./Renderer.scss"); @@ -159,13 +159,13 @@ const SettingList = () => { ); } -export class ModalGlobalSettingsEditor extends InternalModal { +class ModalGlobalSettingsEditor extends AbstractModal { protected readonly events: Registry; - constructor(events: Registry) { + constructor(events: IpcRegistryDescription) { super(); - this.events = events; + this.events = Registry.fromIpcDescription(events); } renderBody(): React.ReactElement { @@ -189,8 +189,9 @@ export class ModalGlobalSettingsEditor extends InternalModal { ); } - title(): string | React.ReactElement { + renderTitle(): string | React.ReactElement { return Global settings registry; } } +export = ModalGlobalSettingsEditor; \ No newline at end of file diff --git a/shared/js/ui/modal/permission/ModalPermissionEditor.scss b/shared/js/ui/modal/permission/ModalPermissionEditor.scss index 6813e676..a85ce63b 100644 --- a/shared/js/ui/modal/permission/ModalPermissionEditor.scss +++ b/shared/js/ui/modal/permission/ModalPermissionEditor.scss @@ -54,10 +54,11 @@ html:root { min-width: 20em; max-width: 100%; + min-height: 20em; + flex-shrink: 1; flex-grow: 1; - .contextContainer { display: flex; flex-direction: column; diff --git a/shared/js/ui/modal/permission/ModalPermissionEditor.tsx b/shared/js/ui/modal/permission/ModalPermissionEditor.tsx index e08c5588..6a06f6a6 100644 --- a/shared/js/ui/modal/permission/ModalPermissionEditor.tsx +++ b/shared/js/ui/modal/permission/ModalPermissionEditor.tsx @@ -1,4 +1,4 @@ -import {spawnReactModal} from "tc-shared/ui/react-elements/Modal"; +import {spawnReactModal} from "tc-shared/ui/react-elements/modal"; import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import * as React from "react"; import {useState} from "react"; @@ -34,6 +34,7 @@ import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controll import {ErrorCode} from "tc-shared/connection/ErrorCode"; import {PermissionEditorTab} from "tc-shared/events/GlobalEvents"; import {LogCategory, logError, logWarn} from "tc-shared/log"; +import {useTr} from "tc-shared/ui/react-elements/Helper"; const cssStyle = require("./ModalPermissionEditor.scss"); @@ -44,12 +45,12 @@ export type PermissionEditorSubject = | "client" | "client-channel" | "none"; -export const PermissionTabName: { [T in PermissionEditorTab]: { name: string, translated: string } } = { - "groups-server": {name: "Server Groups", translated: tr("Server Groups")}, - "groups-channel": {name: "Channel Groups", translated: tr("Channel Groups")}, - "channel": {name: "Channel Permissions", translated: tr("Channel Permissions")}, - "client": {name: "Client Permissions", translated: tr("Client Permissions")}, - "client-channel": {name: "Client Channel Permissions", translated: tr("Client Channel Permissions")}, +export const PermissionTabName: { [T in PermissionEditorTab]: { name: string, useTranslate: () => string, renderTranslate: () => React.ReactNode } } = { + "groups-server": {name: "Server Groups", useTranslate: () => useTr("Server Groups"), renderTranslate: () => Server Groups}, + "groups-channel": {name: "Channel Groups", useTranslate: () => useTr("Channel Groups"), renderTranslate: () => Channel Groups}, + "channel": {name: "Channel Permissions", useTranslate: () => useTr("Channel Permissions"), renderTranslate: () => Channel Permissions}, + "client": {name: "Client Permissions", useTranslate: () => useTr("Client Permissions"), renderTranslate: () => Client Permissions}, + "client-channel": {name: "Client Channel Permissions", useTranslate: () => useTr("Client Channel Permissions"), renderTranslate: () => Client Channel Permissions}, }; export type GroupProperties = { @@ -244,13 +245,15 @@ const ActiveTabInfo = (props: { events: Registry }) => { const [activeTab, setActiveTab] = useState("groups-server"); props.events.reactUse("action_activate_tab", event => setActiveTab(event.tab)); - return
- + ); }; const TabSelectorEntry = (props: { events: Registry, entry: PermissionEditorTab }) => { @@ -258,22 +261,26 @@ const TabSelectorEntry = (props: { events: Registry, entr props.events.reactUse("action_activate_tab", event => setActive(event.tab === props.entry)); - return
!active && props.events.fire("action_activate_tab", {tab: props.entry})}> - - {PermissionTabName[props.entry].translated} - -
; + return ( +
!active && props.events.fire("action_activate_tab", {tab: props.entry})}> + + {PermissionTabName[props.entry].renderTranslate()} + +
+ ); }; const TabSelector = (props: { events: Registry }) => { - return
- - - - - -
; + return ( +
+ + + + + +
+ ); }; export type DefaultTabValues = { groupId?: number, channelId?: number, clientDatabaseId?: number }; @@ -336,7 +343,7 @@ class PermissionEditorModal extends InternalModal { ); } - title(): React.ReactElement { + renderTitle(): React.ReactElement { return Server permissions; } diff --git a/shared/js/ui/modal/permission/PermissionEditor.tsx b/shared/js/ui/modal/permission/PermissionEditor.tsx index c4667035..03bee227 100644 --- a/shared/js/ui/modal/permission/PermissionEditor.tsx +++ b/shared/js/ui/modal/permission/PermissionEditor.tsx @@ -1354,7 +1354,7 @@ export class PermissionEditor extends React.Component
- ] + ]; } componentDidMount(): void { diff --git a/shared/js/ui/modal/settings/Notifications.tsx b/shared/js/ui/modal/settings/Notifications.tsx index 9cd7a78e..74cc8f62 100644 --- a/shared/js/ui/modal/settings/Notifications.tsx +++ b/shared/js/ui/modal/settings/Notifications.tsx @@ -409,8 +409,9 @@ function initializeController(events: Registry) { let filter = undefined; events.on(["query_events", "action_set_filter"], event => { - if (event.type === "action_set_filter") - filter = event.as<"action_set_filter">().filter; + if (event.type === "action_set_filter") { + filter = event.asUnchecked("action_set_filter").filter; + } const groupMapper = (group: EventGroup) => { const result = { diff --git a/shared/js/ui/modal/transfer/ModalFileTransfer.tsx b/shared/js/ui/modal/transfer/ModalFileTransfer.tsx index 0a13bb6f..da7f588b 100644 --- a/shared/js/ui/modal/transfer/ModalFileTransfer.tsx +++ b/shared/js/ui/modal/transfer/ModalFileTransfer.tsx @@ -1,4 +1,4 @@ -import {spawnReactModal} from "tc-shared/ui/react-elements/Modal"; +import {spawnReactModal} from "tc-shared/ui/react-elements/modal"; import * as React from "react"; import {Registry} from "tc-shared/events"; import {FileBrowserRenderer, NavigationBar} from "./FileBrowserRenderer"; @@ -41,7 +41,7 @@ class FileTransferModal extends InternalModal { this.transferInfoEvents.fire("notify_destroy"); } - title() { + renderTitle() { return File Browser; } diff --git a/shared/js/ui/modal/video-source/Controller.tsx b/shared/js/ui/modal/video-source/Controller.tsx index c9b37773..e640b313 100644 --- a/shared/js/ui/modal/video-source/Controller.tsx +++ b/shared/js/ui/modal/video-source/Controller.tsx @@ -1,5 +1,5 @@ import {Registry} from "tc-shared/events"; -import {spawnReactModal} from "tc-shared/ui/react-elements/Modal"; +import {spawnReactModal} from "tc-shared/ui/react-elements/modal"; import {ModalVideoSourceEvents} from "tc-shared/ui/modal/video-source/Definitions"; import {ModalVideoSource} from "tc-shared/ui/modal/video-source/Renderer"; import {getVideoDriver, VideoPermissionStatus, VideoSource} from "tc-shared/video/VideoSource"; @@ -94,7 +94,10 @@ export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, mode } if(event.status.status === "preview") { - /* we've successfully selected something */ + /* We've successfully selected something. Use that device instead. */ + result.source?.deref(); + result.source = controller.getCurrentSource()?.ref(); + result.config = controller.getBroadcastConstraints(); modal.destroy(); } }); @@ -125,13 +128,20 @@ async function generateAndApplyDefaultConfig(source: VideoSource) : Promise { + renderTitle(): string | React.ReactElement { return Start video Broadcasting; } } diff --git a/shared/js/ui/modal/whats-new/Controller.tsx b/shared/js/ui/modal/whats-new/Controller.tsx index ad678a5f..bca43401 100644 --- a/shared/js/ui/modal/whats-new/Controller.tsx +++ b/shared/js/ui/modal/whats-new/Controller.tsx @@ -1,4 +1,4 @@ -import {spawnReactModal} from "tc-shared/ui/react-elements/Modal"; +import {spawnReactModal} from "tc-shared/ui/react-elements/modal"; import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller"; import * as React from "react"; import {WhatsNew} from "tc-shared/ui/modal/whats-new/Renderer"; @@ -15,7 +15,7 @@ export function spawnUpdatedModal(changes: { changesUI?: ChangeLog, changesClien return ; } - title(): string | React.ReactElement { + renderTitle(): string | React.ReactElement { return We've updated the client for you; } }); diff --git a/shared/js/ui/react-elements/ErrorBoundary.tsx b/shared/js/ui/react-elements/ErrorBoundary.tsx index 1b962ebb..e3022f7f 100644 --- a/shared/js/ui/react-elements/ErrorBoundary.tsx +++ b/shared/js/ui/react-elements/ErrorBoundary.tsx @@ -28,6 +28,7 @@ export class ErrorBoundary extends React.Component<{}, ErrorBoundaryState> { componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { /* TODO: Some kind of logging? */ + console.error(error); } static getDerivedStateFromError() : Partial { diff --git a/shared/js/ui/react-elements/InputField.tsx b/shared/js/ui/react-elements/InputField.tsx index 4497b117..a1bae6f1 100644 --- a/shared/js/ui/react-elements/InputField.tsx +++ b/shared/js/ui/react-elements/InputField.tsx @@ -23,6 +23,7 @@ export interface BoxedInputFieldProperties { isInvalid?: boolean; className?: string; + maxLength?: number, size?: "normal" | "large" | "small"; type?: "text" | "password" | "number"; @@ -86,6 +87,7 @@ export class BoxedInputField extends React.Component this.props.onInput(event.currentTarget.value))} onKeyDown={e => this.onKeyDown(e)} + maxLength={this.props.maxLength} /> } {this.props.suffix ? {this.props.suffix} : undefined} @@ -399,6 +401,7 @@ export const ControlledSelect = (props: { export interface SelectProperties { type?: "flat" | "boxed"; + refSelect?: React.RefObject, defaultValue?: string; value?: string; @@ -416,6 +419,8 @@ export interface SelectProperties { disabled?: boolean; editable?: boolean; + title?: string, + onFocus?: () => void; onBlur?: () => void; @@ -430,11 +435,13 @@ export interface SelectFieldState { } export class Select extends React.Component { - private refSelect = React.createRef(); + private refSelect; constructor(props) { super(props); + this.refSelect = this.props.refSelect || React.createRef(); + this.state = { isInvalid: false, invalidMessage: "" @@ -444,7 +451,12 @@ export class Select extends React.Component render() { const disabled = typeof this.state.disabled === "boolean" ? this.state.disabled : typeof this.props.disabled === "boolean" ? this.props.disabled : false; return ( -
+
{this.props.label ? : undefined}