diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index 19bd34cd..aff6de1e 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -281,17 +281,32 @@ export class ConnectionHandler { client_nickname: parameters.nickname }); - this.channelTree.initialiseHead(parameters.targetAddress, resolvedAddress); + this.channelTree.initialiseHead(parameters.targetAddress, parsedAddress); /* hash the password if not already hashed */ - if(parameters.targetPassword && !parameters.targetPasswordHashed) { + if(parameters.serverPassword && !parameters.serverPasswordHashed) { try { - parameters.targetPassword = await hashPassword(parameters.targetPassword); - parameters.targetPasswordHashed = true; + parameters.serverPassword = await hashPassword(parameters.serverPassword); + parameters.serverPasswordHashed = true; } catch(error) { logError(LogCategory.CLIENT, tr("Failed to hash connect password: %o"), error); createErrorModal(tr("Error while hashing password"), tr("Failed to hash server password!
") + error).open(); + /* FIXME: Abort connection attempt */ + } + if(this.connectAttemptId !== localConnectionAttemptId) { + /* Our attempt has been aborted */ + return; + } + } + + if(parameters.defaultChannelPassword && !parameters.defaultChannelPasswordHashed) { + try { + parameters.defaultChannelPassword = await hashPassword(parameters.defaultChannelPassword); + parameters.defaultChannelPasswordHashed = true; + } catch(error) { + logError(LogCategory.CLIENT, tr("Failed to hash channel password: %o"), error); + createErrorModal(tr("Error while hashing password"), tr("Failed to hash channel password!
") + error).open(); /* FIXME: Abort connection attempt */ } @@ -332,6 +347,7 @@ export class ConnectionHandler { } this.handleDisconnect(DisconnectReason.DNS_FAILED, error); + return; } } else { this.handleDisconnect(DisconnectReason.DNS_FAILED, tr("Unable to resolve hostname")); @@ -343,7 +359,7 @@ export class ConnectionHandler { } else { this.currentConnectId = await connectionHistory.logConnectionAttempt({ nickname: parameters.nicknameSpecified ? parameters.nickname : undefined, - hashedPassword: parameters.targetPassword, /* Password will be hashed by now! */ + hashedPassword: parameters.serverPassword, /* Password will be hashed by now! */ targetAddress: parameters.targetAddress, }); } @@ -359,8 +375,8 @@ export class ConnectionHandler { nickname: parameters.nickname, nicknameSpecified: true, - targetPassword: parameters.password?.password, - targetPasswordHashed: parameters.password?.hashed, + serverPassword: parameters.password?.password, + serverPasswordHashed: parameters.password?.hashed, defaultChannel: parameters?.channel?.target, defaultChannelPassword: parameters?.channel?.password, @@ -970,9 +986,9 @@ export class ConnectionHandler { return { channel: targetChannel ? {target: "/" + targetChannel.channelId, password: targetChannel.cached_password()} : undefined, nickname: name, - password: connectParameters.targetPassword ? { - password: connectParameters.targetPassword, - hashed: connectParameters.targetPasswordHashed + password: connectParameters.serverPassword ? { + password: connectParameters.serverPassword, + hashed: connectParameters.serverPasswordHashed } : undefined } } diff --git a/shared/js/clientservice/GeoLocation.ts b/shared/js/clientservice/GeoLocation.ts deleted file mode 100644 index 8a877f2c..00000000 --- a/shared/js/clientservice/GeoLocation.ts +++ /dev/null @@ -1,173 +0,0 @@ -import * as loader from "tc-loader"; -import {Stage} from "tc-loader"; -import {LogCategory, logTrace} from "tc-shared/log"; -import jsonp from 'simple-jsonp-promise'; - -interface GeoLocationInfo { - /* The country code */ - country: string, - - city?: string, - region?: string, - timezone?: string -} - -interface GeoLocationResolver { - name() : string; - resolve() : Promise; -} - -const kLocalCacheKey = "geo-info"; -type GeoLocationCache = { - version: 1, - - timestamp: number, - info: GeoLocationInfo, -} - -class GeoLocationProvider { - private readonly resolver: GeoLocationResolver[]; - private currentResolverIndex: number; - - private cachedInfo: GeoLocationInfo | undefined; - private lookupPromise: Promise; - - constructor() { - this.resolver = [ - new GeoResolverIpInfo(), - new GeoResolverIpData() - ]; - this.currentResolverIndex = 0; - } - - loadCache() { - this.doLoadCache(); - if(!this.cachedInfo) { - this.lookupPromise = this.doQueryInfo(); - } - } - - private doLoadCache() : GeoLocationInfo { - try { - const rawItem = localStorage.getItem(kLocalCacheKey); - if(!rawItem) { - return undefined; - } - - const info: GeoLocationCache = JSON.parse(rawItem); - if(info.version !== 1) { - throw tr("invalid version number"); - } - - if(info.timestamp + 2 * 24 * 60 * 60 * 1000 < Date.now()) { - throw tr("cache is too old"); - } - - if(info.timestamp + 2 * 60 * 60 * 1000 > Date.now()) { - logTrace(LogCategory.GENERAL, tr("Geo cache is less than 2hrs old. Don't updating.")); - this.lookupPromise = Promise.resolve(info.info); - } else { - this.lookupPromise = this.doQueryInfo(); - } - - this.cachedInfo = info.info; - } catch (error) { - logTrace(LogCategory.GENERAL, tr("Failed to load geo resolve cache: %o"), error); - } - } - - async queryInfo(timeout: number) : Promise { - return await new Promise(resolve => { - if(!this.lookupPromise) { - resolve(this.cachedInfo); - return; - } - - const timeoutId = typeof timeout === "number" ? setTimeout(() => resolve(this.cachedInfo), timeout) : -1; - this.lookupPromise.then(result => { - clearTimeout(timeoutId); - resolve(result); - }); - }); - } - - - private async doQueryInfo() : Promise { - while(this.currentResolverIndex < this.resolver.length) { - const resolver = this.resolver[this.currentResolverIndex++]; - try { - const info = await resolver.resolve(); - logTrace(LogCategory.GENERAL, tr("Successfully resolved geo info from %s: %o"), resolver.name(), info); - - localStorage.setItem(kLocalCacheKey, JSON.stringify({ - version: 1, - timestamp: Date.now(), - info: info - } as GeoLocationCache)); - return info; - } catch (error) { - logTrace(LogCategory.GENERAL, tr("Geo resolver %s failed: %o. Trying next one."), resolver.name(), error); - } - } - - logTrace(LogCategory.GENERAL, tr("All geo resolver failed.")); - return undefined; - } -} - -class GeoResolverIpData implements GeoLocationResolver { - name(): string { - return "ipdata.co"; - } - - async resolve(): Promise { - const response = await fetch("https://api.ipdata.co/?api-key=test"); - const json = await response.json(); - - if(!("country_code" in json)) { - throw tr("missing country code"); - } - - return { - country: json["country_code"], - region: json["region"], - city: json["city"], - timezone: json["time_zone"]["name"] - } - } - -} - -class GeoResolverIpInfo implements GeoLocationResolver { - name(): string { - return "ipinfo.io"; - } - - async resolve(): Promise { - const response = await jsonp("http://ipinfo.io"); - if(!("country" in response)) { - throw tr("missing country"); - } - - return { - country: response["country"], - - city: response["city"], - region: response["region"], - timezone: response["timezone"] - } - } - -} - -export let geoLocationProvider: GeoLocationProvider; - -/* The client services depend on this */ -loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { - priority: 35, - function: async () => { - geoLocationProvider = new GeoLocationProvider(); - geoLocationProvider.loadCache(); - }, - name: "geo services" -}); \ No newline at end of file diff --git a/shared/js/clientservice/Messages.d.ts b/shared/js/clientservice/Messages.d.ts deleted file mode 100644 index cd3504e1..00000000 --- a/shared/js/clientservice/Messages.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* Basic message declarations */ -export type Message = - | { type: "Command"; token: string; command: MessageCommand } - | { type: "CommandResult"; token: string | null; result: MessageCommandResult } - | { type: "Notify"; notify: MessageNotify }; - -export type MessageCommand = - | { type: "SessionInitialize"; payload: CommandSessionInitialize } - | { type: "SessionInitializeAgent"; payload: CommandSessionInitializeAgent } - | { type: "SessionUpdateLocale"; payload: CommandSessionUpdateLocale }; - -export type MessageCommandResult = - | { type: "Success" } - | { type: "GenericError"; error: string } - | { type: "ConnectionTimeout" } - | { type: "ConnectionClosed" } - | { type: "ClientSessionUninitialized" } - | { type: "ServerInternalError" } - | { type: "ParameterInvalid"; parameter: string } - | { type: "CommandParseError"; error: string } - | { type: "CommandEnqueueError" } - | { type: "CommandNotFound" } - | { type: "SessionAlreadyInitialized" } - | { type: "SessionAgentAlreadyInitialized" } - | { type: "SessionNotInitialized" }; - -export type MessageNotify = - | { type: "NotifyClientsOnline"; payload: NotifyClientsOnline }; - -/* All commands */ -export type CommandSessionInitialize = { anonymize_ip: boolean }; - -export type CommandSessionInitializeAgent = { session_type: number; platform: string | null; platform_version: string | null; architecture: string | null; client_version: string | null; ui_version: string | null }; - -export type CommandSessionUpdateLocale = { ip_country: string | null; selected_locale: string | null; local_timestamp: number }; - -/* Notifies */ -export type NotifyClientsOnline = { users_online: { [key: number]: number }; unique_users_online: { [key: number]: number }; total_users_online: number; total_unique_users_online: number }; \ No newline at end of file diff --git a/shared/js/clientservice/index.ts b/shared/js/clientservice/index.ts index 23104b84..220b33db 100644 --- a/shared/js/clientservice/index.ts +++ b/shared/js/clientservice/index.ts @@ -1,443 +1,70 @@ import * as loader from "tc-loader"; import {Stage} from "tc-loader"; -import {LogCategory, logDebug, logError, logInfo, logTrace, logWarn} from "tc-shared/log"; -import {Registry} from "tc-shared/events"; -import { - CommandSessionInitializeAgent, CommandSessionUpdateLocale, - Message, - MessageCommand, - MessageCommandResult, - MessageNotify, - NotifyClientsOnline -} from "./Messages"; -import {config, tr} from "tc-shared/i18n/localize"; -import {geoLocationProvider} from "tc-shared/clientservice/GeoLocation"; -import translation_config = config.translation_config; +import {config} from "tc-shared/i18n/localize"; import {getBackend} from "tc-shared/backend"; +import {ClientServiceConfig, ClientServiceInvite, ClientServices, ClientSessionType, LocalAgent} from "tc-services"; -const kApiVersion = 1; -const kVerbose = true; - -type ConnectionState = "disconnected" | "connecting" | "connected" | "reconnect-pending"; -type PendingCommand = { - resolve: (result: MessageCommandResult) => void, - timeout: number -}; - -interface ClientServiceConnectionEvents { - notify_state_changed: { oldState: ConnectionState, newState: ConnectionState }, - notify_notify_received: { notify: MessageNotify } -} - -let tokenIndex = 0; -class ClientServiceConnection { - readonly events: Registry; - readonly verbose: boolean; - readonly reconnectInterval: number; - - private reconnectTimeout: number; - private connectionState: ConnectionState; - private connection: WebSocket; - - private pendingCommands: {[key: string]: PendingCommand} = {}; - - constructor(reconnectInterval: number, verbose: boolean) { - this.events = new Registry(); - this.reconnectInterval = reconnectInterval; - this.verbose = verbose; - } - - destroy() { - this.disconnect(); - this.events.destroy(); - } - - getState() : ConnectionState { - return this.connectionState; - } - - private setState(newState: ConnectionState) { - if(this.connectionState === newState) { - return; - } - - const oldState = this.connectionState; - this.connectionState = newState; - this.events.fire("notify_state_changed", { oldState, newState }) - } - - connect() { - this.disconnect(); - - this.setState("connecting"); - - 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 => { - if(this.verbose) { - logInfo(LogCategory.STATISTICS, tr("Lost connection to statistics server (Connection closed). Reason: %s"), event.reason ? `${event.reason} (${event.code})` : event.code); - } - - this.handleConnectionLost(); - }; - - this.connection.onopen = () => { - if(this.verbose) { - logDebug(LogCategory.STATISTICS, tr("Connection established.")); - } - - this.setState("connected"); - } - - this.connection.onerror = () => { - if(this.connectionState === "connecting") { - if(this.verbose) { - logDebug(LogCategory.STATISTICS, tr("Failed to connect to target server.")); - } - - this.handleConnectFail(); - } else { - if(this.verbose) { - logWarn(LogCategory.STATISTICS, tr("Received web socket error which indicates that the connection has been closed.")); - } - - this.handleConnectionLost(); - } - }; - - this.connection.onmessage = event => { - if(typeof event.data !== "string") { - if(this.verbose) { - logWarn(LogCategory.STATISTICS, tr("Receved non text message: %o"), event.data); - } - - return; - } - - this.handleServerMessage(event.data); - }; - } - - disconnect() { - if(this.connection) { - this.connection.onclose = undefined; - this.connection.onopen = undefined; - this.connection.onmessage = undefined; - this.connection.onerror = undefined; - - this.connection.close(); - this.connection = undefined; - } - - for(const command of Object.values(this.pendingCommands)) { - command.resolve({ type: "ConnectionClosed" }); - } - this.pendingCommands = {}; - - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = undefined; - - this.setState("disconnected"); - } - - cancelReconnect() { - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = undefined; - - if(this.connectionState === "reconnect-pending") { - this.setState("disconnected"); - } - } - - async executeCommand(command: MessageCommand) : Promise { - if(this.connectionState !== "connected") { - return { type: "ConnectionClosed" }; - } - - const token = "tk-" + ++tokenIndex; - try { - this.connection.send(JSON.stringify({ - type: "Command", - token: token, - command: command - } as Message)); - } catch (error) { - if(this.verbose) { - logError(LogCategory.STATISTICS, tr("Failed to send command: %o"), error); - } - - return { type: "GenericError", error: tr("Failed to send command") }; - } - - return await new Promise(resolve => { - const proxiedResolve = (result: MessageCommandResult) => { - clearTimeout(this.pendingCommands[token]?.timeout); - delete this.pendingCommands[token]; - resolve(result); - }; - - this.pendingCommands[token] = { - resolve: proxiedResolve, - timeout: setTimeout(() => proxiedResolve({ type: "ConnectionTimeout" }), 5000) - }; - }); - } - - private handleConnectFail() { - this.disconnect(); - this.executeReconnect(); - } - - private handleConnectionLost() { - this.disconnect(); - this.executeReconnect(); - } - - private executeReconnect() { - if(!this.reconnectInterval) { - return; - } - - if(this.verbose) { - logInfo(LogCategory.STATISTICS, tr("Scheduling reconnect in %dms"), this.reconnectInterval); - } - - this.reconnectTimeout = setTimeout(() => this.connect(), this.reconnectInterval); - this.setState("reconnect-pending"); - } - - private handleServerMessage(message: string) { - let data: Message; - try { - data = JSON.parse(message); - } catch (_error) { - if(this.verbose) { - logWarn(LogCategory.STATISTICS, tr("Received message which isn't parsable as JSON.")); - } - return; - } - - if(data.type === "Command") { - if(this.verbose) { - logWarn(LogCategory.STATISTICS, tr("Received message of type command. The server should not send these. Message: %o"), data); - } - - /* Well this is odd. We should never receive such */ - } else if(data.type === "CommandResult") { - if(data.token === null) { - if(this.verbose) { - logWarn(LogCategory.STATISTICS, tr("Received general error: %o"), data.result); - } - } else if(this.pendingCommands[data.token]) { - /* The entry itself will be cleaned up by the resolve callback */ - this.pendingCommands[data.token].resolve(data.result); - } else if(this.verbose) { - logWarn(LogCategory.STATISTICS, tr("Received command result for unknown token: %o"), data.token); - } - } else if(data.type === "Notify") { - this.events.fire("notify_notify_received", { notify: data.notify }); - } else if(this.verbose) { - logWarn(LogCategory.STATISTICS, tr("Received message with invalid type: %o"), (data as any).type); - } - } -} - -export class ClientServices { - private connection: ClientServiceConnection; - - private sessionInitialized: boolean; - private retryTimer: number; - - private initializeAgentId: number; - private initializeLocaleId: number; - - constructor() { - this.initializeAgentId = 0; - this.initializeLocaleId = 0; - - this.sessionInitialized = false; - this.connection = new ClientServiceConnection(5000, kVerbose); - this.connection.events.on("notify_state_changed", event => { - if(event.newState !== "connected") { - this.sessionInitialized = false; - return; - } - - logInfo(LogCategory.STATISTICS, tr("Connected successfully. Initializing session.")); - this.executeCommandWithRetry({ type: "SessionInitialize", payload: { anonymize_ip: false }}, 2500).then(result => { - if(result.type !== "Success") { - if(result.type === "ConnectionClosed") { - return; - } - - if(kVerbose) { - logError(LogCategory.STATISTICS, tr("Failed to initialize session. Retrying in 120 seconds. Result: %o"), result); - } - - this.scheduleRetry(120 * 1000); - return; - } - - this.sendInitializeAgent().then(undefined); - this.sendLocaleUpdate(); - }); - }); - - this.connection.events.on("notify_notify_received", event => { - switch (event.notify.type) { - case "NotifyClientsOnline": - this.handleNotifyClientsOnline(event.notify.payload); - break; - - default: - return; - } - }); - } - - start() { - this.connection.connect(); - } - - stop() { - this.connection.disconnect(); - clearTimeout(this.retryTimer); - - this.initializeAgentId++; - this.initializeLocaleId++; - } - - private scheduleRetry(time: number) { - this.stop(); - - this.retryTimer = setTimeout(() => this.connection.connect(), time); - } - - /** - * Returns as soon the result indicates that something else went wrong rather than transmitting. - * @param command - * @param retryInterval - */ - private async executeCommandWithRetry(command: MessageCommand, retryInterval: number) : Promise { - while(true) { - const result = await this.connection.executeCommand(command); - switch (result.type) { - case "ServerInternalError": - case "CommandEnqueueError": - case "ClientSessionUninitialized": - const shouldRetry = await new Promise(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); + }} + > + + + {availableChannels.map(channel => ( + + )) 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)); + } + }} + > + + + { + ExpirePresets.map((preset, index) => ( + + )) 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 ( +
+
+
+
General
+ + +
+
+
Channel
+ + +
+
+ {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); + }}> + +
+
+ Copied! +
+
+ ); +}); + +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 ( +
+ +
+ ) +} + +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