Finishing my work on the new connect modal

This commit is contained in:
WolverinDEV 2021-01-10 13:35:19 +01:00
parent c3b64447db
commit 6748a0c978
4 changed files with 965 additions and 473 deletions

View file

@ -1,24 +1,343 @@
import {Registry} from "tc-shared/events"; 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 {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {ConnectModal} from "tc-shared/ui/modal/connect/Renderer"; 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 { class ConnectController {
readonly uiEvents: Registry<ConnectUiEvents>; readonly uiEvents: Registry<ConnectUiEvents>;
private readonly defaultAddress: string;
private readonly propertyProvider: {[K in keyof ConnectProperties]?: () => Promise<ConnectProperties[K]>} = {};
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() { constructor() {
this.uiEvents = new Registry<ConnectUiEvents>(); this.uiEvents = new Registry<ConnectUiEvents>();
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() { destroy() {
Object.keys(this.propertyProvider).forEach(key => delete this.propertyProvider[key]);
this.uiEvents.destroy();
} }
generateConnectParameters() : ConnectParameters | undefined {
private sendProperty(property: keyof ConnectProperties) { if(Object.keys(this.validStates).findIndex(key => this.validStates[key] === false) !== -1) {
switch (property) { return undefined;
case "address":
} }
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", () => { modal.events.one("destroy", () => {
controller.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; (window as any).spawnConnectModalNew = spawnConnectModalNew;

View file

@ -1,4 +1,5 @@
import {kUnknownHistoryServerUniqueId} from "tc-shared/connectionlog/History"; import {kUnknownHistoryServerUniqueId} from "tc-shared/connectionlog/History";
import { RemoteIconInfo} from "tc-shared/file/Icons";
export type ConnectProfileEntry = { export type ConnectProfileEntry = {
id: string, id: string,
@ -13,36 +14,36 @@ export type ConnectHistoryEntry = {
} }
export type ConnectHistoryServerInfo = { export type ConnectHistoryServerInfo = {
iconId: number, icon: RemoteIconInfo,
name: string, name: string,
password: boolean, 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 { export interface ConnectProperties {
address: ConnectServerAddress, address: {
nickname: ConnectServerNickname, currentAddress: string,
password: string, defaultAddress: string,
profiles: ConnectProfiles, },
nickname: {
currentNickname: string | undefined,
defaultNickname: string | undefined,
},
password: {
password: string,
hashed: boolean
} | undefined,
profiles: {
profiles: ConnectProfileEntry[],
selected: string
},
historyShown: boolean,
history: { history: {
history: ConnectHistoryEntry[], history: ConnectHistoryEntry[],
selected: number | -1, selected: number | -1,
state: "shown" | "hidden"
}, },
} }
@ -50,11 +51,12 @@ export interface PropertyValidState {
address: boolean, address: boolean,
nickname: boolean, nickname: boolean,
password: boolean, password: boolean,
profile: boolean
} }
type ConnectProperty<T extends keyof ConnectProperties> = { type IAccess<I, T extends keyof I> = {
property: T, property: T,
value: ConnectProperties[T] value: I[T]
}; };
export interface ConnectUiEvents { export interface ConnectUiEvents {
@ -66,13 +68,20 @@ export interface ConnectUiEvents {
action_delete_history: { action_delete_history: {
target: string, target: string,
targetType: "address" | "server-unique-id" 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: { query_property: {
property: keyof ConnectProperties property: keyof ConnectProperties
}, },
query_property_valid: {
property: keyof PropertyValidState
},
notify_property: ConnectProperty<keyof ConnectProperties> notify_property: IAccess<ConnectProperties, keyof ConnectProperties>,
notify_property_valid: IAccess<PropertyValidState, keyof PropertyValidState>,
query_history_entry: { query_history_entry: {
serverUniqueId: string serverUniqueId: string

View file

@ -5,330 +5,36 @@
@include user-select(none); @include user-select(none);
font-size: 1rem; font-size: 1rem;
padding: 1em;
width: 50em; width: 60em;
min-width: 25em; min-width: 25em;
max-width: 100%; max-width: 100%;
flex-shrink: 1; flex-shrink: 1;
display: flex!important; display: flex;
flex-direction: column!important; flex-direction: column;
justify-content: stretch!important; justify-content: stretch;
.container-last-servers { > * {
flex-grow: 0; padding-left: 1.5em;
flex-shrink: 1; padding-right: 1.5em;
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%;
}
}
}
} }
} }
.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 { .connectContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
padding-top: .75em;
flex-shrink: 0; flex-shrink: 0;
flex-grow: 0; flex-grow: 0;
border-left: 2px solid #0073d4;
.row { .row {
position: relative; position: relative;
@ -337,8 +43,9 @@
justify-content: stretch; justify-content: stretch;
.inputAddress, .inputNickname { .inputAddress, .inputNickname {
width: 75%; width: 35em;
min-width: 10em; min-width: 10em;
max-width: 100%;
flex-grow: 1; flex-grow: 1;
flex-shrink: 1; flex-shrink: 1;
@ -347,12 +54,11 @@
} }
.inputPassword, .inputProfile { .inputPassword, .inputProfile {
width: 25%; width: 25em;
min-width: 15em; min-width: 15em;
max-width: 21em; max-width: 100%;
flex-grow: 1; flex-grow: 0;
flex-shrink: 1; flex-shrink: 1;
} }
@ -362,10 +68,15 @@
justify-content: stretch; justify-content: stretch;
.input { .input {
overflow: visible;
min-width: 0; min-width: 0;
flex-shrink: 1; flex-shrink: 1;
flex-grow: 1; flex-grow: 1;
.invalidFeedback {
width: max-content;
}
} }
.button { .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) { @media all and (max-width: 55rem) {
.container { .container {
padding: .5em!important; 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 { .connectContainer {
.inputAddress, .inputNickname { .inputAddress, .inputNickname {
margin-right: 1em!important; margin-right: 1em!important;
} }
.smallColumn {
flex-direction: column;
> div {
width: 100%!important;
}
}
}
.buttonContainer {
.buttonShowHistory {
display: none;
}
}
.historyContainer {
display: none;
} }
} }

View file

@ -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 {useContext, useState} from "react";
import {Registry} from "tc-shared/events"; import {Registry} from "tc-shared/events";
import * as React from "react";
import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller"; import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller";
import {Translatable} from "tc-shared/ui/react-elements/i18n"; import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {ControlledSelect, FlatInputField, Select} from "tc-shared/ui/react-elements/InputField"; import {ControlledFlatInputField, ControlledSelect, FlatInputField} from "tc-shared/ui/react-elements/InputField";
import {useTr} from "tc-shared/ui/react-elements/Helper"; import {joinClassList, useTr} from "tc-shared/ui/react-elements/Helper";
import {Button} from "tc-shared/ui/react-elements/Button"; 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<Registry<ConnectUiEvents>>(undefined); const EventContext = React.createContext<Registry<ConnectUiEvents>>(undefined);
const ConnectDefaultNewTabContext = React.createContext<boolean>(false); const ConnectDefaultNewTabContext = React.createContext<boolean>(false);
const cssStyle = require("./Renderer.scss"); const cssStyle = require("./Renderer.scss");
/*
<div class="container-connect-input">
<div class="row container-address-password">
<div class="form-group container-address">
<label>{{tr "Server Address" /}}</label>
<input type="text" class="form-control" aria-describedby="input-connect-address-help"
placeholder="ts.teaspeak.de" autocomplete="off">
<div class="invalid-feedback">{{tr "Please enter a valid server address" /}}</div>
</div>
<div class="form-group container-password">
<label class="bmd-label-floating">{{tr "Server password" /}}</label>
<form autocomplete="off" onsubmit="return false;">
<input id="input-connect-password-{{>password_id}}" type="password" class="form-control"
autocomplete="off">
</form>
</div>
</div>
<div class="row container-profile-name">
<div class="form-group container-nickname">
<label>{{tr "Nickname" /}}</label>
<input type="text" class="form-control" aria-describedby="input-connect-nickname-help"
placeholder="Another TeaSpeak user">
<div class="invalid-feedback">{{tr "Please enter a valid server nickname" /}}</div>
<!-- <small id="input-connect-nickname-help" class="form-text text-muted">We'll never share your email with anyone else.</small> -->
</div>
<div class="container-profile-manage">
<div class="form-group container-select-profile">
<label for="select-connect-profile">{{tr "Connect Profile" /}}</label>
<select class="form-control" id="select-connect-profile"> </select>
<div class="invalid-feedback">{{tr "Selected profile is invalid. Select another one or fix the profile." /}}
</div>
</div>
<div class="form-group">
<button type="button" class="btn btn-raised button-manage-profiles">{{tr "Profiles" /}}
</button>
</div>
</div>
</div>
<div class="container-buttons">
<button type="button" class="btn btn-raised button-toggle-last-servers"><a>{{tr "Show last servers"
/}}</a>
<div class="arrow down"></div>
</button>
<div class="container-buttons-connect">
{{if default_connect_new_tab}}
<button type="button" class="btn btn-raised btn-success button-connect button-left">{{tr
"Connect in same tab" /}}
</button>
<button type="button" class="btn btn-raised btn-success button-connect-new-tab button-right">
{{tr "Connect" /}}
</button>
{{else}}
{{if multi_tab}}
<button type="button" class="btn btn-raised btn-success button-connect-new-tab button-left">{{tr
"Connect in a new tab" /}}
</button>
{{/if}}
<button type="button" class="btn btn-raised btn-success button-connect button-right">{{tr
"Connect" /}}
</button>
{{/if}}
</div>
</div>
</div>
<div class="container-last-servers">
<hr>
<div class="table">
<div class="head">
<div class="column delete">Nr</div>
<div class="column name">{{tr "Name" /}}</div>
<div class="column address">{{tr "Address" /}}</div>
<div class="column password">{{tr "Password" /}}</div>
<div class="column country-name">{{tr "Country" /}}</div>
<div class="column clients">{{tr "Clients" /}}</div>
<div class="column connections">{{tr "Connections" /}}</div>
</div>
<div class="body">
<div class="body-empty">
<a>{{tr "No connections yet made" /}}</a>
</div>
</div>
</div>
</div>
*/
function useProperty<T extends keyof ConnectProperties, V>(key: T, defaultValue: V) : ConnectProperties[T] | V { function useProperty<T extends keyof ConnectProperties, V>(key: T, defaultValue: V) : ConnectProperties[T] | V {
const events = useContext(EventContext); const events = useContext(EventContext);
const [ value, setValue ] = useState<ConnectProperties[T] | V>(() => { const [ value, setValue ] = useState<ConnectProperties[T] | V>(() => {
@ -110,55 +35,93 @@ function useProperty<T extends keyof ConnectProperties, V>(key: T, defaultValue:
return value; return value;
} }
function usePropertyValid<T extends keyof PropertyValidState>(key: T, defaultValue: PropertyValidState[T]) : PropertyValidState[T] {
const events = useContext(EventContext);
const [ value, setValue ] = useState<PropertyValidState[T]>(() => {
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 InputServerAddress = () => {
const events = useContext(EventContext);
const address = useProperty("address", undefined);
const valid = usePropertyValid("address", true);
const newTab = useContext(ConnectDefaultNewTabContext);
return ( return (
<FlatInputField <ControlledFlatInputField
value={address?.currentAddress || ""}
placeholder={address?.defaultAddress || tr("Please enter a address")}
className={cssStyle.inputAddress} className={cssStyle.inputAddress}
value={"ts.teaspeak.de"}
placeholder={"ts.teaspeak.de"}
label={<Translatable>Server address</Translatable>} label={<Translatable>Server address</Translatable>}
labelType={"static"} labelType={"static"}
invalid={valid ? undefined : <Translatable>Please enter a valid server address</Translatable>}
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 InputServerPassword = () => {
const events = useContext(EventContext);
const password = useProperty("password", undefined);
return ( return (
<FlatInputField <FlatInputField
className={cssStyle.inputPassword} className={cssStyle.inputPassword}
value={"ts.teaspeak.de"} value={!password?.hashed ? password?.password || "" : ""}
placeholder={"ts.teaspeak.de"} placeholder={password?.hashed ? tr("Password Hidden") : null}
type={"password"} type={"password"}
label={<Translatable>Server password</Translatable>} label={<Translatable>Server password</Translatable>}
labelType={"floating"} labelType={password?.hashed ? "static" : "floating"}
onInput={value => events.fire("action_set_password", { password: value, hashed: false })}
/> />
) )
} }
const InputNickname = () => { const InputNickname = () => {
const events = useContext(EventContext);
const nickname = useProperty("nickname", undefined); const nickname = useProperty("nickname", undefined);
const valid = usePropertyValid("nickname", true);
return ( return (
<FlatInputField <ControlledFlatInputField
className={cssStyle.inputNickname} className={cssStyle.inputNickname}
value={nickname?.currentNickname || ""} value={nickname?.currentNickname || ""}
placeholder={nickname ? nickname.defaultNickname : tr("loading...")} placeholder={nickname ? nickname.defaultNickname ? nickname.defaultNickname : tr("Please enter a nickname") : tr("loading...")}
label={<Translatable>Nickname</Translatable>} label={<Translatable>Nickname</Translatable>}
labelType={"static"} labelType={"static"}
invalid={valid ? undefined : <Translatable>Nickname too short or too long</Translatable>}
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 InputProfile = () => {
const events = useContext(EventContext);
const profiles = useProperty("profiles", undefined); const profiles = useProperty("profiles", undefined);
const selectedProfile = profiles?.profiles.find(profile => profile.id === profiles?.selected); const selectedProfile = profiles?.profiles.find(profile => profile.id === profiles?.selected);
let invalidMarker; let invalidMarker;
if(profiles) { if(profiles) {
if(!selectedProfile) { if(!profiles.selected) {
invalidMarker = <Translatable key={"no-profile"}>Select a profile</Translatable>; /* We have to select a profile. */
/* TODO: Only show if we've tried to press connect */
//invalidMarker = <Translatable key={"no-profile"}>Please select a profile</Translatable>;
} else if(!selectedProfile) {
invalidMarker = <Translatable key={"no-profile"}>Unknown select profile</Translatable>;
} else if(!selectedProfile.valid) { } else if(!selectedProfile.valid) {
invalidMarker = <Translatable key={"invalid"}>Selected profile is invalid</Translatable> invalidMarker = <Translatable key={"invalid"}>Selected profile has an invalid config</Translatable>
} }
} }
@ -166,20 +129,21 @@ const InputProfile = () => {
<div className={cssStyle.inputProfile}> <div className={cssStyle.inputProfile}>
<ControlledSelect <ControlledSelect
className={cssStyle.input} className={cssStyle.input}
value={profiles?.selected || "loading"} value={selectedProfile ? selectedProfile.id : profiles?.selected ? "invalid" : profiles ? "no-selected" : "loading"}
type={"flat"} type={"flat"}
label={<Translatable>Connect profile</Translatable>} label={<Translatable>Connect profile</Translatable>}
invalid={invalidMarker} invalid={invalidMarker}
invalidClassName={cssStyle.invalidFeedback}
onChange={event => events.fire("action_select_profile", { id: event.target.value })}
> >
<option key={"loading"} value={"invalid"} style={{ display: "none" }}>{useTr("unknown profile")}</option> <option key={"no-selected"} value={"no-selected"} style={{ display: "none" }}>{useTr("please select")}</option>
<option key={"invalid"} value={"invalid"} style={{ display: "none" }}>{useTr("unknown profile")}</option>
<option key={"loading"} value={"loading"} style={{ display: "none" }}>{useTr("loading") + "..."}</option> <option key={"loading"} value={"loading"} style={{ display: "none" }}>{useTr("loading") + "..."}</option>
{profiles?.profiles.forEach(profile => { {profiles?.profiles.map(profile => (
return ( <option key={"profile-" + profile.id} value={profile.id}>{profile.name}</option>
<option key={"profile-" + profile.id}>{profile.name}</option> ))}
);
})}
</ControlledSelect> </ControlledSelect>
<Button className={cssStyle.button} type={"small"} color={"none"}> <Button className={cssStyle.button} type={"small"} color={"none"} onClick={() => events.fire("action_manage_profiles")}>
<Translatable>Profiles</Translatable> <Translatable>Profiles</Translatable>
</Button> </Button>
</div> </div>
@ -192,12 +156,248 @@ const ConnectContainer = () => (
<InputServerAddress /> <InputServerAddress />
<InputServerPassword /> <InputServerPassword />
</div> </div>
<div className={cssStyle.row}> <div className={cssStyle.row + " " + cssStyle.smallColumn}>
<InputNickname /> <InputNickname />
<InputProfile /> <InputProfile />
</div> </div>
</div> </div>
) );
const ButtonToggleHistory = () => {
const state = useProperty("historyShown", false);
const events = useContext(EventContext);
let body;
if(state) {
body = (
<React.Fragment key={"hide"}>
<div className={cssStyle.containerText}><Translatable>Hide connect history</Translatable></div>
<div className={cssStyle.containerArrow}><div className={"arrow down"} /></div>
</React.Fragment>
);
} else {
body = (
<React.Fragment key={"show"}>
<div className={cssStyle.containerText}><Translatable>Show connect history</Translatable></div>
<div className={cssStyle.containerArrow}><div className={"arrow up"} /></div>
</React.Fragment>
);
}
return (
<Button
className={cssStyle.buttonShowHistory + " " + cssStyle.button}
type={"small"}
color={"none"}
onClick={() => events.fire("action_toggle_history", { enabled: !state })}
>
{body}
</Button>
);
}
const ButtonsConnect = () => {
const connectNewTab = useContext(ConnectDefaultNewTabContext);
const events = useContext(EventContext);
let left;
if(connectNewTab) {
left = (
<Button
color={"green"}
type={"small"}
key={"same-tab"}
onClick={() => events.fire("action_connect", { newTab: false })}
className={cssStyle.button}
>
<Translatable>Connect in the same tab</Translatable>
</Button>
);
} else {
left = (
<Button
color={"green"}
type={"small"}
key={"new-tab"}
onClick={() => events.fire("action_connect", { newTab: true })}
className={cssStyle.button}
>
<Translatable>Connect in a new tab</Translatable>
</Button>
);
}
return (
<div className={cssStyle.buttonsConnect}>
{left}
<Button
color={"green"}
type={"small"}
onClick={() => events.fire("action_connect", { newTab: connectNewTab })}
className={cssStyle.button}
>
<Translatable>Connect</Translatable>
</Button>
</div>
);
};
const ButtonContainer = () => (
<div className={cssStyle.buttonContainer}>
<ButtonToggleHistory />
<ButtonsConnect />
</div>
);
const CountryIcon = (props: { country: string }) => {
return (
<div className={cssStyle.countryContainer}>
<div className={"country flag-" + props.country} />
{i18n.country_name(props.country, useTr("Global"))}
</div>
)
}
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 <React.Fragment key={"set"}>{amount}</React.Fragment>;
} else {
return null;
}
});
const HistoryTableEntry = React.memo((props: { entry: ConnectHistoryEntry, selected: boolean }) => {
const connectNewTab = useContext(ConnectDefaultNewTabContext);
const events = useContext(EventContext);
const [ info, setInfo ] = useState<ConnectHistoryServerInfo>(() => {
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 (
<div className={cssStyle.row + " " + (props.selected ? cssStyle.selected : "")}
onClick={event => {
if(event.isDefaultPrevented()) {
return;
}
events.fire("action_select_history", { id: props.entry.id });
}}
onDoubleClick={() => events.fire("action_connect", { newTab: connectNewTab })}
>
<div className={cssStyle.column + " " + cssStyle.delete} onClick={event => {
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
});
}
}}>
<ClientIconRenderer icon={ClientIcon.Delete} />
</div>
<div className={cssStyle.column + " " + cssStyle.name}>
<RemoteIconRenderer icon={icon} className={cssStyle.iconContainer} />
{info?.name}
</div>
<div className={cssStyle.column + " " + cssStyle.address}>
{props.entry.targetAddress}
</div>
<div className={cssStyle.column + " " + cssStyle.password}>
{info ? info.password ? tr("Yes") : tr("No") : ""}
</div>
<div className={cssStyle.column + " " + cssStyle.country}>
{info ? <CountryIcon country={info.country || "xx"} key={"country"} /> : null}
</div>
<div className={cssStyle.column + " " + cssStyle.clients}>
{info && info.maxClients !== -1 ? `${info.clients}/${info.maxClients}` : ""}
</div>
<div className={cssStyle.column + " " + cssStyle.connections}>
<HistoryTableEntryConnectCount entry={props.entry} />
</div>
</div>
);
});
const HistoryTable = () => {
const history = useProperty("history", undefined);
let body;
if(history) {
if(history.history.length > 0) {
body = history.history.map(entry => <HistoryTableEntry entry={entry} key={"entry-" + entry.id} selected={entry.id === history.selected} />);
} else {
body = (
<div className={cssStyle.bodyEmpty} key={"no-history"}>
<a><Translatable>No connections yet made</Translatable></a>
</div>
);
}
} else {
return null;
}
return (
<div className={cssStyle.historyTable}>
<div className={cssStyle.head}>
<div className={cssStyle.column + " " + cssStyle.delete} />
<div className={cssStyle.column + " " + cssStyle.name}>
<Translatable>Name</Translatable>
</div>
<div className={cssStyle.column + " " + cssStyle.address}>
<Translatable>Address</Translatable>
</div>
<div className={cssStyle.column + " " + cssStyle.password}>
<Translatable>Password</Translatable>
</div>
<div className={cssStyle.column + " " + cssStyle.country}>
<Translatable>Country</Translatable>
</div>
<div className={cssStyle.column + " " + cssStyle.clients}>
<Translatable>Clients</Translatable>
</div>
<div className={cssStyle.column + " " + cssStyle.connections}>
<Translatable>Connections</Translatable>
</div>
</div>
<div className={cssStyle.body}>
{body}
</div>
</div>
)
}
const HistoryContainer = () => {
const historyShown = useProperty("historyShown", false);
return (
<div className={joinClassList(cssStyle.historyContainer, historyShown && cssStyle.shown)}>
<HistoryTable />
</div>
)
}
export class ConnectModal extends InternalModal { export class ConnectModal extends InternalModal {
private readonly events: Registry<ConnectUiEvents>; private readonly events: Registry<ConnectUiEvents>;
@ -216,6 +416,8 @@ export class ConnectModal extends InternalModal {
<ConnectDefaultNewTabContext.Provider value={this.connectNewTabByDefault}> <ConnectDefaultNewTabContext.Provider value={this.connectNewTabByDefault}>
<div className={cssStyle.container}> <div className={cssStyle.container}>
<ConnectContainer /> <ConnectContainer />
<ButtonContainer />
<HistoryContainer />
</div> </div>
</ConnectDefaultNewTabContext.Provider> </ConnectDefaultNewTabContext.Provider>
</EventContext.Provider> </EventContext.Provider>
@ -229,4 +431,8 @@ export class ConnectModal extends InternalModal {
color(): "none" | "blue" { color(): "none" | "blue" {
return "blue"; return "blue";
} }
verticalAlignment(): "top" | "center" | "bottom" {
return "top";
}
} }