Added the new invite system

This commit is contained in:
WolverinDEV 2021-02-19 23:05:48 +01:00
parent 1a78bf8d57
commit fbbf319ef3
22 changed files with 1386 additions and 1291 deletions

View file

@ -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!<br>") + 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!<br>") + 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
}
}

View file

@ -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<GeoLocationInfo>;
}
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<GeoLocationInfo>;
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<GeoLocationInfo | undefined> {
return await new Promise<GeoLocationInfo>(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<GeoLocationInfo | undefined> {
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<GeoLocationInfo> {
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<GeoLocationInfo> {
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"
});

View file

@ -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 };

View file

@ -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<ClientServiceConnectionEvents>;
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<ClientServiceConnectionEvents>();
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<MessageCommandResult> {
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<MessageCommandResult> {
while(true) {
const result = await this.connection.executeCommand(command);
switch (result.type) {
case "ServerInternalError":
case "CommandEnqueueError":
case "ClientSessionUninitialized":
const shouldRetry = await new Promise<boolean>(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"
});

View file

@ -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(),

View file

@ -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<P> = {
[K in keyof P]: EventPayloadObject & {
/* prohibit the type attribute on the highest layer (used to identify the event type) */
type?: never
}
};
export type Event<P extends EventMap<P>, T extends keyof P> = {
readonly type: T,
as<S extends T>(target: S) : Event<P, S>;
asUnchecked<S extends T>(target: S) : Event<P, S>;
asAnyUnchecked<S extends keyof P>(target: S) : Event<P, S>;
/**
* Return an object containing only the event payload specific key value pairs.
*/
extractPayload() : P[T];
} & P[T];
namespace EventHelper {
/**
* Turn the payload object into a bus event object
* @param payload
*/
/* May inline this somehow? A function call seems to be 3% slower */
export function createEvent<P extends EventMap<P>, T extends keyof P>(type: T, payload?: P[T]) : Event<P, T> {
if(payload) {
(payload as any).type = type;
let event = payload as any as Event<P, T>;
event.as = as;
event.asUnchecked = asUnchecked;
event.asAnyUnchecked = asUnchecked;
event.extractPayload = extractPayload;
return event;
} else {
return {
type,
as,
asUnchecked,
asAnyUnchecked: asUnchecked,
extractPayload
} as any;
}
}
function extractPayload() {
const result = Object.assign({}, this);
delete result["as"];
delete result["asUnchecked"];
delete result["asAnyUnchecked"];
delete result["extractPayload"];
return result;
}
function as(target) {
if(this.type !== target) {
throw "Mismatching event type. Expected: " + target + ", Got: " + this.type;
}
return this;
}
function asUnchecked() {
return this;
}
}
export interface EventSender<Events extends EventMap<Events> = EventMap<any>> {
fire<T extends keyof Events>(event_type: T, data?: Events[T], overrideTypeKey?: boolean);
/**
* Fire an event later by using setTimeout(..)
* @param event_type The target event to be fired
* @param data The payload of the event
* @param callback The callback will be called after the event has been successfully dispatched
*/
fire_later<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void);
/**
* Fire an event, which will be delayed until the next animation frame.
* This ensures that all react components have been successfully mounted/unmounted.
* @param event_type The target event to be fired
* @param data The payload of the event
* @param callback The callback will be called after the event has been successfully dispatched
*/
fire_react<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void);
}
export type EventDispatchType = "sync" | "later" | "react";
export interface EventConsumer {
handleEvent(mode: EventDispatchType, type: string, data: any);
}
interface EventHandlerRegisterData {
registeredHandler: {[key: string]: ((event) => void)[]}
}
const kEventAnnotationKey = guid();
export class Registry<Events extends EventMap<Events> = EventMap<any>> implements EventSender<Events> {
protected readonly registryUniqueId;
protected persistentEventHandler: { [key: string]: ((event) => void)[] } = {};
protected oneShotEventHandler: { [key: string]: ((event) => void)[] } = {};
protected genericEventHandler: ((event) => void)[] = [];
protected consumer: EventConsumer[] = [];
private 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<Events extends EventMap<Events> = EventMap<any>>(description: IpcRegistryDescription<Events>) : Registry<Events> {
const registry = new Registry<Events>();
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<T extends keyof Events>(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<T extends keyof Events>(eventType: T, data?: Events[T], callback?: () => void) {
if(!this.pendingAsyncCallbacksTimeout) {
this.pendingAsyncCallbacksTimeout = setTimeout(() => this.invokeAsyncCallbacks());
this.pendingAsyncCallbacks = [];
}
this.pendingAsyncCallbacks.push({ type: eventType, data: data, callback: callback });
for(const consumer of this.consumer) {
consumer.handleEvent("later", eventType as string, data);
}
}
fire_react<T extends keyof Events>(eventType: T, data?: Events[T], callback?: () => void) {
if(!this.pendingReactCallbacks) {
this.pendingReactCallbacksFrame = requestAnimationFrame(() => this.invokeReactCallbacks());
this.pendingReactCallbacks = [];
}
this.pendingReactCallbacks.push({ type: eventType, data: data, callback: callback });
for(const consumer of this.consumer) {
consumer.handleEvent("react", eventType as string, data);
}
}
on<T extends keyof Events>(event: T | T[], handler: (event: Event<Events, T>) => void) : () => void;
on(events, handler) : () => void {
if(!Array.isArray(events)) {
events = [events];
}
for(const event of events as string[]) {
const persistentHandler = this.persistentEventHandler[event] || (this.persistentEventHandler[event] = []);
persistentHandler.push(handler);
}
return () => this.off(events, handler);
}
one<T extends keyof Events>(event: T | T[], handler: (event: Event<Events, T>) => void) : () => void;
one(events, handler) : () => void {
if(!Array.isArray(events)) {
events = [events];
}
for(const event of events as string[]) {
const persistentHandler = this.oneShotEventHandler[event] || (this.oneShotEventHandler[event] = []);
persistentHandler.push(handler);
}
return () => this.off(events, handler);
}
off(handler: (event: Event<Events, keyof Events>) => void);
off<T extends keyof Events>(events: T | T[], handler: (event: Event<Events, T>) => void);
off(handlerOrEvents, handler?) {
if(typeof handlerOrEvents === "function") {
this.offAll(handler);
} else if(typeof handlerOrEvents === "string") {
if(this.persistentEventHandler[handlerOrEvents]) {
this.persistentEventHandler[handlerOrEvents].remove(handler);
}
if(this.oneShotEventHandler[handlerOrEvents]) {
this.oneShotEventHandler[handlerOrEvents].remove(handler);
}
} else if(Array.isArray(handlerOrEvents)) {
handlerOrEvents.forEach(handler_or_event => this.off(handler_or_event, handler));
}
}
onAll(handler: (event: Event<Events, keyof Events>) => void): () => void {
this.genericEventHandler.push(handler);
return () => this.genericEventHandler.remove(handler);
}
offAll(handler: (event: Event<Events, keyof Events>) => void) {
Object.values(this.persistentEventHandler).forEach(persistentHandler => persistentHandler.remove(handler));
Object.values(this.oneShotEventHandler).forEach(oneShotHandler => oneShotHandler.remove(handler));
this.genericEventHandler.remove(handler);
}
/**
* @param event
* @param handler
* @param condition If a boolean the event handler will only be registered if the condition is true
* @param reactEffectDependencies
*/
reactUse<T extends keyof Events>(event: T | T[], handler: (event: Event<Events, T>) => 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<Events, keyof Events>) {
const oneShotHandler = this.oneShotEventHandler[event.type];
if(oneShotHandler) {
delete this.oneShotEventHandler[event.type];
for(const handler of oneShotHandler) {
handler(event);
setEventRegistryHooks(new class implements EventRegistryHooks {
logAsyncInvokeError(error: any) {
logError(LogCategory.EVENT_REGISTRY, tr("Failed to invoke async callback:\n%o"), error);
}
}
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);
logReactInvokeError(error: any) {
logError(LogCategory.EVENT_REGISTRY, tr("Failed to invoke react callback:\n%o"), error);
}
});
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<Events> {
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<EventTypes>(events: (keyof EventTypes) | (keyof EventTypes)[]) {
return function (target: any,
propertyKey: string,
_descriptor: PropertyDescriptor) {
if(typeof target[propertyKey] !== "function")
throw "Invalid event handler annotation. Expected to be on a function type.";
target[propertyKey][kEventAnnotationKey] = {
events: Array.isArray(events) ? events : [events]
};
}
}
export function ReactEventHandler<ObjectClass = React.Component<any, any>, Events = any>(registry_callback: (object: ObjectClass) => Registry<Events>) {
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<Events extends EventMap<Events> = EventMap<any>> = {
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;
}
}
logTrace(message: string, ...args: any[]) {
logTrace(LogCategory.EVENT_REGISTRY, message, ...args);
}
}
});

View file

@ -8,6 +8,7 @@ export type ConnectRequestData = {
profile?: string;
username?: string;
password?: {
value: string;
hashed: boolean;

View file

@ -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,22 +96,39 @@ 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();
/* 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")}`;
}
}
const password = properties.password ? properties.password.value : "";
const password_hashed = properties.password ? properties.password.hashed : false;
handleConnectRequest(serverAddress, new UrlParameterParser(url));
}
if(profile && profile.valid()) {
settings.setValue(Settings.KEY_USER_IS_NEW, false);
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: serverAddress,
selectedProfile: profile
});
return;
}
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 => {
spawnYesNo(tra("Connect to {}", serverAddress), tra("Would you like to connect to {}?", serverAddress), result => {
if(result) {
aplayer.on_ready(() => handle_connect_request(properties, connection));
aplayer.on_ready(() => handleConnectRequest(serverAddress, parameters));
} else {
/* Well... the client don't want to... */
}
@ -119,20 +136,49 @@ export function handle_connect_request(properties: ConnectRequestData, connectio
return;
}
connection.startConnection(properties.address, profile, true, {
nickname: username,
password: password.length > 0 ? {
password: password,
hashed: password_hashed
} : undefined
});
server_connections.setActiveConnectionHandler(connection);
} else {
spawnConnectModalNew({
selectedAddress: properties.address,
selectedProfile: profile
});
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);

View file

@ -76,10 +76,11 @@ function encodeValueToString<T extends RegistryValueType>(input: T) : string {
function resolveKey<ValueType extends RegistryValueType, DefaultType>(
key: RegistryKey<ValueType>,
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<ValueType extends RegistryValueType, DefaultType>(
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<V extends RegistryValueType, DV>(key: RegistryKey<V>, defaultValue: DV) : V | DV;
getValue<V extends RegistryValueType>(key: ValuedRegistryKey<V>, defaultValue?: V) : V;
getValue<V extends RegistryValueType, DV>(key: RegistryKey<V> | ValuedRegistryKey<V>, 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<V extends RegistryValueType>(key: RegistryKey<V>, 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<V extends RegistryValueType, DV>(key: RegistryKey<V>, defaultValue: DV) : V | DV;
export function getValue<V extends RegistryValueType>(key: ValuedRegistryKey<V>, defaultValue?: V) : V;
export function getValue<V extends RegistryValueType, DV>(key: RegistryKey<V> | ValuedRegistryKey<V>, 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<string> = {
key: "cn",
fallbackKeys: ["connect_username"],
fallbackKeys: ["connect_username", "nickname"],
valueType: "string"
};
export const KEY_CONNECT_TOKEN: RegistryKey<string> = {
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<string> = {
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<boolean> = {
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<string> = {
@ -201,7 +240,7 @@ export namespace AppParameters {
export const KEY_CONNECT_CHANNEL_PASSWORD: RegistryKey<string> = {
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<boolean> = {
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<boolean> = {
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<boolean> = category => {
return {
key: "log." + category.toLowerCase() + ".enabled",

View file

@ -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<ChannelEvents> {
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<ChannelEvents> {
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();

View file

@ -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<ServerEvents> {
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: ''

View file

@ -432,7 +432,7 @@ const ServerGroupRenderer = () => {
return (
<InfoBlock clientIcon={ClientIcon.PermissionChannel} valueClass={cssStyle.groups}>
<Translatable>Channel group</Translatable>
<Translatable>Server groups</Translatable>
<>{body}</>
</InfoBlock>
);

View file

@ -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
};
}

View file

@ -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<InviteUiEvents>;
readonly variables: IpcUiVariableProvider<InviteUiVariables>;
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<InviteUiEvents>();
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);
}

View file

@ -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: {}
}

View file

@ -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;
}
}
}

View file

@ -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<Registry<InviteUiEvents>>(undefined);
const VariablesContext = React.createContext<UiVariableConsumer<InviteUiVariables>>(undefined);
const OptionChannel = React.memo(() => {
const variables = useContext(VariablesContext);
const availableChannels = variables.useReadOnly("availableChannels", undefined, []);
const selectedChannel = variables.useVariable("selectedChannel", undefined, 0);
return (
<div className={cssStyle.option}>
<div className={cssStyle.optionTitle}>
<Translatable>Automatically join channel</Translatable>
</div>
<div className={cssStyle.optionValue}>
<ControlledSelect
value={selectedChannel.localValue.toString()}
type={"boxed"}
className={cssStyle.optionsChannel}
onChange={event => {
const value = parseInt(event.target.value);
if(isNaN(value)) {
return;
}
selectedChannel.setValue(value);
}}
>
<option key={"no-channel"} value={"0"}>{useTr("No specific channel")}</option>
<option key={"server-channels"} disabled>{useTr("Available channels:")}</option>
{availableChannels.map(channel => (
<option key={"channel-" + channel.channelId} value={channel.channelId + ""}>{channel.channelName}</option>
)) as any}
</ControlledSelect>
</div>
</div>
);
});
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 = (
<ControlledBoxedInputField className={cssStyle.optionChannelPassword} value={""} key={"no-password"} placeholder={tr("No channel selected")} editable={false} onChange={() => {}} />
);
} else if(channelPassword.localValue.hashed && !channelPassword.localValue.raw) {
body = (
<ControlledBoxedInputField
className={cssStyle.optionChannelPassword}
value={""}
placeholder={tr("Using cached password")}
editable={true}
onChange={newValue => channelPassword.setValue({ hashed: channelPassword.localValue.hashed, raw: newValue }, true)}
/>
);
} else {
body = (
<ControlledBoxedInputField
className={cssStyle.optionChannelPassword}
value={channelPassword.localValue.raw}
placeholder={tr("Don't use a password")}
editable={true}
onChange={newValue => channelPassword.setValue({ hashed: channelPassword.localValue.hashed, raw: newValue }, true)}
onBlur={() => channelPassword.setValue(channelPassword.localValue, false)}
finishOnEnter={true}
/>
);
}
return (
<div className={cssStyle.option}>
<div className={cssStyle.optionTitle}><Translatable>Channel password</Translatable></div>
<div className={cssStyle.optionValue}>
{body}
</div>
</div>
);
})
const OptionGeneralShortLink = React.memo(() => {
const variables = useContext(VariablesContext);
const showShortUrl = variables.useVariable("shortLink", undefined, true);
return (
<div className={cssStyle.option}>
<Checkbox
onChange={newValue => showShortUrl.setValue(newValue)}
value={showShortUrl.localValue}
label={<Translatable>Use short URL</Translatable>}
/>
</div>
)
})
const OptionGeneralShowAdvanced = React.memo(() => {
const variables = useContext(VariablesContext);
const showShortUrl = variables.useVariable("advancedSettings", undefined, false);
return (
<div className={cssStyle.option}>
<Checkbox
onChange={newValue => showShortUrl.setValue(newValue)}
value={showShortUrl.localValue}
label={<Translatable>Advanced settings</Translatable>}
/>
</div>
)
})
const OptionAdvancedToken = React.memo(() => {
const variables = useContext(VariablesContext);
const currentToken = variables.useVariable("token", undefined, "");
return (
<div className={cssStyle.option}>
<div className={cssStyle.optionTitle}><Translatable>Token</Translatable></div>
<div className={cssStyle.optionValue}>
<ControlledBoxedInputField
className={cssStyle.optionChannelPassword}
value={currentToken.localValue}
placeholder={tr("Don't use a token")}
editable={true}
onChange={newValue => currentToken.setValue(newValue, true)}
onBlur={() => currentToken.setValue(currentToken.localValue, false)}
finishOnEnter={true}
/>
</div>
</div>
);
});
const OptionAdvancedWebUrlBase = React.memo(() => {
const variables = useContext(VariablesContext);
const currentUrl = variables.useVariable("webClientUrlBase", undefined, { override: undefined, fallback: undefined });
return (
<div className={cssStyle.option}>
<div className={cssStyle.optionTitle}><Translatable>WebClient URL</Translatable></div>
<div className={cssStyle.optionValue}>
<ControlledBoxedInputField
className={cssStyle.optionChannelPassword}
value={currentUrl.localValue.override || ""}
placeholder={currentUrl.localValue.fallback || tr("loading...")}
editable={true}
onChange={newValue => currentUrl.setValue({ fallback: currentUrl.localValue.fallback, override: newValue }, true)}
onBlur={() => currentUrl.setValue(currentUrl.localValue, false)}
finishOnEnter={true}
/>
</div>
</div>
);
});
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 (
<div className={cssStyle.option}>
<div className={cssStyle.optionTitle}><Translatable>Link expire time</Translatable></div>
<div className={cssStyle.optionValue}>
<ControlledSelect
type={"boxed"}
value={presetSelected + ""}
onChange={event => {
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));
}
}}
>
<option key={"unknown"} value={"-2"} style={{ display: "none" }}>{useTr("Unknown")}</option>
<option key={"never"} value={"-1"}>{useTr("never")}</option>
{
ExpirePresets.map((preset, index) => (
<option key={"p-" + index} value={index.toString()}>{preset.name()}</option>
)) as any
}
</ControlledSelect>
</div>
</div>
);
});
const OptionsAdvanced = React.memo(() => {
return (
<div className={cssStyle.containerOptionsAdvanced}>
<div className={cssStyle.title}><Translatable>Advanced options</Translatable></div>
<OptionAdvancedToken />
<OptionAdvancedWebUrlBase />
<OptionAdvancedExpires />
</div>
)
});
const Options = React.memo(() => {
const variables = useContext(VariablesContext);
const showAdvanced = variables.useReadOnly("advancedSettings", undefined, false);
return (
<div className={cssStyle.containerOptions}>
<div className={cssStyle.generalOptions}>
<div className={cssStyle.general}>
<div className={cssStyle.title}><Translatable>General</Translatable></div>
<OptionGeneralShortLink />
<OptionGeneralShowAdvanced />
</div>
<div className={cssStyle.channel}>
<div className={cssStyle.title}><Translatable>Channel</Translatable></div>
<OptionChannel />
<OptionChannelPassword />
</div>
</div>
{showAdvanced ? <OptionsAdvanced key={"advanced"} /> : undefined}
</div>
);
});
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 (
<div className={cssStyle.containerCopy}>
<div className={cssStyle.button} onClick={() => {
if(props.disabled) {
return;
}
props.onCopy();
setShowTimeout(Date.now() + 1750);
}}>
<ClientIconRenderer icon={ClientIcon.CopyUrl} />
</div>
<div className={cssStyle.copied + " " + (now < showTimeout ? cssStyle.shown : "")}>
<a>Copied!</a>
</div>
</div>
);
});
const LinkExpire = (props: { date: number | 0 | -1 }) => {
let value;
if(props.date === -1) {
value = <React.Fragment key={"unset"}>&nbsp;</React.Fragment>;
} else if(props.date === 0) {
value = <React.Fragment key={"never"}><Translatable>Link expires never</Translatable></React.Fragment>;
} else {
value = <React.Fragment key={"never"}><Translatable>Link expires at</Translatable> {moment(props.date * 1000).format('LLLL')}</React.Fragment>;
}
return (
<div className={cssStyle.linkExpire}>{value}</div>
);
}
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 = <React.Fragment key={"loading"}><Translatable>Generating link</Translatable> <LoadingDots /></React.Fragment>;
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 (
<div className={cssStyle.containerLink}>
<div className={cssStyle.title}><Translatable>Link</Translatable></div>
<div className={cssStyle.output + " " + className}>
<a>{value}</a>
<ButtonCopy disabled={link.status === "generating"} onCopy={() => {
if(copyValue) {
copyToClipboard(copyValue);
}
}} />
</div>
<LinkExpire date={link.status === "success" ? link.expireDate : -1} />
</div>
);
});
const Buttons = () => {
const events = useContext(EventsContext);
return (
<div className={cssStyle.containerButtons}>
<Button type={"small"} color={"green"} onClick={() => events.fire("action_close")}>
<Translatable>Close</Translatable>
</Button>
</div>
)
}
class ModalInvite extends AbstractModal {
private readonly events: Registry<InviteUiEvents>;
private readonly variables: UiVariableConsumer<InviteUiVariables>;
private readonly serverName: string;
constructor(events: IpcRegistryDescription<InviteUiEvents>, variables: IpcVariableDescriptor<InviteUiVariables>, serverName: string) {
super();
this.events = Registry.fromIpcDescription(events);
this.variables = createIpcUiVariableConsumer(variables);
this.serverName = serverName;
}
renderBody(): React.ReactElement {
return (
<EventsContext.Provider value={this.events}>
<VariablesContext.Provider value={this.variables}>
<div className={cssStyle.container}>
<Options />
<Link />
<Buttons />
</div>
</VariablesContext.Provider>
</EventsContext.Provider>
);
}
renderTitle(): string | React.ReactElement {
return <><Translatable>Invite People to</Translatable> {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();
});
*/

View file

@ -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 (
<div
draggable={false}
className={
cssStyle.containerBoxed + " " +
cssStyle["size-" + (props.size || "normal")] + " " +
(props.disabled ? cssStyle.disabled : "") + " " +
(props.isInvalid ? cssStyle.isInvalid : "") + " " +
(typeof props.editable !== "boolean" || props.editable ? cssStyle.editable : "") + " " +
(props.leftIcon ? "" : cssStyle.noLeftIcon) + " " +
(props.rightIcon ? "" : cssStyle.noRightIcon) + " " +
props.className
}
onFocus={props.onFocus}
onBlur={() => props.onBlur()}
>
{props.leftIcon ? props.leftIcon() : ""}
{props.prefix ? <a key={"prefix"} className={cssStyle.prefix}>{props.prefix}</a> : undefined}
{props.inputBox ?
<span key={"custom-input"}
className={cssStyle.inputBox + " " + (props.editable ? cssStyle.editable : "")}
onClick={props.onFocus}>{props.inputBox()}</span> :
<input key={"input"}
value={props.value || ""}
placeholder={props.placeholder}
readOnly={typeof props.editable === "boolean" ? !props.editable : false}
disabled={typeof props.disabled === "boolean" ? props.disabled : false}
onChange={event => props.onChange(event.currentTarget.value)}
onKeyPress={event => {
if(event.key === "Enter") {
if(props.finishOnEnter) {
event.currentTarget.blur();
}
if(props.onEnter) {
props.onEnter();
}
}
}}
/>
}
{props.suffix ? <a key={"suffix"} className={cssStyle.suffix}>{props.suffix}</a> : undefined}
{props.rightIcon ? props.rightIcon() : ""}
</div>
);
}
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 {

View file

@ -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<InviteUiEvents>,
/* variables */ IpcVariableDescriptor<InviteUiVariables>,
/* serverName */ string
]
}

View file

@ -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
});

View file

@ -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<Variables extends UiVariableMap> extends UiVariableProvider<Variables> {
export class IpcUiVariableProvider<Variables extends UiVariableMap> extends UiVariableProvider<Variables> {
readonly ipcChannelId: string;
private broadcastChannel: BroadcastChannel;
@ -146,7 +146,6 @@ class IpcUiVariableConsumer<Variables extends UiVariableMap> 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];

View file

@ -45,6 +45,10 @@ export abstract class UiVariableProvider<Variables extends UiVariableMap> {
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<T extends keyof Variables>(variable: T, editor: UiVariableEditor<Variables, T>) {
this.variableEditor[variable as any] = editor;
}
@ -247,7 +251,7 @@ export abstract class UiVariableConsumer<Variables extends UiVariableMap> {
/* Variable constructor */
cacheEntry.useCount++;
if(cacheEntry.status === "loading") {
if(cacheEntry.status === "loaded") {
return {
status: "set",
value: cacheEntry.currentValue