diff --git a/shared/js/ui/modal/connect/Controller.ts b/shared/js/ui/modal/connect/Controller.ts index 98412093..3a0680ec 100644 --- a/shared/js/ui/modal/connect/Controller.ts +++ b/shared/js/ui/modal/connect/Controller.ts @@ -1,24 +1,343 @@ import {Registry} from "tc-shared/events"; -import {ConnectProperties, ConnectUiEvents} from "tc-shared/ui/modal/connect/Definitions"; +import {ConnectProperties, ConnectUiEvents, PropertyValidState} from "tc-shared/ui/modal/connect/Definitions"; import {spawnReactModal} from "tc-shared/ui/react-elements/Modal"; import {ConnectModal} from "tc-shared/ui/modal/connect/Renderer"; +import {LogCategory, logError, logWarn} from "tc-shared/log"; +import { + availableConnectProfiles, + ConnectionProfile, + defaultConnectProfile, + findConnectProfile +} from "tc-shared/profiles/ConnectionProfile"; +import {Settings, settings} from "tc-shared/settings"; +import {connectionHistory, ConnectionHistoryEntry} from "tc-shared/connectionlog/History"; +import {global_client_actions} from "tc-shared/events/GlobalEvents"; +import {createErrorModal} from "tc-shared/ui/elements/Modal"; +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import {server_connections} from "tc-shared/ConnectionManager"; +import _ = require("lodash"); +import {parseServerAddress} from "tc-shared/tree/Server"; +import * as ipRegex from "ip-regex"; + +const kRegexDomain = /^(localhost|((([a-zA-Z0-9_-]{0,63}\.){0,253})?[a-zA-Z0-9_-]{0,63}\.[a-zA-Z]{2,64}))$/i; + +export type ConnectParameters = { + targetAddress: string, + targetPassword?: string, + targetPasswordHashed?: boolean, + + nickname: string, + nicknameSpecified: boolean, + + profile: ConnectionProfile, + + token?: string, + + defaultChannel?: string | number, + defaultChannelPassword?: string, +} + +type ValidityStates = {[T in keyof PropertyValidState]: boolean}; +const kDefaultValidityStates: ValidityStates = { + address: false, + nickname: false, + password: false, + profile: false +} class ConnectController { readonly uiEvents: Registry; + private readonly defaultAddress: string; + private readonly propertyProvider: {[K in keyof ConnectProperties]?: () => Promise} = {}; + + private historyShown: boolean; + + private currentAddress: string; + private currentNickname: string; + private currentPassword: string; + private currentPasswordHashed: boolean; + private currentProfile: ConnectionProfile | undefined; + + private addressChanged: boolean; + private nicknameChanged: boolean; + + private selectedHistoryId: number; + private history: ConnectionHistoryEntry[]; + + private validStates: {[T in keyof PropertyValidState]: boolean} = { + address: false, + nickname: false, + password: false, + profile: false + }; + + private validateStates: {[T in keyof PropertyValidState]: boolean} = { + profile: false, + password: false, + nickname: false, + address: false + }; + constructor() { this.uiEvents = new Registry(); + this.uiEvents.enableDebug("modal-connect"); + + this.defaultAddress = "ts.teaspeak.de"; + this.historyShown = settings.static_global(Settings.KEY_CONNECT_SHOW_HISTORY); + + this.currentAddress = settings.static_global(Settings.KEY_CONNECT_ADDRESS); + this.currentProfile = findConnectProfile(settings.static_global(Settings.KEY_CONNECT_PROFILE)) || defaultConnectProfile(); + this.currentNickname = settings.static_global(Settings.KEY_CONNECT_USERNAME); + + this.addressChanged = false; + this.nicknameChanged = false; + + this.propertyProvider["nickname"] = async () => { + return { + defaultNickname: this.currentProfile?.connectUsername(), + currentNickname: this.currentNickname, + }; + }; + this.propertyProvider["address"] = async () => { + return { + currentAddress: this.currentAddress, + defaultAddress: this.defaultAddress, + }; + }; + this.propertyProvider["password"] = async () => this.currentPassword ? ({ + hashed: this.currentPasswordHashed, + password: this.currentPassword + }) : undefined; + this.propertyProvider["profiles"] = async () => ({ + selected: this.currentProfile?.id, + profiles: availableConnectProfiles().map(profile => ({ + id: profile.id, + valid: profile.valid(), + name: profile.profileName + })) + }); + this.propertyProvider["historyShown"] = async () => this.historyShown; + this.propertyProvider["history"] = async () => { + if(!this.history) { + this.history = await connectionHistory.lastConnectedServers(10); + } + + return { + selected: this.selectedHistoryId, + history: this.history.map(entry => ({ + id: entry.id, + targetAddress: entry.targetAddress, + uniqueServerId: entry.serverUniqueId + })) + }; + }; + + this.uiEvents.on("query_property", event => this.sendProperty(event.property)); + this.uiEvents.on("query_property_valid", event => this.uiEvents.fire_react("notify_property_valid", { property: event.property, value: this.validStates[event.property] })); + this.uiEvents.on("query_history_connections", event => { + connectionHistory.countConnectCount(event.target, event.targetType).catch(async error => { + logError(LogCategory.GENERAL, tr("Failed to query the connect count for %s (%s): %o"), event.target, event.targetType, error); + return -1; + }).then(count => { + this.uiEvents.fire_react("notify_history_connections", { + target: event.target, + targetType: event.targetType, + value: count + }); + }); + }); + this.uiEvents.on("query_history_entry", event => { + connectionHistory.queryServerInfo(event.serverUniqueId).then(info => { + this.uiEvents.fire_react("notify_history_entry", { + serverUniqueId: event.serverUniqueId, + info: { + icon: { + iconId: info.iconId, + serverUniqueId: event.serverUniqueId, + handlerId: undefined + }, + name: info.name, + password: info.passwordProtected, + country: info.country, + clients: info.clientsOnline, + maxClients: info.clientsMax + } + }); + }).catch(async error => { + logError(LogCategory.GENERAL, tr("Failed to query the history server info for %s: %o"), event.serverUniqueId, error); + }); + }); + + this.uiEvents.on("action_toggle_history", event => { + if(this.historyShown === event.enabled) { + return; + } + + this.historyShown = event.enabled; + this.sendProperty("historyShown").then(undefined); + settings.changeGlobal(Settings.KEY_CONNECT_SHOW_HISTORY, event.enabled); + }); + + this.uiEvents.on("action_manage_profiles", () => { + /* FIXME: Reload profiles if their status have changed... */ + global_client_actions.fire("action_open_window_settings", { defaultCategory: "identity-profiles" }); + }); + + this.uiEvents.on("action_select_profile", event => { + const profile = findConnectProfile(event.id); + if(!profile) { + createErrorModal(tr("Invalid profile"), tr("Target connect profile is missing.")).open(); + return; + } + + this.currentProfile = profile; + this.sendProperty("profiles").then(undefined); + settings.changeGlobal(Settings.KEY_CONNECT_PROFILE, profile.id); + + /* Clear out the nickname on profile switch and use the default nickname */ + this.uiEvents.fire("action_set_nickname", { nickname: undefined, validate: true }); + + this.validateStates["profile"] = true; + this.updateValidityStates(); + }); + + this.uiEvents.on("action_set_address", event => { + if(this.currentAddress !== event.address) { + this.currentAddress = event.address; + this.sendProperty("address").then(undefined); + settings.changeGlobal(Settings.KEY_CONNECT_ADDRESS, event.address); + this.setSelectedHistoryId(-1); + } + + this.validateStates["address"] = event.validate; + this.updateValidityStates(); + }); + + this.uiEvents.on("action_set_nickname", event => { + if(this.currentNickname !== event.nickname) { + this.currentNickname = event.nickname; + this.sendProperty("nickname").then(undefined); + settings.changeGlobal(Settings.KEY_CONNECT_USERNAME, event.nickname); + } + + this.validateStates["nickname"] = event.validate; + this.updateValidityStates(); + }); + + this.uiEvents.on("action_set_password", event => { + if(this.currentPassword === event.password) { + return; + } + + this.currentPassword = event.password; + this.currentPasswordHashed = event.hashed; + this.sendProperty("password").then(undefined); + + this.validateStates["password"] = true; + this.updateValidityStates(); + }); + + this.uiEvents.on("action_select_history", event => this.setSelectedHistoryId(event.id)); + + this.uiEvents.on("action_connect", () => { + Object.keys(this.validateStates).forEach(key => this.validateStates[key] = true); + this.updateValidityStates(); + }); + + this.updateValidityStates(); } destroy() { - + Object.keys(this.propertyProvider).forEach(key => delete this.propertyProvider[key]); + this.uiEvents.destroy(); } - - private sendProperty(property: keyof ConnectProperties) { - switch (property) { - case "address": + generateConnectParameters() : ConnectParameters | undefined { + if(Object.keys(this.validStates).findIndex(key => this.validStates[key] === false) !== -1) { + return undefined; } + + return { + nickname: this.currentNickname || this.currentProfile?.connectUsername(), + nicknameSpecified: !!this.currentNickname, + + targetAddress: this.currentAddress || this.defaultAddress, + + profile: this.currentProfile, + + targetPassword: this.currentPassword, + targetPasswordHashed: this.currentPasswordHashed + }; + } + + setSelectedHistoryId(id: number | -1) { + if(this.selectedHistoryId === id) { + return; + } + + this.selectedHistoryId = id; + this.sendProperty("history").then(undefined); + + const historyEntry = this.history?.find(entry => entry.id === id); + if(!historyEntry) { return; } + + this.currentAddress = historyEntry.targetAddress; + this.currentNickname = historyEntry.nickname; + this.currentPassword = historyEntry.hashedPassword; + this.currentPasswordHashed = true; + + this.sendProperty("address").then(undefined); + this.sendProperty("password").then(undefined); + this.sendProperty("nickname").then(undefined); + } + + private updateValidityStates() { + const newStates = Object.assign({}, kDefaultValidityStates); + if(this.validateStates["nickname"]) { + const nickname = this.currentNickname || this.currentProfile?.connectUsername() || ""; + newStates["nickname"] = nickname.length >= 3 && nickname.length <= 30; + } else { + newStates["nickname"] = true; + } + + if(this.validateStates["address"]) { + const address = this.currentAddress || this.defaultAddress || ""; + const parsedAddress = parseServerAddress(address); + + if(parsedAddress) { + kRegexDomain.lastIndex = 0; + newStates["address"] = kRegexDomain.test(parsedAddress.host) || ipRegex({ exact: true }).test(parsedAddress.host); + } else { + newStates["address"] = false; + } + } else { + newStates["address"] = true; + } + + newStates["profile"] = !!this.currentProfile?.valid(); + newStates["password"] = true; + + for(const key of Object.keys(newStates)) { + if(_.isEqual(this.validStates[key], newStates[key])) { + continue; + } + + this.validStates[key] = newStates[key]; + this.uiEvents.fire_react("notify_property_valid", { property: key as any, value: this.validStates[key] }); + } + } + + private async sendProperty(property: keyof ConnectProperties) { + if(!this.propertyProvider[property]) { + logWarn(LogCategory.GENERAL, tr("Tried to send a property where we don't have a provider for")); + return; + } + + this.uiEvents.fire_react("notify_property", { + property: property, + value: await this.propertyProvider[property]() + }); } } @@ -36,6 +355,29 @@ export function spawnConnectModalNew(options: ConnectModalOptions) { modal.events.one("destroy", () => { controller.destroy(); }); + + controller.uiEvents.on("action_connect", event => { + const parameters = controller.generateConnectParameters(); + if(!parameters) { + /* invalid parameters detected */ + return; + } + + modal.destroy(); + + let connection: ConnectionHandler; + if(event.newTab) { + connection = server_connections.spawn_server_connection(); + } else { + connection = server_connections.active_connection(); + } + + if(!connection) { + return; + } + + connection.startConnectionNew(parameters, false).then(undefined); + }); } (window as any).spawnConnectModalNew = spawnConnectModalNew; \ No newline at end of file diff --git a/shared/js/ui/modal/connect/Definitions.ts b/shared/js/ui/modal/connect/Definitions.ts index befc0da0..28580cb6 100644 --- a/shared/js/ui/modal/connect/Definitions.ts +++ b/shared/js/ui/modal/connect/Definitions.ts @@ -1,4 +1,5 @@ import {kUnknownHistoryServerUniqueId} from "tc-shared/connectionlog/History"; +import { RemoteIconInfo} from "tc-shared/file/Icons"; export type ConnectProfileEntry = { id: string, @@ -13,36 +14,36 @@ export type ConnectHistoryEntry = { } export type ConnectHistoryServerInfo = { - iconId: number, + icon: RemoteIconInfo, name: string, password: boolean, + country: string, + clients: number | -1, + maxClients: number | -1 } -export type ConnectServerAddress = { - currentAddress: string, - defaultAddress: string, -} - -export type ConnectServerNickname = { - currentNickname: string, - defaultNickname: string, -} - -export type ConnectProfiles = { - profiles: ConnectProfileEntry[], - selected: string -}; - export interface ConnectProperties { - address: ConnectServerAddress, - nickname: ConnectServerNickname, - password: string, - profiles: ConnectProfiles, + address: { + currentAddress: string, + defaultAddress: string, + }, + nickname: { + currentNickname: string | undefined, + defaultNickname: string | undefined, + }, + password: { + password: string, + hashed: boolean + } | undefined, + profiles: { + profiles: ConnectProfileEntry[], + selected: string + }, + historyShown: boolean, history: { history: ConnectHistoryEntry[], selected: number | -1, - state: "shown" | "hidden" }, } @@ -50,11 +51,12 @@ export interface PropertyValidState { address: boolean, nickname: boolean, password: boolean, + profile: boolean } -type ConnectProperty = { +type IAccess = { property: T, - value: ConnectProperties[T] + value: I[T] }; export interface ConnectUiEvents { @@ -66,13 +68,20 @@ export interface ConnectUiEvents { action_delete_history: { target: string, targetType: "address" | "server-unique-id" - } + }, + action_set_nickname: { nickname: string, validate: boolean }, + action_set_address: { address: string, validate: boolean }, + action_set_password: { password: string, hashed: boolean }, query_property: { property: keyof ConnectProperties }, + query_property_valid: { + property: keyof PropertyValidState + }, - notify_property: ConnectProperty + notify_property: IAccess, + notify_property_valid: IAccess, query_history_entry: { serverUniqueId: string diff --git a/shared/js/ui/modal/connect/Renderer.scss b/shared/js/ui/modal/connect/Renderer.scss index b9005efe..08a13d2b 100644 --- a/shared/js/ui/modal/connect/Renderer.scss +++ b/shared/js/ui/modal/connect/Renderer.scss @@ -5,330 +5,36 @@ @include user-select(none); font-size: 1rem; - padding: 1em; - width: 50em; + width: 60em; min-width: 25em; max-width: 100%; flex-shrink: 1; - display: flex!important; - flex-direction: column!important; - justify-content: stretch!important; + display: flex; + flex-direction: column; + justify-content: stretch; - .container-last-servers { - flex-grow: 0; - flex-shrink: 1; - - display: flex; - flex-direction: column; - justify-content: stretch; - - max-height: 0; - opacity: 0; - overflow: hidden; - padding: 0; - - min-width: 0; - - - border: none; - border-left: 2px solid #7a7a7a; - - @include transition(max-height .5s ease-in-out, opacity .5s ease-in-out, padding .5s ease-in-out); - &.shown { - /* apply the default padding */ - padding: 0 24px 24px; - - max-height: 100%; - opacity: 1; - - @include transition(max-height .5s ease-in-out, opacity .5s ease-in-out, padding .5s ease-in-out) - } - - hr { - height: 0; - width: calc(100% + 46px); - min-width: 0; - - margin: 0 0 0 -23px; - - padding: 0; - - border: none; - border-top: 1px solid #090909; - - margin-bottom: .75em; - } - - color: #7a7a7a; - - /* general table class */ - .table { - width: 100em; - max-width: 100%; - - display: flex; - flex-direction: column; - justify-content: stretch; - - .head { - display: flex; - flex-direction: row; - justify-content: stretch; - - flex-grow: 0; - flex-shrink: 0; - - border: none; - border-bottom: 1px solid #161618; - } - - - .body { - flex-grow: 0; - flex-shrink: 1; - - display: flex; - flex-direction: column; - justify-content: stretch; - - overflow: auto; - - .row { - cursor: pointer; - - flex-grow: 0; - flex-shrink: 0; - - display: flex; - flex-direction: row; - justify-content: stretch; - - &:hover { - background-color: #202022; - } - - &.selected { - background-color: #131315; - } - } - - .body-empty { - height: 3em; - text-align: center; - display: flex; - flex-direction: column; - justify-content: space-around; - font-size: 1.25em; - color: rgba(121, 121, 121, 0.5); - } - } - - .column { - flex-grow: 1; - flex-shrink: 1; - - overflow: hidden; - white-space: nowrap; - - padding-right: .25em; - padding-left: .25em; - - display: flex; - flex-direction: row; - justify-content: flex-start; - - &:not(:last-of-type) { - border-right: 1px solid #161618; - } - - > a { - max-width: 100%; - text-overflow: ellipsis; - overflow: hidden; - } - } - } - - /* connect table */ - .table { - margin-left: -1.5em; /* the delete row */ - - .head { - margin-left: 1.5em; /* the delete row */ - .column.delete { - display: none; - } - } - - .column { - align-self: center; - .country, .icon-container { - align-self: center; - margin-right: 0.25em; - } - - - @mixin fixed-column($name, $width) { - &.#{$name} { - flex-grow: 0; - flex-shrink: 0; - - width: $width; - } - } - - @include fixed-column(delete, 1.5em); - @include fixed-column(password, 5em); - @include fixed-column(country-name, 7em); - @include fixed-column(clients, 4em); - @include fixed-column(connections, 6.5em); - - &.delete { - opacity: 0; - border-right: none; - border-bottom: none; - - text-align: center; - @include transition(opacity .25 ease-in-out); - - &:hover { - opacity: 1; - @include transition(opacity .25 ease-in-out); - } - } - - &.address { - flex-grow: 1; - flex-shrink: 1; - - width: 40%; - } - - &.name { - flex-grow: 1; - flex-shrink: 1; - - width: 60%; - } - } - } + > * { + padding-left: 1.5em; + padding-right: 1.5em; } } -.connectContainer_ { - flex-grow: 0; - flex-shrink: 0; - - /* apply the default padding */ - padding: .75em 24px; - - border-left: 2px solid #0073d4; - overflow: hidden; - - > .row { - display: flex; - flex-direction: row; - justify-content: stretch; - - > *:not(:last-of-type) { - margin-right: 3em; - } - } - - .container-address-password { - .container-address { - flex-grow: 1; - flex-shrink: 1; - } - - .container-password { - flex-grow: 0; - flex-shrink: 4; - - min-width: 21.5em; - } - } - - .container-profile-manage { - flex-grow: 0; - flex-shrink: 4; - - display: flex; - flex-direction: row; - justify-content: stretch; - - .container-select-profile { - flex-grow: 1; - flex-shrink: 1; - - min-width: 14em; - - > .invalid-feedback { - width: max-content; /* allow overflow here */ - } - } - - .container-manage { - flex-grow: 0; - flex-shrink: 4; - - margin-left: 15px; - } - - .button-manage-profiles { - min-width: 7em; - margin-left: 0.5em; - } - } - - .container-nickname { - flex-grow: 1; - flex-shrink: 1; - } - - .container-buttons { - padding-top: 1em; - - display: flex; - flex-direction: row; - justify-content: space-between; - - .container-buttons-connect { - display: flex; - flex-direction: row; - - flex-shrink: 1; - min-width: 6em; - } - - .button-right { - min-width: 7em; - margin-left: 0.5em; - } - - .button-left { - min-width: 14em; - } - } - - .arrow { - border-color: #7a7a7a; - margin-left: .5em; - } -} - .connectContainer { display: flex; flex-direction: column; justify-content: flex-start; + padding-top: .75em; + flex-shrink: 0; flex-grow: 0; + border-left: 2px solid #0073d4; + .row { position: relative; @@ -337,8 +43,9 @@ justify-content: stretch; .inputAddress, .inputNickname { - width: 75%; + width: 35em; min-width: 10em; + max-width: 100%; flex-grow: 1; flex-shrink: 1; @@ -347,12 +54,11 @@ } .inputPassword, .inputProfile { - width: 25%; - + width: 25em; min-width: 15em; - max-width: 21em; + max-width: 100%; - flex-grow: 1; + flex-grow: 0; flex-shrink: 1; } @@ -362,10 +68,15 @@ justify-content: stretch; .input { + overflow: visible; min-width: 0; flex-shrink: 1; flex-grow: 1; + + .invalidFeedback { + width: max-content; + } } .button { @@ -382,37 +93,261 @@ } } +.buttonContainer { + padding-top: 1em; + padding-bottom: 1.5em; + + display: flex; + flex-direction: row; + justify-content: flex-start; + + border-left: 2px solid #0073d4; + + .buttonShowHistory { + .containerText { + display: inline-block; + width: 10em; + } + + .containerArrow { + display: inline-block; + margin-left: .5em; + + :global(.arrow) { + border-color: #7a7a7a; + } + + :global(.arrow.up) { + margin-bottom: -.25em; + } + } + } + + .buttonsConnect { + padding-left: .5em; + margin-left: auto; + + display: flex; + flex-direction: row; + justify-content: flex-end; + + .button:not(:first-of-type) { + margin-left: .5em; + } + } +} + +.historyContainer { + border-left: 2px solid #7a7a7a; + border-top: 1px solid #090909; + + max-height: 0; + overflow: hidden; + + @include transition(all .3s); + + &.shown { + max-height: 30em; + } +} + +.historyTable { + margin-top: 1em; + margin-bottom: 1em; + + color: #7a7a7a; + + width: 100em; + max-width: 100%; + + display: flex; + flex-direction: column; + justify-content: stretch; + + .head { + display: flex; + flex-direction: row; + justify-content: stretch; + + flex-grow: 0; + flex-shrink: 0; + + border: none; + border-bottom: 1px solid #161618; + } + + + .body { + flex-grow: 0; + flex-shrink: 1; + + display: flex; + flex-direction: column; + justify-content: stretch; + + overflow: auto; + + .row { + cursor: pointer; + + flex-grow: 0; + flex-shrink: 0; + + display: flex; + flex-direction: row; + justify-content: stretch; + + &:hover { + background-color: #202022; + } + + &.selected { + background-color: #131315; + } + } + + .bodyEmpty { + height: 3em; + text-align: center; + display: flex; + flex-direction: column; + justify-content: space-around; + font-size: 1.25em; + color: rgba(121, 121, 121, 0.5); + } + } + + .column { + flex-grow: 1; + flex-shrink: 1; + + overflow: hidden; + white-space: nowrap; + + padding-right: .25em; + padding-left: .25em; + + display: flex; + flex-direction: row; + justify-content: flex-start; + + &:not(:last-of-type) { + border-right: 1px solid #161618; + } + + > a { + max-width: 100%; + text-overflow: ellipsis; + overflow: hidden; + } + } + + margin-left: -1.5em; /* the delete row */ + + .head { + margin-left: 1.5em; /* the delete row */ + + .column.delete { + display: none; + } + } + + .column { + align-self: center; + .country, .iconContainer { + align-self: center; + margin-right: 0.25em; + } + + + @mixin fixed-column($name, $width) { + &.#{$name} { + flex-grow: 0; + flex-shrink: 0; + + width: $width; + } + } + + @include fixed-column(delete, 1.5em); + @include fixed-column(password, 5em); + @include fixed-column(country, 7em); + @include fixed-column(clients, 4em); + @include fixed-column(connections, 6.5em); + + &.delete { + opacity: 0; + border-right: none; + border-bottom: none; + + text-align: center; + @include transition(opacity .25s ease-in-out); + } + + &.address { + flex-grow: 1; + flex-shrink: 1; + + width: 40%; + } + + &.name { + flex-grow: 1; + flex-shrink: 1; + + width: 60%; + } + } + + .row { + &:hover { + .delete { + opacity: 1; + } + } + } +} + +.countryContainer { + display: inline-flex; + flex-direction: row; + justify-content: flex-start; + + :global(.country) { + align-self: center; + margin-right: .25em; + } +} + @media all and (max-width: 55rem) { .container { padding: .5em!important; + padding-top: 0!important; - .container-address-password { - .container-password { - min-width: unset!important; - margin-left: 1em!important; - } - } - .container-buttons { - justify-content: flex-end!important; - - .button-toggle-last-servers { - display: none; - } - } - - .container-profile-name { - flex-direction: column!important; - } - - .container-last-servers { - display: none; - } } .connectContainer { .inputAddress, .inputNickname { margin-right: 1em!important; } + + .smallColumn { + flex-direction: column; + + > div { + width: 100%!important; + } + } + } + + .buttonContainer { + .buttonShowHistory { + display: none; + } + } + + .historyContainer { + display: none; } } \ No newline at end of file diff --git a/shared/js/ui/modal/connect/Renderer.tsx b/shared/js/ui/modal/connect/Renderer.tsx index 57ccfaec..fc46569b 100644 --- a/shared/js/ui/modal/connect/Renderer.tsx +++ b/shared/js/ui/modal/connect/Renderer.tsx @@ -1,104 +1,29 @@ -import {ConnectProperties, ConnectUiEvents} from "tc-shared/ui/modal/connect/Definitions"; +import { + ConnectHistoryEntry, + ConnectHistoryServerInfo, + ConnectProperties, + ConnectUiEvents, PropertyValidState +} from "tc-shared/ui/modal/connect/Definitions"; +import * as React from "react"; import {useContext, useState} from "react"; import {Registry} from "tc-shared/events"; -import * as React from "react"; import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller"; import {Translatable} from "tc-shared/ui/react-elements/i18n"; -import {ControlledSelect, FlatInputField, Select} from "tc-shared/ui/react-elements/InputField"; -import {useTr} from "tc-shared/ui/react-elements/Helper"; +import {ControlledFlatInputField, ControlledSelect, FlatInputField} from "tc-shared/ui/react-elements/InputField"; +import {joinClassList, useTr} from "tc-shared/ui/react-elements/Helper"; import {Button} from "tc-shared/ui/react-elements/Button"; +import {kUnknownHistoryServerUniqueId} from "tc-shared/connectionlog/History"; +import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons"; +import {ClientIcon} from "svg-sprites/client-icons"; +import * as i18n from "../../../i18n/country"; +import {getIconManager} from "tc-shared/file/Icons"; +import {RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon"; const EventContext = React.createContext>(undefined); const ConnectDefaultNewTabContext = React.createContext(false); const cssStyle = require("./Renderer.scss"); -/* -
-
-
- - -
{{tr "Please enter a valid server address" /}}
-
-
- -
- -
-
-
-
-
- - -
{{tr "Please enter a valid server nickname" /}}
- -
-
-
- - -
{{tr "Selected profile is invalid. Select another one or fix the profile." /}} -
-
-
- -
-
-
-
- - -
- {{if default_connect_new_tab}} - - - {{else}} - {{if multi_tab}} - - {{/if}} - - {{/if}} -
-
-
-
-
-
-
-
Nr
-
{{tr "Name" /}}
-
{{tr "Address" /}}
-
{{tr "Password" /}}
-
{{tr "Country" /}}
-
{{tr "Clients" /}}
-
{{tr "Connections" /}}
-
- -
-
- */ - function useProperty(key: T, defaultValue: V) : ConnectProperties[T] | V { const events = useContext(EventContext); const [ value, setValue ] = useState(() => { @@ -110,55 +35,93 @@ function useProperty(key: T, defaultValue: return value; } +function usePropertyValid(key: T, defaultValue: PropertyValidState[T]) : PropertyValidState[T] { + const events = useContext(EventContext); + const [ value, setValue ] = useState(() => { + events.fire("query_property_valid", { property: key }); + return defaultValue; + }); + events.reactUse("notify_property_valid", event => event.property === key && setValue(event.value as any)); + + return value; +} + const InputServerAddress = () => { + const events = useContext(EventContext); + const address = useProperty("address", undefined); + const valid = usePropertyValid("address", true); + const newTab = useContext(ConnectDefaultNewTabContext); + return ( - Server address} labelType={"static"} + + invalid={valid ? undefined : Please enter a valid server address} + + onInput={value => events.fire("action_set_address", { address: value, validate: false })} + onBlur={() => events.fire("action_set_address", { address: address?.currentAddress, validate: true })} + onEnter={() => events.fire("action_connect", { newTab })} /> ) } const InputServerPassword = () => { + const events = useContext(EventContext); + const password = useProperty("password", undefined); + return ( Server password} - labelType={"floating"} + labelType={password?.hashed ? "static" : "floating"} + onInput={value => events.fire("action_set_password", { password: value, hashed: false })} /> ) } const InputNickname = () => { + const events = useContext(EventContext); const nickname = useProperty("nickname", undefined); + const valid = usePropertyValid("nickname", true); return ( - Nickname} labelType={"static"} + invalid={valid ? undefined : Nickname too short or too long} + onInput={value => events.fire("action_set_nickname", { nickname: value, validate: false })} + onBlur={() => events.fire("action_set_nickname", { nickname: nickname?.currentNickname, validate: true })} /> ); } const InputProfile = () => { + const events = useContext(EventContext); const profiles = useProperty("profiles", undefined); const selectedProfile = profiles?.profiles.find(profile => profile.id === profiles?.selected); let invalidMarker; if(profiles) { - if(!selectedProfile) { - invalidMarker = Select a profile; + if(!profiles.selected) { + /* We have to select a profile. */ + /* TODO: Only show if we've tried to press connect */ + //invalidMarker = Please select a profile; + } else if(!selectedProfile) { + invalidMarker = Unknown select profile; } else if(!selectedProfile.valid) { - invalidMarker = Selected profile is invalid + invalidMarker = Selected profile has an invalid config } } @@ -166,20 +129,21 @@ const InputProfile = () => {
Connect profile} invalid={invalidMarker} + invalidClassName={cssStyle.invalidFeedback} + onChange={event => events.fire("action_select_profile", { id: event.target.value })} > - + + - {profiles?.profiles.forEach(profile => { - return ( - - ); - })} + {profiles?.profiles.map(profile => ( + + ))} -
@@ -192,12 +156,248 @@ const ConnectContainer = () => ( -
+
-) +); + +const ButtonToggleHistory = () => { + const state = useProperty("historyShown", false); + const events = useContext(EventContext); + + let body; + if(state) { + body = ( + +
Hide connect history
+
+ + ); + } else { + body = ( + +
Show connect history
+
+ + ); + } + return ( + + ); +} + +const ButtonsConnect = () => { + const connectNewTab = useContext(ConnectDefaultNewTabContext); + const events = useContext(EventContext); + + let left; + if(connectNewTab) { + left = ( + + ); + } else { + left = ( + + ); + } + return ( +
+ {left} + +
+ ); +}; + +const ButtonContainer = () => ( +
+ + +
+); + +const CountryIcon = (props: { country: string }) => { + return ( +
+
+ {i18n.country_name(props.country, useTr("Global"))} +
+ ) +} + +const HistoryTableEntryConnectCount = React.memo((props: { entry: ConnectHistoryEntry }) => { + const targetType = props.entry.uniqueServerId === kUnknownHistoryServerUniqueId ? "address" : "server-unique-id"; + const target = targetType === "address" ? props.entry.targetAddress : props.entry.uniqueServerId; + + const events = useContext(EventContext); + const [ amount, setAmount ] = useState(() => { + events.fire("query_history_connections", { + target, + targetType + }); + return -1; + }); + + events.reactUse("notify_history_connections", event => event.targetType === targetType && event.target === target && setAmount(event.value)); + + if(amount >= 0) { + return {amount}; + } else { + return null; + } +}); + +const HistoryTableEntry = React.memo((props: { entry: ConnectHistoryEntry, selected: boolean }) => { + const connectNewTab = useContext(ConnectDefaultNewTabContext); + const events = useContext(EventContext); + const [ info, setInfo ] = useState(() => { + if(props.entry.uniqueServerId !== kUnknownHistoryServerUniqueId) { + events.fire("query_history_entry", { serverUniqueId: props.entry.uniqueServerId }); + } + return undefined; + }); + events.reactUse("notify_history_entry", event => event.serverUniqueId === props.entry.uniqueServerId && setInfo(event.info)); + + const icon = getIconManager().resolveIcon(info ? info.icon.iconId : 0, info?.icon.serverUniqueId, info?.icon.handlerId); + + return ( +
{ + if(event.isDefaultPrevented()) { + return; + } + + events.fire("action_select_history", { id: props.entry.id }); + }} + onDoubleClick={() => events.fire("action_connect", { newTab: connectNewTab })} + > +
{ + event.preventDefault(); + + if(props.entry.uniqueServerId === kUnknownHistoryServerUniqueId) { + events.fire("action_delete_history", { + targetType: "address", + target: props.entry.targetAddress + }); + } else { + events.fire("action_delete_history", { + targetType: "server-unique-id", + target: props.entry.uniqueServerId + }); + } + }}> + +
+
+ + {info?.name} +
+
+ {props.entry.targetAddress} +
+
+ {info ? info.password ? tr("Yes") : tr("No") : ""} +
+
+ {info ? : null} +
+
+ {info && info.maxClients !== -1 ? `${info.clients}/${info.maxClients}` : ""} +
+
+ +
+
+ ); +}); + +const HistoryTable = () => { + const history = useProperty("history", undefined); + let body; + + if(history) { + if(history.history.length > 0) { + body = history.history.map(entry => ); + } else { + body = ( + + ); + } + } else { + return null; + } + return ( +
+
+
+
+ Name +
+
+ Address +
+
+ Password +
+
+ Country +
+
+ Clients +
+
+ Connections +
+
+
+ {body} +
+
+ ) +} + +const HistoryContainer = () => { + const historyShown = useProperty("historyShown", false); + + return ( +
+ +
+ ) +} export class ConnectModal extends InternalModal { private readonly events: Registry; @@ -216,6 +416,8 @@ export class ConnectModal extends InternalModal {
+ +
@@ -229,4 +431,8 @@ export class ConnectModal extends InternalModal { color(): "none" | "blue" { return "blue"; } + + verticalAlignment(): "top" | "center" | "bottom" { + return "top"; + } } \ No newline at end of file