(resolve => {
- const timeout = setTimeout(() => {
- listener();
- resolve(true);
- }, 2500);
-
- const listener = this.connection.events.on("notify_state_changed", event => {
- if(event.newState !== "connected") {
- resolve(false);
- clearTimeout(timeout);
- }
- })
- });
-
- if(shouldRetry) {
- continue;
- } else {
- return result;
- }
-
- default:
- return result;
- }
- }
- }
-
- private async sendInitializeAgent() {
- const taskId = ++this.initializeAgentId;
- const payload: CommandSessionInitializeAgent = {
- session_type: __build.target === "web" ? 0 : 1,
- architecture: null,
- platform_version: null,
- platform: null,
- client_version: null,
- ui_version: __build.version
- };
-
- if(__build.target === "client") {
- const info = getBackend("native").getVersionInfo();
-
- payload.client_version = info.version;
- payload.architecture = info.os_architecture;
- payload.platform = info.os_platform;
- payload.platform_version = info.os_platform_version;
- } else {
- const os = window.detectedBrowser.os;
- const osParts = os.split(" ");
- if(osParts.last().match(/^[0-9\.]+$/)) {
- payload.platform_version = osParts.last();
- osParts.splice(osParts.length - 1, 1);
- }
-
- payload.platform = osParts.join(" ");
- payload.architecture = window.detectedBrowser.name;
- payload.client_version = window.detectedBrowser.version;
- }
-
- if(this.initializeAgentId !== taskId) {
- /* We don't want to send that stuff any more */
- return;
- }
-
- this.executeCommandWithRetry({ type: "SessionInitializeAgent", payload }, 2500).then(result => {
- if(kVerbose) {
- logTrace(LogCategory.STATISTICS, tr("Agent initialize result: %o"), result);
- }
- });
- }
-
- private async sendLocaleUpdate() {
- const taskId = ++this.initializeLocaleId;
-
- const payload: CommandSessionUpdateLocale = {
- ip_country: null,
- selected_locale: null,
- local_timestamp: Date.now()
- };
-
- const geoInfo = await geoLocationProvider.queryInfo(2500);
- payload.ip_country = geoInfo?.country?.toLowerCase() || null;
-
- const trConfig = translation_config();
- payload.selected_locale = trConfig?.current_translation_url || null;
-
- if(this.initializeLocaleId !== taskId) {
- return;
- }
-
- this.connection.executeCommand({ type: "SessionUpdateLocale", payload }).then(result => {
- if(kVerbose) {
- logTrace(LogCategory.STATISTICS, tr("Agent local update result: %o"), result);
- }
- });
- }
-
- private handleNotifyClientsOnline(notify: NotifyClientsOnline) {
- logInfo(LogCategory.GENERAL, tr("Received user count update: %o"), notify);
- }
-}
+import translation_config = config.translation_config;
export let clientServices: ClientServices;
+export let clientServiceInvite: ClientServiceInvite;
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
priority: 30,
function: async () => {
- clientServices = new ClientServices();
- clientServices.start();
+ clientServices = new ClientServices(new class implements ClientServiceConfig {
+ getServiceHost(): string {
+ //return "localhost:1244";
+ return "client-services.teaspeak.de:27791";
+ }
+ getSessionType(): ClientSessionType {
+ return __build.target === "web" ? ClientSessionType.WebClient : ClientSessionType.TeaClient;
+ }
+
+ generateHostInfo(): LocalAgent {
+ if(__build.target === "client") {
+ const info = getBackend("native").getVersionInfo();
+
+ return {
+ clientVersion: info.version,
+ uiVersion: __build.version,
+
+ architecture: info.os_architecture,
+ platform: info.os_platform,
+ platformVersion: info.os_platform_version
+ };
+ } else {
+ const os = window.detectedBrowser.os;
+ const osParts = os.split(" ");
+ let platformVersion;
+ if(osParts.last().match(/^[0-9.]+$/)) {
+ platformVersion = osParts.last();
+ osParts.splice(osParts.length - 1, 1);
+ }
+
+ return {
+ uiVersion: __build.version,
+
+ platform: osParts.join(" "),
+ platformVersion: platformVersion,
+ architecture: window.detectedBrowser.name,
+ clientVersion: window.detectedBrowser.version,
+ }
+ }
+ }
+
+ getSelectedLocaleUrl(): string | null {
+ const trConfig = translation_config();
+ return trConfig?.current_translation_url || null;
+ }
+ });
+
+ clientServices.start();
(window as any).clientServices = clientServices;
+
+ clientServiceInvite = new ClientServiceInvite(clientServices);
+ (window as any).clientServiceInvite = clientServiceInvite;
},
name: "client services"
});
\ No newline at end of file
diff --git a/shared/js/connection/HandshakeHandler.ts b/shared/js/connection/HandshakeHandler.ts
index 42b55be8..819f7be2 100644
--- a/shared/js/connection/HandshakeHandler.ts
+++ b/shared/js/connection/HandshakeHandler.ts
@@ -87,7 +87,7 @@ export class HandshakeHandler {
client_default_channel_password: this.parameters.defaultChannelPassword || "",
client_default_token: this.parameters.token,
- client_server_password: this.parameters.targetPassword,
+ client_server_password: this.parameters.serverPassword,
client_input_hardware: this.connection.client.isMicrophoneDisabled(),
client_output_hardware: this.connection.client.hasOutputHardware(),
diff --git a/shared/js/events.ts b/shared/js/events.ts
index cc0cf62e..afb87d21 100644
--- a/shared/js/events.ts
+++ b/shared/js/events.ts
@@ -1,570 +1,18 @@
-import {LogCategory, logTrace} from "./log";
-import {guid} from "./crypto/uid";
-import {useEffect} from "react";
-import {unstable_batchedUpdates} from "react-dom";
-import * as React from "react";
+import {EventRegistryHooks, setEventRegistryHooks} from "tc-events";
+import {LogCategory, logError, logTrace} from "tc-shared/log";
-/*
-export type EventPayloadObject = {
- [key: string]: EventPayload
-} | {
- [key: number]: EventPayload
-};
+export * from "tc-events";
-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;
- }
+setEventRegistryHooks(new class implements EventRegistryHooks {
+ logAsyncInvokeError(error: any) {
+ logError(LogCategory.EVENT_REGISTRY, tr("Failed to invoke async callback:\n%o"), error);
}
- function extractPayload() {
- const result = Object.assign({}, this);
- delete result["as"];
- delete result["asUnchecked"];
- delete result["asAnyUnchecked"];
- delete result["extractPayload"];
- return result;
+ logReactInvokeError(error: any) {
+ logError(LogCategory.EVENT_REGISTRY, tr("Failed to invoke react callback:\n%o"), error);
}
- function as(target) {
- if(this.type !== target) {
- throw "Mismatching event type. Expected: " + target + ", Got: " + this.type;
- }
-
- return this;
+ logTrace(message: string, ...args: any[]) {
+ logTrace(LogCategory.EVENT_REGISTRY, message, ...args);
}
-
- function asUnchecked() {
- return this;
- }
-}
-
-export interface EventSender = EventMap> {
- fire(event_type: T, data?: Events[T], overrideTypeKey?: boolean);
-
- /**
- * Fire an event later by using setTimeout(..)
- * @param event_type The target event to be fired
- * @param data The payload of the event
- * @param callback The callback will be called after the event has been successfully dispatched
- */
- fire_later(event_type: T, data?: Events[T], callback?: () => void);
-
- /**
- * Fire an event, which will be delayed until the next animation frame.
- * This ensures that all react components have been successfully mounted/unmounted.
- * @param event_type The target event to be fired
- * @param data The payload of the event
- * @param callback The callback will be called after the event has been successfully dispatched
- */
- fire_react(event_type: T, data?: Events[T], callback?: () => void);
-}
-
-export type EventDispatchType = "sync" | "later" | "react";
-
-export interface EventConsumer {
- handleEvent(mode: EventDispatchType, type: string, data: any);
-}
-
-interface EventHandlerRegisterData {
- registeredHandler: {[key: string]: ((event) => void)[]}
-}
-
-const kEventAnnotationKey = guid();
-export class Registry = EventMap> implements EventSender {
- protected readonly registryUniqueId;
-
- protected persistentEventHandler: { [key: string]: ((event) => void)[] } = {};
- protected oneShotEventHandler: { [key: string]: ((event) => void)[] } = {};
- protected genericEventHandler: ((event) => void)[] = [];
- protected consumer: EventConsumer[] = [];
-
- private ipcConsumer: IpcEventBridge;
-
- private debugPrefix = undefined;
- private warnUnhandledEvents = false;
-
- private pendingAsyncCallbacks: { type: any, data: any, callback: () => void }[];
- private pendingAsyncCallbacksTimeout: number = 0;
-
- private pendingReactCallbacks: { type: any, data: any, callback: () => void }[];
- private pendingReactCallbacksFrame: number = 0;
-
- static fromIpcDescription = EventMap>(description: IpcRegistryDescription) : Registry {
- const registry = new Registry();
- registry.ipcConsumer = new IpcEventBridge(registry as any, description.ipcChannelId);
- registry.registerConsumer(registry.ipcConsumer);
- return registry;
- }
-
- constructor() {
- this.registryUniqueId = "evreg_data_" + guid();
- }
-
- destroy() {
- Object.values(this.persistentEventHandler).forEach(handlers => handlers.splice(0, handlers.length));
- Object.values(this.oneShotEventHandler).forEach(handlers => handlers.splice(0, handlers.length));
- this.genericEventHandler.splice(0, this.genericEventHandler.length);
- this.consumer.splice(0, this.consumer.length);
-
- this.ipcConsumer?.destroy();
- this.ipcConsumer = undefined;
- }
-
- enableDebug(prefix: string) { this.debugPrefix = prefix || "---"; }
- disableDebug() { this.debugPrefix = undefined; }
-
- enableWarnUnhandledEvents() { this.warnUnhandledEvents = true; }
- disableWarnUnhandledEvents() { this.warnUnhandledEvents = false; }
-
- fire(eventType: T, data?: Events[T], overrideTypeKey?: boolean) {
- if(this.debugPrefix) {
- logTrace(LogCategory.EVENT_REGISTRY, "[%s] Trigger event: %s", this.debugPrefix, eventType);
- }
-
- if(typeof data === "object" && 'type' in data && !overrideTypeKey) {
- if((data as any).type !== eventType) {
- debugger;
- throw "The keyword 'type' is reserved for the event type and should not be passed as argument";
- }
- }
-
- for(const consumer of this.consumer) {
- consumer.handleEvent("sync", eventType as string, data);
- }
-
- this.doInvokeEvent(EventHelper.createEvent(eventType, data));
- }
-
- fire_later(eventType: T, data?: Events[T], callback?: () => void) {
- if(!this.pendingAsyncCallbacksTimeout) {
- this.pendingAsyncCallbacksTimeout = setTimeout(() => this.invokeAsyncCallbacks());
- this.pendingAsyncCallbacks = [];
- }
- this.pendingAsyncCallbacks.push({ type: eventType, data: data, callback: callback });
-
- for(const consumer of this.consumer) {
- consumer.handleEvent("later", eventType as string, data);
- }
- }
-
- fire_react(eventType: T, data?: Events[T], callback?: () => void) {
- if(!this.pendingReactCallbacks) {
- this.pendingReactCallbacksFrame = requestAnimationFrame(() => this.invokeReactCallbacks());
- this.pendingReactCallbacks = [];
- }
-
- this.pendingReactCallbacks.push({ type: eventType, data: data, callback: callback });
-
- for(const consumer of this.consumer) {
- consumer.handleEvent("react", eventType as string, data);
- }
- }
-
- on(event: T | T[], handler: (event: Event) => void) : () => void;
- on(events, handler) : () => void {
- if(!Array.isArray(events)) {
- events = [events];
- }
-
- for(const event of events as string[]) {
- const persistentHandler = this.persistentEventHandler[event] || (this.persistentEventHandler[event] = []);
- persistentHandler.push(handler);
- }
-
- return () => this.off(events, handler);
- }
-
- one(event: T | T[], handler: (event: Event) => void) : () => void;
- one(events, handler) : () => void {
- if(!Array.isArray(events)) {
- events = [events];
- }
-
- for(const event of events as string[]) {
- const persistentHandler = this.oneShotEventHandler[event] || (this.oneShotEventHandler[event] = []);
- persistentHandler.push(handler);
- }
-
- return () => this.off(events, handler);
- }
-
- off(handler: (event: Event) => void);
- off(events: T | T[], handler: (event: Event) => void);
- off(handlerOrEvents, handler?) {
- if(typeof handlerOrEvents === "function") {
- this.offAll(handler);
- } else if(typeof handlerOrEvents === "string") {
- if(this.persistentEventHandler[handlerOrEvents]) {
- this.persistentEventHandler[handlerOrEvents].remove(handler);
- }
-
- if(this.oneShotEventHandler[handlerOrEvents]) {
- this.oneShotEventHandler[handlerOrEvents].remove(handler);
- }
- } else if(Array.isArray(handlerOrEvents)) {
- handlerOrEvents.forEach(handler_or_event => this.off(handler_or_event, handler));
- }
- }
-
- onAll(handler: (event: Event) => void): () => void {
- this.genericEventHandler.push(handler);
- return () => this.genericEventHandler.remove(handler);
- }
-
- offAll(handler: (event: Event) => void) {
- Object.values(this.persistentEventHandler).forEach(persistentHandler => persistentHandler.remove(handler));
- Object.values(this.oneShotEventHandler).forEach(oneShotHandler => oneShotHandler.remove(handler));
- this.genericEventHandler.remove(handler);
- }
-
- /**
- * @param event
- * @param handler
- * @param condition If a boolean the event handler will only be registered if the condition is true
- * @param reactEffectDependencies
- */
- reactUse(event: T | T[], handler: (event: Event) => void, condition?: boolean, reactEffectDependencies?: any[]);
- reactUse(event, handler, condition?, reactEffectDependencies?) {
- if(typeof condition === "boolean" && !condition) {
- useEffect(() => {});
- return;
- }
-
- const handlers = this.persistentEventHandler[event as any] || (this.persistentEventHandler[event as any] = []);
-
- useEffect(() => {
- handlers.push(handler);
-
- return () => {
- const index = handlers.indexOf(handler);
- if(index !== -1) {
- handlers.splice(index, 1);
- }
- };
- }, reactEffectDependencies);
- }
-
- private doInvokeEvent(event: Event) {
- const oneShotHandler = this.oneShotEventHandler[event.type];
- if(oneShotHandler) {
- delete this.oneShotEventHandler[event.type];
- for(const handler of oneShotHandler) {
- handler(event);
- }
- }
-
- const handlers = [...(this.persistentEventHandler[event.type] || [])];
- for(const handler of handlers) {
- handler(event);
- }
-
- for(const handler of this.genericEventHandler) {
- handler(event);
- }
- /*
- let invokeCount = 0;
- if(this.warnUnhandledEvents && invokeCount === 0) {
- logWarn(LogCategory.EVENT_REGISTRY, "Event handler (%s) triggered event %s which has no consumers.", this.debugPrefix, event.type);
- }
- */
- }
-
- private invokeAsyncCallbacks() {
- const callbacks = this.pendingAsyncCallbacks;
- this.pendingAsyncCallbacksTimeout = 0;
- this.pendingAsyncCallbacks = undefined;
-
- let index = 0;
- while(index < callbacks.length) {
- this.fire(callbacks[index].type, callbacks[index].data);
- try {
- if(callbacks[index].callback) {
- callbacks[index].callback();
- }
- } catch (error) {
- console.error(error);
- /* TODO: Improve error logging? */
- }
- index++;
- }
- }
-
- private invokeReactCallbacks() {
- const callbacks = this.pendingReactCallbacks;
- this.pendingReactCallbacksFrame = 0;
- this.pendingReactCallbacks = undefined;
-
- /* run this after the requestAnimationFrame has been finished since else it might be fired instantly */
- setTimeout(() => {
- /* batch all react updates */
- unstable_batchedUpdates(() => {
- let index = 0;
- while(index < callbacks.length) {
- this.fire(callbacks[index].type, callbacks[index].data);
- try {
- if(callbacks[index].callback) {
- callbacks[index].callback();
- }
- } catch (error) {
- console.error(error);
- /* TODO: Improve error logging? */
- }
- index++;
- }
- });
- });
- }
-
- registerHandler(handler: any, parentClasses?: boolean) {
- if(typeof handler !== "object") {
- throw "event handler must be an object";
- }
-
- if(typeof handler[this.registryUniqueId] !== "undefined") {
- throw "event handler already registered";
- }
-
- const prototype = Object.getPrototypeOf(handler);
- if(typeof prototype !== "object") {
- throw "event handler must have a prototype";
- }
-
- const data = handler[this.registryUniqueId] = {
- registeredHandler: {}
- } as EventHandlerRegisterData;
-
- let currentPrototype = prototype;
- do {
- Object.getOwnPropertyNames(currentPrototype).forEach(functionName => {
- if(functionName === "constructor") {
- return;
- }
-
- if(typeof prototype[functionName] !== "function") {
- return;
- }
-
- if(typeof prototype[functionName][kEventAnnotationKey] !== "object") {
- return;
- }
-
- const eventData = prototype[functionName][kEventAnnotationKey];
- const eventHandler = event => prototype[functionName].call(handler, event);
- for(const event of eventData.events) {
- const registeredHandler = data.registeredHandler[event] || (data.registeredHandler[event] = []);
- registeredHandler.push(eventHandler);
-
- this.on(event, eventHandler);
- }
- });
-
- if(!parentClasses) {
- break;
- }
- } while ((currentPrototype = Object.getPrototypeOf(currentPrototype)));
- }
-
- unregisterHandler(handler: any) {
- if(typeof handler !== "object") {
- throw "event handler must be an object";
- }
-
- if(typeof handler[this.registryUniqueId] === "undefined") {
- throw "event handler not registered";
- }
-
- const data = handler[this.registryUniqueId] as EventHandlerRegisterData;
- delete handler[this.registryUniqueId];
-
- for(const event of Object.keys(data.registeredHandler)) {
- for(const handler of data.registeredHandler[event]) {
- this.off(event as any, handler);
- }
- }
- }
-
- registerConsumer(consumer: EventConsumer) : () => void {
- const allConsumer = this.consumer;
- allConsumer.push(consumer);
-
- return () => allConsumer.remove(consumer);
- }
-
- unregisterConsumer(consumer: EventConsumer) {
- this.consumer.remove(consumer);
- }
-
- generateIpcDescription() : IpcRegistryDescription {
- if(!this.ipcConsumer) {
- this.ipcConsumer = new IpcEventBridge(this as any, undefined);
- this.registerConsumer(this.ipcConsumer);
- }
-
- return {
- ipcChannelId: this.ipcConsumer.ipcChannelId
- };
- }
-}
-
-export type RegistryMap = {[key: string]: any /* can't use Registry here since the template parameter is missing */ };
-
-export function EventHandler(events: (keyof EventTypes) | (keyof EventTypes)[]) {
- return function (target: any,
- propertyKey: string,
- _descriptor: PropertyDescriptor) {
- if(typeof target[propertyKey] !== "function")
- throw "Invalid event handler annotation. Expected to be on a function type.";
-
- target[propertyKey][kEventAnnotationKey] = {
- events: Array.isArray(events) ? events : [events]
- };
- }
-}
-
-export function ReactEventHandler, Events = any>(registry_callback: (object: ObjectClass) => Registry) {
- return function (constructor: Function) {
- if(!React.Component.prototype.isPrototypeOf(constructor.prototype))
- throw "Class/object isn't an instance of React.Component";
-
- const didMount = constructor.prototype.componentDidMount;
- constructor.prototype.componentDidMount = function() {
- const registry = registry_callback(this);
- if(!registry) throw "Event registry returned for an event object is invalid";
- registry.registerHandler(this);
-
- if(typeof didMount === "function") {
- didMount.call(this, arguments);
- }
- };
-
- const willUnmount = constructor.prototype.componentWillUnmount;
- constructor.prototype.componentWillUnmount = function () {
- const registry = registry_callback(this);
- if(!registry) throw "Event registry returned for an event object is invalid";
- try {
- registry.unregisterHandler(this);
- } catch (error) {
- console.warn("Failed to unregister event handler: %o", error);
- }
-
- if(typeof willUnmount === "function") {
- willUnmount.call(this, arguments);
- }
- };
- }
-}
-
-export type IpcRegistryDescription = EventMap> = {
- ipcChannelId: string
-}
-
-class IpcEventBridge implements EventConsumer {
- readonly registry: Registry;
- readonly ipcChannelId: string;
- private readonly ownBridgeId: string;
- private broadcastChannel: BroadcastChannel;
-
- constructor(registry: Registry, ipcChannelId: string | undefined) {
- this.registry = registry;
- this.ownBridgeId = guid();
-
- this.ipcChannelId = ipcChannelId || ("teaspeak-ipc-events-" + guid());
- this.broadcastChannel = new BroadcastChannel(this.ipcChannelId);
- this.broadcastChannel.onmessage = event => this.handleIpcMessage(event.data, event.source, event.origin);
- }
-
- destroy() {
- if(this.broadcastChannel) {
- this.broadcastChannel.onmessage = undefined;
- this.broadcastChannel.onmessageerror = undefined;
- this.broadcastChannel.close();
- }
-
- this.broadcastChannel = undefined;
- }
-
- handleEvent(dispatchType: EventDispatchType, eventType: string, eventPayload: any) {
- if(eventPayload && eventPayload[this.ownBridgeId]) {
- return;
- }
-
- this.broadcastChannel.postMessage({
- type: "event",
- source: this.ownBridgeId,
-
- dispatchType,
- eventType,
- eventPayload,
- });
- }
-
- private handleIpcMessage(message: any, _source: MessageEventSource | null, _origin: string) {
- if(message.source === this.ownBridgeId) {
- /* It's our own event */
- return;
- }
-
- if(message.type === "event") {
- const payload = message.eventPayload || {};
- payload[this.ownBridgeId] = true;
- switch(message.dispatchType as EventDispatchType) {
- case "sync":
- this.registry.fire(message.eventType, payload);
- break;
-
- case "react":
- this.registry.fire_react(message.eventType, payload);
- break;
-
- case "later":
- this.registry.fire_later(message.eventType, payload);
- break;
- }
- }
- }
-}
\ No newline at end of file
+});
\ No newline at end of file
diff --git a/shared/js/ipc/ConnectHandler.ts b/shared/js/ipc/ConnectHandler.ts
index c731f83e..ac25755b 100644
--- a/shared/js/ipc/ConnectHandler.ts
+++ b/shared/js/ipc/ConnectHandler.ts
@@ -8,6 +8,7 @@ export type ConnectRequestData = {
profile?: string;
username?: string;
+
password?: {
value: string;
hashed: boolean;
diff --git a/shared/js/main.tsx b/shared/js/main.tsx
index 20521489..9527aeb4 100644
--- a/shared/js/main.tsx
+++ b/shared/js/main.tsx
@@ -1,18 +1,18 @@
import * as loader from "tc-loader";
+import {Stage} from "tc-loader";
import * as bipc from "./ipc/BrowserIPC";
import * as sound from "./sound/Sounds";
import * as i18n from "./i18n/localize";
+import {tra} from "./i18n/localize";
import * as fidentity from "./profiles/identities/TeaForumIdentity";
import * as aplayer from "tc-backend/audio/player";
import * as ppt from "tc-backend/ppt";
import * as global_ev_handler from "./events/ClientGlobalControlHandler";
-import {Stage} from "tc-loader";
-import {AppParameters, settings, Settings} from "tc-shared/settings";
-import {LogCategory, logError, logInfo} from "tc-shared/log";
-import {tra} from "./i18n/localize";
+import {AppParameters, settings, Settings, UrlParameterBuilder, UrlParameterParser} from "tc-shared/settings";
+import {LogCategory, logError, logInfo, logWarn} from "tc-shared/log";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {createInfoModal} from "tc-shared/ui/elements/Modal";
-import {defaultRecorder, RecorderProfile, setDefaultRecorder} from "tc-shared/voice/RecorderProfile";
+import {RecorderProfile, setDefaultRecorder} from "tc-shared/voice/RecorderProfile";
import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
import {formatMessage} from "tc-shared/ui/frames/chat";
import {openModalNewcomer} from "tc-shared/ui/modal/ModalNewcomer";
@@ -25,6 +25,8 @@ import {ConnectRequestData} from "tc-shared/ipc/ConnectHandler";
import {defaultConnectProfile, findConnectProfile} from "tc-shared/profiles/ConnectionProfile";
import {server_connections} from "tc-shared/ConnectionManager";
import {spawnConnectModalNew} from "tc-shared/ui/modal/connect/Controller";
+import {initializeKeyControl} from "./KeyControl";
+import {assertMainApplication} from "tc-shared/ui/utils";
/* required import for init */
import "svg-sprites/client-icons";
@@ -46,8 +48,6 @@ import "./ui/modal/connect/Controller";
import "./ui/elements/ContextDivider";
import "./ui/elements/Tab";
import "./clientservice";
-import {initializeKeyControl} from "./KeyControl";
-import {assertMainApplication} from "tc-shared/ui/utils";
assertMainApplication();
@@ -96,43 +96,89 @@ async function initializeApp() {
}
}
-/* Used by the native client... We can't refactor this yet */
-export function handle_connect_request(properties: ConnectRequestData, connection: ConnectionHandler) {
- const profile = findConnectProfile(properties.profile) || defaultConnectProfile();
- const username = properties.username || profile.connectUsername();
-
- const password = properties.password ? properties.password.value : "";
- const password_hashed = properties.password ? properties.password.hashed : false;
-
- if(profile && profile.valid()) {
- settings.setValue(Settings.KEY_USER_IS_NEW, false);
-
- if(!aplayer.initialized()) {
- /* Trick the client into clicking somewhere on the site */
- spawnYesNo(tra("Connect to {}", properties.address), tra("Would you like to connect to {}?", properties.address), result => {
- if(result) {
- aplayer.on_ready(() => handle_connect_request(properties, connection));
- } else {
- /* Well... the client don't want to... */
- }
- }).open();
- return;
+/* The native client has received a connect request. */
+export function handleNativeConnectRequest(url: URL) {
+ let serverAddress = url.host;
+ if(url.searchParams.has("port")) {
+ if(serverAddress.indexOf(':') !== -1) {
+ logWarn(LogCategory.GENERAL, tr("Received connect request which specified the port twice (via parameter and host). Using host port."));
+ } else if(serverAddress.indexOf(":") === -1) {
+ serverAddress += ":" + url.searchParams.get("port");
+ } else {
+ serverAddress = `[${serverAddress}]:${url.searchParams.get("port")}`;
}
+ }
- connection.startConnection(properties.address, profile, true, {
- nickname: username,
- password: password.length > 0 ? {
- password: password,
- hashed: password_hashed
- } : undefined
- });
- server_connections.setActiveConnectionHandler(connection);
- } else {
+ handleConnectRequest(serverAddress, new UrlParameterParser(url));
+}
+
+function handleConnectRequest(serverAddress: string, parameters: UrlParameterParser) {
+ const profileId = parameters.getValue(AppParameters.KEY_CONNECT_PROFILE, undefined);
+ const profile = findConnectProfile(profileId) || defaultConnectProfile();
+
+ if(!profile || !profile.valid()) {
spawnConnectModalNew({
- selectedAddress: properties.address,
+ selectedAddress: serverAddress,
selectedProfile: profile
});
+ return;
}
+
+ if(!aplayer.initialized()) {
+ /* Trick the client into clicking somewhere on the site */
+ spawnYesNo(tra("Connect to {}", serverAddress), tra("Would you like to connect to {}?", serverAddress), result => {
+ if(result) {
+ aplayer.on_ready(() => handleConnectRequest(serverAddress, parameters));
+ } else {
+ /* Well... the client don't want to... */
+ }
+ }).open();
+ return;
+ }
+
+ const clientNickname = parameters.getValue(AppParameters.KEY_CONNECT_NICKNAME, undefined);
+
+ const serverPassword = parameters.getValue(AppParameters.KEY_CONNECT_SERVER_PASSWORD, undefined);
+ const passwordsHashed = parameters.getValue(AppParameters.KEY_CONNECT_PASSWORDS_HASHED);
+
+ const channel = parameters.getValue(AppParameters.KEY_CONNECT_CHANNEL, undefined);
+ const channelPassword = parameters.getValue(AppParameters.KEY_CONNECT_CHANNEL_PASSWORD, undefined);
+
+ let connection = server_connections.getActiveConnectionHandler();
+ if(connection.connected) {
+ connection = server_connections.spawnConnectionHandler();
+ }
+
+ connection.startConnectionNew({
+ targetAddress: serverAddress,
+
+ nickname: clientNickname,
+ nicknameSpecified: false,
+
+ profile: profile,
+ token: undefined,
+
+ serverPassword: serverPassword,
+ serverPasswordHashed: passwordsHashed,
+
+ defaultChannel: channel,
+ defaultChannelPassword: channelPassword,
+ defaultChannelPasswordHashed: passwordsHashed
+ }, false).then(undefined);
+ server_connections.setActiveConnectionHandler(connection);
+}
+
+/* Used by the old native clients (an within the multi instance handler). Delete it later */
+export function handle_connect_request(properties: ConnectRequestData, _connection: ConnectionHandler) {
+ const urlBuilder = new UrlParameterBuilder();
+ urlBuilder.setValue(AppParameters.KEY_CONNECT_PROFILE, properties.profile);
+ urlBuilder.setValue(AppParameters.KEY_CONNECT_NICKNAME, properties.username);
+
+ urlBuilder.setValue(AppParameters.KEY_CONNECT_SERVER_PASSWORD, properties.password?.value);
+ urlBuilder.setValue(AppParameters.KEY_CONNECT_PASSWORDS_HASHED, properties.password?.hashed);
+
+ const url = new URL(`https://localhost/?${urlBuilder.build()}`);
+ handleConnectRequest(properties.address, new UrlParameterParser(url));
}
function main() {
@@ -235,7 +281,7 @@ const task_connect_handler: loader.Task = {
return;
}
- /* FIXME: All additional parameters! */
+ /* FIXME: All additional connect parameters! */
const connectData = {
address: address,
@@ -293,7 +339,7 @@ const task_connect_handler: loader.Task = {
preventWelcomeUI = true;
loader.register_task(loader.Stage.LOADED, {
priority: 0,
- function: async () => handle_connect_request(connectData, server_connections.getActiveConnectionHandler() || server_connections.spawnConnectionHandler()),
+ function: async () => handleConnectRequest(address, AppParameters.Instance),
name: tr("default url connect")
});
loader.register_task(loader.Stage.LOADED, task_teaweb_starter);
diff --git a/shared/js/settings.ts b/shared/js/settings.ts
index 5288ff3c..9d280e0e 100644
--- a/shared/js/settings.ts
+++ b/shared/js/settings.ts
@@ -76,10 +76,11 @@ function encodeValueToString(input: T) : string {
function resolveKey(
key: RegistryKey,
- resolver: (key: string) => string | undefined,
+ resolver: (key: string) => string | undefined | null,
defaultValue: DefaultType
) : ValueType | DefaultType {
let value = resolver(key.key);
+
if(typeof value === "string") {
return decodeValueFromString(value, key.valueType);
}
@@ -104,41 +105,71 @@ function resolveKey(
return defaultValue;
}
+export class UrlParameterParser {
+ private readonly url: URL;
+
+ constructor(url: URL) {
+ this.url = url;
+ }
+
+ private getParameter(key: string) : string | undefined {
+ const value = this.url.searchParams.get(key);
+ if(value === null) {
+ return undefined;
+ }
+
+ return decodeURIComponent(value);
+ }
+
+ getValue(key: RegistryKey, defaultValue: DV) : V | DV;
+ getValue(key: ValuedRegistryKey, defaultValue?: V) : V;
+ getValue(key: RegistryKey | ValuedRegistryKey, defaultValue: DV) : V | DV {
+ if(arguments.length > 1) {
+ return resolveKey(key, key => this.getParameter(key), defaultValue);
+ } else if("defaultValue" in key) {
+ return resolveKey(key, key => this.getParameter(key), key.defaultValue);
+ } else {
+ throw tr("missing value");
+ }
+ }
+}
+
+export class UrlParameterBuilder {
+ private parameters = {};
+
+ setValue(key: RegistryKey, value: V) {
+ if(value === undefined) {
+ delete this.parameters[key.key];
+ } else {
+ this.parameters[key.key] = encodeURIComponent(encodeValueToString(value));
+ }
+ }
+
+ build() : string {
+ return Object.keys(this.parameters).map(key => `${key}=${this.parameters[key]}`).join("&");
+ }
+}
+
/**
* Switched appended to the application via the URL.
* TODO: Passing native client switches
*/
export namespace AppParameters {
- const parameters = {};
-
- function parseParameters() {
- let search;
- if(window.opener && window.opener !== window) {
- search = new URL(window.location.href).search;
- } else {
- search = location.search;
- }
-
- search.substr(1).split("&").forEach(part => {
- let item = part.split("=");
- parameters[item[0]] = decodeURIComponent(item[1]);
- });
- }
+ export const Instance = new UrlParameterParser(new URL(window.location.href));
export function getValue(key: RegistryKey, defaultValue: DV) : V | DV;
export function getValue(key: ValuedRegistryKey, defaultValue?: V) : V;
export function getValue(key: RegistryKey | ValuedRegistryKey, defaultValue: DV) : V | DV {
if(arguments.length > 1) {
- return resolveKey(key, key => parameters[key], defaultValue);
+ return Instance.getValue(key, defaultValue);
} else if("defaultValue" in key) {
- return resolveKey(key, key => parameters[key], key.defaultValue);
+ return Instance.getValue(key);
} else {
throw tr("missing value");
}
}
-
- parseParameters();
}
+
(window as any).AppParameters = AppParameters;
export namespace AppParameters {
@@ -167,13 +198,13 @@ export namespace AppParameters {
export const KEY_CONNECT_NICKNAME: RegistryKey = {
key: "cn",
- fallbackKeys: ["connect_username"],
+ fallbackKeys: ["connect_username", "nickname"],
valueType: "string"
};
export const KEY_CONNECT_TOKEN: RegistryKey = {
key: "ctk",
- fallbackKeys: ["connect_token"],
+ fallbackKeys: ["connect_token", "connect-token", "token"],
valueType: "string",
description: "Token which will be used by default if the connection attempt succeeded."
};
@@ -187,9 +218,17 @@ export namespace AppParameters {
export const KEY_CONNECT_SERVER_PASSWORD: RegistryKey = {
key: "csp",
- fallbackKeys: ["connect_server_password"],
+ fallbackKeys: ["connect_server_password", "server-password"],
valueType: "string",
- description: "The password (hashed) for the auto connect attempt."
+ description: "The password for the auto connect attempt."
+ };
+
+ export const KEY_CONNECT_PASSWORDS_HASHED: ValuedRegistryKey = {
+ key: "cph",
+ fallbackKeys: ["connect_passwords_hashed", "passwords-hashed"],
+ valueType: "boolean",
+ description: "Indicate whatever all passwords are hashed or not",
+ defaultValue: false
};
export const KEY_CONNECT_CHANNEL: RegistryKey = {
@@ -201,7 +240,7 @@ export namespace AppParameters {
export const KEY_CONNECT_CHANNEL_PASSWORD: RegistryKey = {
key: "ccp",
- fallbackKeys: ["connect_channel_password"],
+ fallbackKeys: ["connect_channel_password", "channel-password"],
valueType: "string",
description: "The target channel password (hashed) for the connect attempt."
};
@@ -708,6 +747,20 @@ export class Settings {
valueType: "boolean",
};
+ static readonly KEY_INVITE_SHORT_URL: ValuedRegistryKey = {
+ key: "invite_short_url",
+ defaultValue: true,
+ description: "Enable/disable the short url for the invite menu",
+ valueType: "boolean",
+ };
+
+ static readonly KEY_INVITE_ADVANCED_ENABLED: ValuedRegistryKey = {
+ key: "invite_advanced_enabled",
+ defaultValue: false,
+ description: "Enable/disable the advanced menu for the invite menu",
+ valueType: "boolean",
+ };
+
static readonly FN_LOG_ENABLED: (category: string) => RegistryKey = category => {
return {
key: "log." + category.toLowerCase() + ".enabled",
diff --git a/shared/js/tree/Channel.ts b/shared/js/tree/Channel.ts
index bfd39a13..474f897d 100644
--- a/shared/js/tree/Channel.ts
+++ b/shared/js/tree/Channel.ts
@@ -22,6 +22,7 @@ import {ClientIcon} from "svg-sprites/client-icons";
import { tr } from "tc-shared/i18n/localize";
import {EventChannelData} from "tc-shared/connectionlog/Definitions";
import {spawnChannelEditNew} from "tc-shared/ui/modal/channel-edit/Controller";
+import {spawnInviteGenerator} from "tc-shared/ui/modal/invite/Controller";
export enum ChannelType {
PERMANENT,
@@ -456,7 +457,7 @@ export class ChannelEntry extends ChannelTreeEntry {
name: bold(tr("Switch to channel")),
callback: () => this.joinChannel(),
visible: this !== this.channelTree.client.getClient()?.currentChannel()
- },{
+ }, {
type: contextmenu.MenuEntryType.ENTRY,
icon_class: "client-filetransfer",
name: bold(tr("Open channel file browser")),
@@ -482,6 +483,11 @@ export class ChannelEntry extends ChannelTreeEntry {
openChannelInfo(this);
},
icon_class: "client-about"
+ }, {
+ type: contextmenu.MenuEntryType.ENTRY,
+ name: tr("Invite People"),
+ callback: () => spawnInviteGenerator(this),
+ icon_class: ClientIcon.InviteBuddy
},
...(() => {
const local_client = this.channelTree.client.getClient();
diff --git a/shared/js/tree/Server.ts b/shared/js/tree/Server.ts
index c7023684..25bd29c8 100644
--- a/shared/js/tree/Server.ts
+++ b/shared/js/tree/Server.ts
@@ -13,6 +13,7 @@ import {spawnAvatarList} from "../ui/modal/ModalAvatarList";
import {Registry} from "../events";
import {ChannelTreeEntry, ChannelTreeEntryEvents} from "./ChannelTreeEntry";
import { tr } from "tc-shared/i18n/localize";
+import {spawnInviteGenerator} from "tc-shared/ui/modal/invite/Controller";
export class ServerProperties {
virtualserver_host: string = "";
@@ -209,7 +210,7 @@ export class ServerEntry extends ChannelTreeEntry {
type: contextmenu.MenuEntryType.ENTRY,
icon_class: "client-invite_buddy",
name: tr("Invite buddy"),
- callback: () => spawnInviteEditor(this.channelTree.client)
+ callback: () => spawnInviteGenerator(this)
}, {
type: contextmenu.MenuEntryType.HR,
name: ''
diff --git a/shared/js/ui/frames/side/ClientInfoRenderer.tsx b/shared/js/ui/frames/side/ClientInfoRenderer.tsx
index 92748b03..44035223 100644
--- a/shared/js/ui/frames/side/ClientInfoRenderer.tsx
+++ b/shared/js/ui/frames/side/ClientInfoRenderer.tsx
@@ -432,7 +432,7 @@ const ServerGroupRenderer = () => {
return (
- Channel group
+ Server groups
<>{body}>
);
diff --git a/shared/js/ui/modal/connect/Controller.ts b/shared/js/ui/modal/connect/Controller.ts
index 45c2c08e..5ce74de1 100644
--- a/shared/js/ui/modal/connect/Controller.ts
+++ b/shared/js/ui/modal/connect/Controller.ts
@@ -26,8 +26,8 @@ const kRegexDomain = /^(localhost|((([a-zA-Z0-9_-]{0,63}\.){0,253})?[a-zA-Z0-9_-
export type ConnectParameters = {
targetAddress: string,
- targetPassword?: string,
- targetPasswordHashed?: boolean,
+ serverPassword?: string,
+ serverPasswordHashed?: boolean,
nickname: string,
nicknameSpecified: boolean,
@@ -38,6 +38,7 @@ export type ConnectParameters = {
defaultChannel?: string | number,
defaultChannelPassword?: string,
+ defaultChannelPasswordHashed?: boolean,
}
class ConnectController {
@@ -272,8 +273,8 @@ class ConnectController {
profile: this.currentProfile,
- targetPassword: this.currentPassword,
- targetPasswordHashed: this.currentPasswordHashed
+ serverPassword: this.currentPassword,
+ serverPasswordHashed: this.currentPasswordHashed
};
}
diff --git a/shared/js/ui/modal/invite/Controller.ts b/shared/js/ui/modal/invite/Controller.ts
new file mode 100644
index 00000000..5ae07e25
--- /dev/null
+++ b/shared/js/ui/modal/invite/Controller.ts
@@ -0,0 +1,334 @@
+import {ChannelEntry} from "tc-shared/tree/Channel";
+import {ServerAddress, ServerEntry} from "tc-shared/tree/Server";
+import {Registry} from "tc-events";
+import {InviteChannel, InviteUiEvents, InviteUiVariables} from "tc-shared/ui/modal/invite/Definitions";
+import {createIpcUiVariableProvider, IpcUiVariableProvider} from "tc-shared/ui/utils/IpcVariable";
+import {spawnModal} from "tc-shared/ui/react-elements/modal";
+import {ConnectionHandler} from "tc-shared/ConnectionHandler";
+import {hashPassword} from "tc-shared/utils/helpers";
+import {LogCategory, logError} from "tc-shared/log";
+import {clientServiceInvite, clientServices} from "tc-shared/clientservice";
+import {Settings, settings} from "tc-shared/settings";
+
+class InviteController {
+ readonly connection: ConnectionHandler;
+ readonly events: Registry;
+ readonly variables: IpcUiVariableProvider;
+
+ private registeredEvents: (() => void)[] = [];
+
+ private readonly targetAddress: string;
+ private readonly targetServerPassword: string | undefined;
+
+ private readonly fallbackWebClientUrlBase: string;
+
+ private targetChannelId: number;
+ private targetChannelName: string;
+ private targetChannelPasswordHashed: string | undefined;
+ private targetChannelPasswordRaw: string | undefined;
+
+ private useToken: string;
+ private linkExpiresAfter: number | 0;
+
+ private inviteLinkError: string;
+ private inviteLinkShort: string;
+ private inviteLinkLong: string;
+ private inviteLinkExpireDate: number;
+
+ private showShortInviteLink: boolean;
+ private showAdvancedSettings: boolean;
+ private webClientUrlBase: string;
+
+ private inviteLinkUpdateExecuting: boolean;
+ private inviteLinkUpdatePending: boolean;
+
+ private linkAdminToken: string;
+
+ constructor(connection: ConnectionHandler, targetAddress: string, targetHashedServerPassword: string | undefined) {
+ this.connection = connection;
+ this.events = new Registry();
+ this.variables = createIpcUiVariableProvider();
+ this.registeredEvents = [];
+
+ if (document.location.protocol !== 'https:') {
+ /*
+ * Seems to be a test environment or the TeaClient for localhost where we dont have to use https.
+ */
+ this.fallbackWebClientUrlBase = "https://web.teaspeak.de/";
+ } else if (document.location.hostname === "localhost" || document.location.host.startsWith("127.")) {
+ this.fallbackWebClientUrlBase = "https://web.teaspeak.de/";
+ } else {
+ this.fallbackWebClientUrlBase = document.location.origin + document.location.pathname;
+ }
+
+ this.targetAddress = targetAddress;
+ this.targetServerPassword = targetHashedServerPassword;
+
+ this.targetChannelId = 0;
+
+ this.linkExpiresAfter = 0;
+
+ this.showShortInviteLink = settings.getValue(Settings.KEY_INVITE_SHORT_URL);
+ this.showAdvancedSettings = settings.getValue(Settings.KEY_INVITE_ADVANCED_ENABLED);
+
+ this.inviteLinkUpdateExecuting = false;
+ this.inviteLinkUpdatePending = false;
+
+ this.variables.setVariableProvider("generatedLink", () => {
+ if(typeof this.inviteLinkError === "string") {
+ return { status: "error", message: this.inviteLinkError };
+ } else if(typeof this.inviteLinkLong === "string") {
+ return { status: "success", shortUrl: this.inviteLinkShort, longUrl: this.inviteLinkLong, expireDate: this.inviteLinkExpireDate };
+ } else {
+ return { status: "generating" };
+ }
+ });
+ this.variables.setVariableProvider("availableChannels", () => {
+ const result: InviteChannel[] = [];
+ const walkChannel = (channel: ChannelEntry, depth: number) => {
+ result.push({ channelId: channel.channelId, channelName: channel.properties.channel_name, depth });
+
+ channel = channel.child_channel_head;
+ while(channel) {
+ walkChannel(channel, depth + 1);
+ channel = channel.channel_next;
+ }
+ };
+ this.connection.channelTree.rootChannel().forEach(channel => walkChannel(channel, 0));
+ return result;
+ });
+
+ this.variables.setVariableProvider("selectedChannel", () => this.targetChannelId);
+ this.variables.setVariableEditor("selectedChannel", newValue => {
+ const channel = this.connection.channelTree.findChannel(newValue);
+ if(!channel) {
+ return false;
+ }
+
+ this.selectChannel(channel);
+ });
+
+ this.variables.setVariableProvider("channelPassword", () => ({
+ hashed: this.targetChannelPasswordHashed,
+ raw: this.targetChannelPasswordRaw
+ }));
+ this.variables.setVariableEditorAsync("channelPassword", async newValue => {
+ this.targetChannelPasswordRaw = newValue.raw;
+ this.targetChannelPasswordHashed = await hashPassword(newValue.raw);
+ this.updateInviteLink();
+
+ return {
+ hashed: this.targetChannelPasswordHashed,
+ raw: this.targetChannelPasswordRaw
+ };
+ });
+
+ this.registeredEvents.push(this.connection.channelTree.events.on(["notify_channel_list_received", "notify_channel_created"], () => {
+ this.variables.sendVariable("availableChannels");
+ }));
+
+ this.registeredEvents.push(this.connection.channelTree.events.on("notify_channel_deleted", event => {
+ if(this.targetChannelId === event.channel.channelId) {
+ this.selectChannel(undefined);
+ }
+
+ this.variables.sendVariable("availableChannels");
+ }));
+
+ this.variables.setVariableProvider("shortLink", () => this.showShortInviteLink);
+ this.variables.setVariableEditor("shortLink", newValue => {
+ this.showShortInviteLink = newValue;
+ settings.setValue(Settings.KEY_INVITE_SHORT_URL, newValue);
+ });
+
+ this.variables.setVariableProvider("advancedSettings", () => this.showAdvancedSettings);
+ this.variables.setVariableEditor("advancedSettings", newValue => {
+ this.showAdvancedSettings = newValue;
+ settings.setValue(Settings.KEY_INVITE_ADVANCED_ENABLED, newValue);
+ });
+
+ this.variables.setVariableProvider("token", () => this.useToken);
+ this.variables.setVariableEditor("token", newValue => {
+ this.useToken = newValue;
+ this.updateInviteLink();
+ });
+
+ this.variables.setVariableProvider("expiresAfter", () => this.linkExpiresAfter);
+ this.variables.setVariableEditor("expiresAfter", newValue => {
+ this.linkExpiresAfter = newValue;
+ this.updateInviteLink();
+ });
+
+ this.variables.setVariableProvider("webClientUrlBase", () => ({ fallback: this.fallbackWebClientUrlBase, override: this.webClientUrlBase }));
+ this.variables.setVariableEditor("webClientUrlBase", newValue => {
+ this.webClientUrlBase = newValue.override;
+ this.updateInviteLink();
+ });
+ }
+
+ destroy() {
+ this.events.destroy();
+ this.variables.destroy();
+
+ this.registeredEvents?.forEach(callback => callback());
+ this.registeredEvents = undefined;
+ }
+
+ selectChannel(channel: ChannelEntry | undefined) {
+ if(channel) {
+ if(this.targetChannelId === channel.channelId) {
+ return;
+ }
+
+ this.targetChannelId = channel.channelId;
+ this.targetChannelName = channel.channelName();
+ this.targetChannelPasswordHashed = channel.cached_password();
+ this.targetChannelPasswordRaw = undefined;
+ } else if(this.targetChannelId === 0) {
+ return;
+ } else {
+ this.targetChannelId = 0;
+ this.targetChannelPasswordHashed = undefined;
+ this.targetChannelPasswordRaw = undefined;
+ }
+ this.updateInviteLink();
+ }
+
+ updateInviteLink() {
+ if(this.inviteLinkUpdateExecuting) {
+ this.inviteLinkUpdatePending = true;
+ return;
+ }
+
+ this.inviteLinkUpdateExecuting = true;
+ this.inviteLinkUpdatePending = true;
+
+ (async () => {
+ this.inviteLinkError = undefined;
+ this.inviteLinkShort = undefined;
+ this.inviteLinkLong = undefined;
+ this.variables.sendVariable("generatedLink");
+
+ while(this.inviteLinkUpdatePending) {
+ this.inviteLinkUpdatePending = false;
+
+ try {
+ await this.doUpdateInviteLink();
+ } catch (error) {
+ logError(LogCategory.GENERAL, tr("Failed to update invite link: %o"), error);
+ this.inviteLinkError = tr("Unknown error occurred");
+ }
+ }
+
+ this.variables.sendVariable("generatedLink");
+ this.inviteLinkUpdateExecuting = false;
+ })();
+ }
+
+ private async doUpdateInviteLink() {
+ this.inviteLinkError = undefined;
+ this.inviteLinkShort = undefined;
+ this.inviteLinkLong = undefined;
+
+ if(!clientServices.isSessionInitialized()) {
+ this.inviteLinkError = tr("Client services not available");
+ return;
+ }
+
+ const server = this.connection.channelTree.server;
+ try { await server.updateProperties(); } catch (_) {}
+
+ const propertiesInfo = {};
+ const propertiesConnect = {};
+
+ {
+ propertiesInfo["server-name"] = server.properties.virtualserver_name;
+ propertiesInfo["slots-used"] = server.properties.virtualserver_clientsonline.toString();
+ propertiesInfo["slots-max"] = server.properties.virtualserver_maxclients.toString();
+
+ propertiesConnect["server-address"] = this.targetAddress;
+ if(this.targetServerPassword) {
+ propertiesConnect["server-password"] = this.targetServerPassword;
+ }
+
+ if(this.targetChannelId > 0) {
+ propertiesConnect["channel"] = `/${this.targetChannelId}`;
+ propertiesInfo["channel-name"] = this.targetChannelName;
+
+ if(this.targetChannelPasswordHashed) {
+ propertiesConnect["channel-password"] = this.targetChannelPasswordHashed;
+ }
+ }
+
+ if(this.targetChannelPasswordHashed || this.targetServerPassword) {
+ propertiesConnect["passwords-hashed"] = "1";
+ }
+
+ const urlBase = this.webClientUrlBase || this.fallbackWebClientUrlBase;
+ if(new URL(urlBase).hostname !== "web.teaspeak.de") {
+ propertiesConnect["webclient-host"] = urlBase;
+ }
+ }
+
+ const result = await clientServiceInvite.createInviteLink(propertiesConnect, propertiesInfo, typeof this.linkAdminToken === "undefined", this.linkExpiresAfter);
+ if(result.status !== "success") {
+ logError(LogCategory.GENERAL, tr("Failed to register invite link: %o"), result.result);
+ this.inviteLinkError = tr("Server error") + " (" + result.result.type + ")";
+ return;
+ }
+
+ const inviteLink = result.unwrap();
+ this.linkAdminToken = inviteLink.adminToken;
+ this.inviteLinkShort = `https://teaspeak.de/${inviteLink.linkId}`;
+ this.inviteLinkLong = `https://join.teaspeak.de/invite/${inviteLink.linkId}`;
+ this.inviteLinkExpireDate = this.linkExpiresAfter;
+ }
+}
+
+export function spawnInviteGenerator(target: ChannelEntry | ServerEntry) {
+ let targetAddress: string, targetHashedServerPassword: string | undefined, serverName: string;
+
+ {
+ let address: ServerAddress;
+ if(target instanceof ServerEntry) {
+ address = target.remote_address;
+ serverName = target.properties.virtualserver_name;
+ } else if(target instanceof ChannelEntry) {
+ address = target.channelTree.server.remote_address;
+ serverName = target.channelTree.server.properties.virtualserver_name;
+ } else {
+ throw tr("invalid target");
+ }
+
+ const connection = target.channelTree.client;
+ const connectParameters = connection.getServerConnection().handshake_handler().parameters;
+ if(connectParameters.serverPassword) {
+ if(!connectParameters.serverPasswordHashed) {
+ throw tr("expected the target server password to be hashed");
+ }
+ targetHashedServerPassword = connectParameters.serverPassword;
+ }
+
+ if(!address) {
+ throw tr("missing target address");
+ }
+
+ if(address.host.indexOf(':') === -1) {
+ targetAddress = `${address.host}:${address.port}`;
+ } else {
+ targetAddress = `[${address.host}]:${address.port}`;
+ }
+ }
+
+ const controller = new InviteController(target.channelTree.client, targetAddress, targetHashedServerPassword);
+ if(target instanceof ChannelEntry) {
+ /* will implicitly update the invite link */
+ controller.selectChannel(target);
+ } else {
+ controller.updateInviteLink();
+ }
+
+ const modal = spawnModal("modal-invite", [ controller.events.generateIpcDescription(), controller.variables.generateConsumerDescription(), serverName ]);
+ modal.getEvents().on("destroy", () => controller.destroy());
+ modal.show().then(undefined);
+}
\ No newline at end of file
diff --git a/shared/js/ui/modal/invite/Definitions.ts b/shared/js/ui/modal/invite/Definitions.ts
new file mode 100644
index 00000000..bf01742d
--- /dev/null
+++ b/shared/js/ui/modal/invite/Definitions.ts
@@ -0,0 +1,39 @@
+
+export type InviteChannel = {
+ channelId: number,
+ channelName: string,
+ depth: number
+};
+
+export interface InviteUiVariables {
+ shortLink: boolean,
+ advancedSettings: boolean,
+
+ selectedChannel: number | 0,
+ channelPassword: {
+ raw: string | undefined,
+ hashed: string | undefined
+ },
+
+ token: string | undefined,
+ expiresAfter: number | 0,
+
+ webClientUrlBase: { override: string | undefined, fallback: string },
+
+ readonly availableChannels: InviteChannel[],
+
+ readonly generatedLink: {
+ status: "generating"
+ } | {
+ status: "error", message: string
+ } | {
+ status: "success",
+ longUrl: string,
+ shortUrl: string,
+ expireDate: number | 0
+ }
+}
+
+export interface InviteUiEvents {
+ action_close: {}
+}
\ No newline at end of file
diff --git a/shared/js/ui/modal/invite/Renderer.scss b/shared/js/ui/modal/invite/Renderer.scss
new file mode 100644
index 00000000..6c3152cc
--- /dev/null
+++ b/shared/js/ui/modal/invite/Renderer.scss
@@ -0,0 +1,215 @@
+@import "../../../../css/static/mixin";
+@import "../../../../css/static/properties";
+
+.container {
+ display: flex;
+ flex-direction: column;
+ justify-content: stretch;
+
+ width: 30em;
+ padding: 1em;
+
+ @include user-select(none);
+
+ .title {
+ color: #557edc;
+ text-transform: uppercase;
+ }
+}
+
+.containerOptions {
+ display: flex;
+ flex-direction: column;
+ justify-content: stretch;
+
+ margin-bottom: .5em;
+
+ .generalOptions {
+ display: flex;
+ flex-direction: row;
+ justify-content: stretch;
+
+ .general, .channel {
+ display: flex;
+ flex-direction: column;
+ justify-content: stretch;
+
+ width: 50%;
+ }
+ }
+
+ .advancedOptions {
+
+ }
+
+ .option {
+ margin-bottom: .5em;
+
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+
+ .optionTitle {
+
+ }
+
+ .optionValue {
+ height: 2em;
+ }
+ }
+}
+
+.containerOptionsAdvanced {
+ margin-bottom: .5em;
+
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+}
+
+.containerButtons {
+ margin-top: 1em;
+
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+}
+
+.containerLink {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+
+ .output {
+ position: relative;
+
+ color: #999999;
+ background-color: #28292b;
+
+ border: 1px #161616 solid;
+ border-radius: .2em;
+
+ padding: .5em;
+ padding-right: 1.5em;
+
+ flex-grow: 1;
+ flex-shrink: 1;
+
+ a {
+ @include text-dotdotdot();
+ }
+
+ &.generating {
+ a {
+ color: #606060;
+ }
+ }
+
+ &.errored {
+ a {
+ color: #e62222;
+ }
+ }
+
+ &.success, &.errored {
+ @include user-select(text);
+ }
+ }
+
+ .linkExpire {
+ font-size: .8em;
+ text-align: left;
+ color: #666;
+ margin-bottom: -1em;
+ }
+}
+
+
+.containerCopy {
+ position: absolute;
+
+ right: .5em;
+ top: 0;
+ bottom: 0;
+
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+
+ .button {
+ font-size: 1.3em;
+ padding: .1em;
+
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+
+ cursor: pointer;
+ border-radius: .115em;
+
+ transition: background-color .25s ease-in-out;
+
+ &:hover {
+ background-color: #ffffff10;
+ }
+
+ img {
+ height: 1em;
+ width: 1em;
+ }
+ }
+
+ $copied-color: #222224;
+ .copied {
+ opacity: 0;
+ box-shadow: 0 8px 16px rgba(0,0,0,0.24);
+
+ position: absolute;
+
+ width: 4em;
+ height: 1.5em;
+
+ background: $copied-color;
+
+ top: 100%;
+ left: 50%;
+
+ border-radius: .1em;
+ margin-left: -2em;
+
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+
+ transition: opacity .1s ease-in-out;
+
+ &.shown {
+ opacity: 1;
+ }
+
+ a {
+ color: #389738;
+ z-index: 1;
+ align-self: center;
+ }
+
+ $width: .5em;
+ &::before {
+ content: ' ';
+
+ position: absolute;
+
+ left: 50%;
+ top: 0;
+ margin-left: -$width / 2;
+ margin-top: -$width / 2;
+
+ transform: rotate(45deg);
+
+ width: $width;
+ height: $width;
+
+ background: $copied-color;
+ }
+ }
+}
\ No newline at end of file
diff --git a/shared/js/ui/modal/invite/Renderer.tsx b/shared/js/ui/modal/invite/Renderer.tsx
new file mode 100644
index 00000000..35302b5d
--- /dev/null
+++ b/shared/js/ui/modal/invite/Renderer.tsx
@@ -0,0 +1,416 @@
+import * as React from "react";
+import {useContext, useEffect, useState} from "react";
+import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions";
+import {Translatable} from "tc-shared/ui/react-elements/i18n";
+import {IpcRegistryDescription, Registry} from "tc-events";
+import {InviteUiEvents, InviteUiVariables} from "tc-shared/ui/modal/invite/Definitions";
+import {UiVariableConsumer} from "tc-shared/ui/utils/Variable";
+import {Button} from "tc-shared/ui/react-elements/Button";
+import {createIpcUiVariableConsumer, IpcVariableDescriptor} from "tc-shared/ui/utils/IpcVariable";
+import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
+import {ClientIcon} from "svg-sprites/client-icons";
+import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
+import {copyToClipboard} from "tc-shared/utils/helpers";
+import {ControlledBoxedInputField, ControlledSelect} from "tc-shared/ui/react-elements/InputField";
+import {useTr} from "tc-shared/ui/react-elements/Helper";
+import {Checkbox} from "tc-shared/ui/react-elements/Checkbox";
+import * as moment from 'moment';
+
+const cssStyle = require("./Renderer.scss");
+
+const EventsContext = React.createContext>(undefined);
+const VariablesContext = React.createContext>(undefined);
+
+const OptionChannel = React.memo(() => {
+ const variables = useContext(VariablesContext);
+ const availableChannels = variables.useReadOnly("availableChannels", undefined, []);
+ const selectedChannel = variables.useVariable("selectedChannel", undefined, 0);
+
+ return (
+
+
+ Automatically join channel
+
+
+ {
+ const value = parseInt(event.target.value);
+ if(isNaN(value)) {
+ return;
+ }
+
+ selectedChannel.setValue(value);
+ }}
+ >
+ {useTr("No specific channel")}
+ {useTr("Available channels:")}
+ {availableChannels.map(channel => (
+ {channel.channelName}
+ )) as any}
+
+
+
+ );
+});
+
+const OptionChannelPassword = React.memo(() => {
+ const variables = useContext(VariablesContext);
+ const selectedChannel = variables.useReadOnly("selectedChannel", undefined, 0);
+ const channelPassword = variables.useVariable("channelPassword", undefined, { raw: undefined, hashed: undefined });
+
+ let body;
+ if(selectedChannel === 0) {
+ body = (
+ {}} />
+ );
+ } else if(channelPassword.localValue.hashed && !channelPassword.localValue.raw) {
+ body = (
+ channelPassword.setValue({ hashed: channelPassword.localValue.hashed, raw: newValue }, true)}
+ />
+ );
+ } else {
+ body = (
+ channelPassword.setValue({ hashed: channelPassword.localValue.hashed, raw: newValue }, true)}
+ onBlur={() => channelPassword.setValue(channelPassword.localValue, false)}
+ finishOnEnter={true}
+ />
+ );
+ }
+
+ return (
+
+
Channel password
+
+ {body}
+
+
+ );
+})
+
+const OptionGeneralShortLink = React.memo(() => {
+ const variables = useContext(VariablesContext);
+ const showShortUrl = variables.useVariable("shortLink", undefined, true);
+
+ return (
+
+ showShortUrl.setValue(newValue)}
+ value={showShortUrl.localValue}
+ label={Use short URL }
+ />
+
+ )
+})
+
+const OptionGeneralShowAdvanced = React.memo(() => {
+ const variables = useContext(VariablesContext);
+ const showShortUrl = variables.useVariable("advancedSettings", undefined, false);
+
+ return (
+
+ showShortUrl.setValue(newValue)}
+ value={showShortUrl.localValue}
+ label={Advanced settings }
+ />
+
+ )
+})
+
+const OptionAdvancedToken = React.memo(() => {
+ const variables = useContext(VariablesContext);
+ const currentToken = variables.useVariable("token", undefined, "");
+
+ return (
+
+
Token
+
+ currentToken.setValue(newValue, true)}
+ onBlur={() => currentToken.setValue(currentToken.localValue, false)}
+ finishOnEnter={true}
+ />
+
+
+ );
+});
+
+const OptionAdvancedWebUrlBase = React.memo(() => {
+ const variables = useContext(VariablesContext);
+ const currentUrl = variables.useVariable("webClientUrlBase", undefined, { override: undefined, fallback: undefined });
+
+ return (
+
+
WebClient URL
+
+ currentUrl.setValue({ fallback: currentUrl.localValue.fallback, override: newValue }, true)}
+ onBlur={() => currentUrl.setValue(currentUrl.localValue, false)}
+ finishOnEnter={true}
+ />
+
+
+ );
+});
+
+type ExpirePreset = {
+ name: () => string,
+ seconds: number
+};
+
+const ExpirePresets: ExpirePreset[] = [
+ { name: () => tr("5 Minutes"), seconds: 5 * 60 },
+ { name: () => tr("1 hour"), seconds: 60 * 60 },
+ { name: () => tr("24 hours"), seconds: 24 * 60 * 60 },
+ { name: () => tr("1 Week"), seconds: 7 * 24 * 60 * 60 },
+ { name: () => tr("1 Month"), seconds: 31 * 24 * 60 * 60 },
+]
+
+const OptionAdvancedExpires = React.memo(() => {
+ const variables = useContext(VariablesContext);
+ const expiresAfter = variables.useVariable("expiresAfter", undefined, 0);
+
+ let presetSelected = -2;
+ if(expiresAfter.localValue === 0) {
+ presetSelected = -1;
+ } else {
+ const difference = expiresAfter.localValue - Date.now() / 1000;
+ if(difference > 0) {
+ for(let index = 0; index < ExpirePresets.length; index++) {
+ if(Math.abs(difference - ExpirePresets[index].seconds) <= 60 * 60) {
+ presetSelected = index;
+ break;
+ }
+ }
+ }
+ }
+
+ return (
+
+
Link expire time
+
+ {
+ const value = parseInt(event.target.value);
+ if(isNaN(value)) {
+ return;
+ }
+
+ if(value === -1) {
+ expiresAfter.setValue(0);
+ } else if(value >= 0) {
+ expiresAfter.setValue(Math.floor(Date.now() / 1000 + ExpirePresets[value].seconds));
+ }
+ }}
+ >
+ {useTr("Unknown")}
+ {useTr("never")}
+ {
+ ExpirePresets.map((preset, index) => (
+ {preset.name()}
+ )) as any
+ }
+
+
+
+ );
+});
+
+const OptionsAdvanced = React.memo(() => {
+ return (
+
+
Advanced options
+
+
+
+
+ )
+});
+
+const Options = React.memo(() => {
+ const variables = useContext(VariablesContext);
+ const showAdvanced = variables.useReadOnly("advancedSettings", undefined, false);
+
+ return (
+
+
+ {showAdvanced ?
: undefined}
+
+ );
+});
+
+const ButtonCopy = React.memo((props: { onCopy: () => void, disabled: boolean }) => {
+ const [ showTimeout, setShowTimeout ] = useState(0);
+
+ const now = Date.now();
+ useEffect(() => {
+ if(now >= showTimeout) {
+ return;
+ }
+
+ const timeout = setTimeout(() => setShowTimeout(0), showTimeout - now);
+ return () => clearTimeout(timeout);
+ });
+
+ return (
+
+
{
+ if(props.disabled) {
+ return;
+ }
+
+ props.onCopy();
+ setShowTimeout(Date.now() + 1750);
+ }}>
+
+
+
+
+ );
+});
+
+const LinkExpire = (props: { date: number | 0 | -1 }) => {
+ let value;
+ if(props.date === -1) {
+ value = ;
+ } else if(props.date === 0) {
+ value = Link expires never ;
+ } else {
+ value = Link expires at {moment(props.date * 1000).format('LLLL')} ;
+ }
+
+ return (
+ {value}
+ );
+}
+
+const Link = React.memo(() => {
+ const variables = useContext(VariablesContext);
+ const shortLink = variables.useReadOnly("shortLink", undefined, true);
+ const link = variables.useReadOnly("generatedLink", undefined, { status: "generating" });
+
+ let className, value, copyValue;
+ switch (link.status) {
+ case "generating":
+ className = cssStyle.generating;
+ value = Generating link ;
+ break;
+
+ case "error":
+ className = cssStyle.errored;
+ copyValue = link.message;
+ value = link.message;
+ break;
+
+ case "success":
+ className = cssStyle.success;
+ copyValue = shortLink ? link.shortUrl : link.longUrl;
+ value = copyValue;
+ break;
+ }
+
+ return (
+
+
Link
+
+
{value}
+
{
+ if(copyValue) {
+ copyToClipboard(copyValue);
+ }
+ }} />
+
+
+
+ );
+});
+
+const Buttons = () => {
+ const events = useContext(EventsContext);
+
+ return (
+
+ events.fire("action_close")}>
+ Close
+
+
+ )
+}
+
+class ModalInvite extends AbstractModal {
+ private readonly events: Registry;
+ private readonly variables: UiVariableConsumer;
+ private readonly serverName: string;
+
+ constructor(events: IpcRegistryDescription, variables: IpcVariableDescriptor, serverName: string) {
+ super();
+
+ this.events = Registry.fromIpcDescription(events);
+ this.variables = createIpcUiVariableConsumer(variables);
+ this.serverName = serverName;
+ }
+
+ renderBody(): React.ReactElement {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ renderTitle(): string | React.ReactElement {
+ return <>Invite People to {this.serverName}>;
+ }
+}
+export = ModalInvite;
+
+/*
+const modal = spawnModal("global-settings-editor", [ events.generateIpcDescription() ], { popoutable: true, popedOut: false });
+modal.show();
+modal.getEvents().on("destroy", () => {
+ events.fire("notify_destroy");
+ events.destroy();
+});
+ */
\ No newline at end of file
diff --git a/shared/js/ui/react-elements/InputField.tsx b/shared/js/ui/react-elements/InputField.tsx
index a1bae6f1..aa3d7a6f 100644
--- a/shared/js/ui/react-elements/InputField.tsx
+++ b/shared/js/ui/react-elements/InputField.tsx
@@ -4,6 +4,89 @@ import {joinClassList} from "tc-shared/ui/react-elements/Helper";
const cssStyle = require("./InputField.scss");
+export const ControlledBoxedInputField = (props: {
+ prefix?: string;
+ suffix?: string;
+
+ placeholder?: string;
+
+ disabled?: boolean;
+ editable?: boolean;
+
+ value?: string;
+
+ rightIcon?: () => ReactElement;
+ leftIcon?: () => ReactElement;
+ inputBox?: () => ReactElement; /* if set the onChange and onInput will not work anymore! */
+
+ isInvalid?: boolean;
+
+ className?: string;
+ maxLength?: number,
+
+ size?: "normal" | "large" | "small";
+ type?: "text" | "password" | "number";
+
+ onChange: (newValue?: string) => void,
+ onEnter?: () => void,
+
+ onFocus?: () => void,
+ onBlur?: () => void,
+
+ finishOnEnter?: boolean,
+}) => {
+
+ return (
+ props.onBlur()}
+ >
+ {props.leftIcon ? props.leftIcon() : ""}
+ {props.prefix ?
{props.prefix} : undefined}
+ {props.inputBox ?
+
{props.inputBox()} :
+
+
props.onChange(event.currentTarget.value)}
+ onKeyPress={event => {
+ if(event.key === "Enter") {
+ if(props.finishOnEnter) {
+ event.currentTarget.blur();
+ }
+
+ if(props.onEnter) {
+ props.onEnter();
+ }
+ }
+ }}
+ />
+ }
+ {props.suffix ?
{props.suffix} : undefined}
+ {props.rightIcon ? props.rightIcon() : ""}
+
+ );
+}
+
export interface BoxedInputFieldProperties {
prefix?: string;
suffix?: string;
@@ -33,6 +116,8 @@ export interface BoxedInputFieldProperties {
onChange?: (newValue: string) => void;
onInput?: (newValue: string) => void;
+
+ finishOnEnter?: boolean,
}
export interface BoxedInputFieldState {
diff --git a/shared/js/ui/react-elements/modal/Definitions.ts b/shared/js/ui/react-elements/modal/Definitions.ts
index 78b3da2c..f5336015 100644
--- a/shared/js/ui/react-elements/modal/Definitions.ts
+++ b/shared/js/ui/react-elements/modal/Definitions.ts
@@ -1,10 +1,13 @@
import {IpcRegistryDescription, Registry} from "tc-shared/events";
import {VideoViewerEvents} from "tc-shared/video-viewer/Definitions";
-import {ReactElement} from "react";
-import * as React from "react";
import {ChannelEditEvents} from "tc-shared/ui/modal/channel-edit/Definitions";
import {EchoTestEvents} from "tc-shared/ui/modal/echo-test/Definitions";
import {ModalGlobalSettingsEditorEvents} from "tc-shared/ui/modal/global-settings-editor/Definitions";
+import {InviteUiEvents, InviteUiVariables} from "tc-shared/ui/modal/invite/Definitions";
+
+import {ReactElement} from "react";
+import * as React from "react";
+import {IpcVariableDescriptor} from "tc-shared/ui/utils/IpcVariable";
export type ModalType = "error" | "warning" | "info" | "none";
export type ModalRenderType = "page" | "dialog";
@@ -124,5 +127,10 @@ export interface ModalConstructorArguments {
"conversation": any,
"css-editor": any,
"channel-tree": any,
- "modal-connect": any
+ "modal-connect": any,
+ "modal-invite": [
+ /* events */ IpcRegistryDescription,
+ /* variables */ IpcVariableDescriptor,
+ /* serverName */ string
+ ]
}
\ No newline at end of file
diff --git a/shared/js/ui/react-elements/modal/Registry.ts b/shared/js/ui/react-elements/modal/Registry.ts
index 29d39fae..833294ed 100644
--- a/shared/js/ui/react-elements/modal/Registry.ts
+++ b/shared/js/ui/react-elements/modal/Registry.ts
@@ -66,3 +66,10 @@ registerModal({
classLoader: async () => await import("tc-shared/ui/modal/connect/Renderer"),
popoutSupported: true
});
+
+registerModal({
+ modalId: "modal-invite",
+ classLoader: async () => await import("tc-shared/ui/modal/invite/Renderer"),
+ popoutSupported: true
+});
+
diff --git a/shared/js/ui/utils/IpcVariable.ts b/shared/js/ui/utils/IpcVariable.ts
index 1bf82315..1ca545d6 100644
--- a/shared/js/ui/utils/IpcVariable.ts
+++ b/shared/js/ui/utils/IpcVariable.ts
@@ -2,7 +2,7 @@ import {UiVariableConsumer, UiVariableMap, UiVariableProvider} from "tc-shared/u
import {guid} from "tc-shared/crypto/uid";
import {LogCategory, logWarn} from "tc-shared/log";
-class IpcUiVariableProvider extends UiVariableProvider {
+export class IpcUiVariableProvider extends UiVariableProvider {
readonly ipcChannelId: string;
private broadcastChannel: BroadcastChannel;
@@ -146,7 +146,6 @@ class IpcUiVariableConsumer extends UiVariableC
private handleIpcMessage(message: any, _source: MessageEventSource | null) {
if(message.type === "notify") {
- console.error("Received notify %s", message.variable);
this.notifyRemoteVariable(message.variable, message.customData, message.value);
} else if(message.type === "edit-result") {
const payload = this.editListener[message.token];
diff --git a/shared/js/ui/utils/Variable.ts b/shared/js/ui/utils/Variable.ts
index b3fa3fa7..656e740c 100644
--- a/shared/js/ui/utils/Variable.ts
+++ b/shared/js/ui/utils/Variable.ts
@@ -45,6 +45,10 @@ export abstract class UiVariableProvider {
this.variableProvider[variable as any] = provider;
}
+ /**
+ * @param variable
+ * @param editor If the editor returns `false` or a new variable, such variable will be used
+ */
setVariableEditor(variable: T, editor: UiVariableEditor) {
this.variableEditor[variable as any] = editor;
}
@@ -247,7 +251,7 @@ export abstract class UiVariableConsumer {
/* Variable constructor */
cacheEntry.useCount++;
- if(cacheEntry.status === "loading") {
+ if(cacheEntry.status === "loaded") {
return {
status: "set",
value: cacheEntry.currentValue