Added the new bookmark system and updated the bookmark UI

This commit is contained in:
WolverinDEV 2021-03-14 19:39:08 +01:00
parent 439ba5488e
commit 6ff210be07
63 changed files with 3078 additions and 1538 deletions

View file

@ -1,4 +1,12 @@
# Changelog:
* **14.03.21**
- Enchanted the bookmark system
- Added support for auto connect on startup
- Cleaned and simplified up the bookmark UI
- Added support for importing/exporting bookmarks
- Added support for duplicating bookmarks
- Adding support for default channels and passwords
* **12.03.21**
- Added a new video spotlight mode which allows showing multiple videos at the same time as well as
dragging and resizing them
@ -150,7 +158,7 @@
- Fixed invalid channel tree unique id assignment for the initial server entry ([#F2986](https://forum.teaspeak.de/index.php?threads/2986))
* **27.09.20**
- Middle clicking on bookmarks now directly connects in a new tab
- Middle clicking on bookmarksOld now directly connects in a new tab
* **26.09.20**
- Updating group prefix/suffixes when the group naming mode changes
@ -320,7 +328,7 @@
- Fixed channel tree deletions
- Removed layout recalculate bottleneck on connection handler switching
- Fixed empty channel tree on tab change, if the tree has some scroll offset
- Added the ability to duplicate bookmarks
- Added the ability to duplicate bookmarksOld
- Fixed issue [#106](https://github.com/TeaSpeak/TeaWeb/issues/106)
- Fixed issue [#90](https://github.com/TeaSpeak/TeaWeb/issues/90)
@ -350,7 +358,7 @@
* **21.04.20**
- Clicking on the music bot does not longer results in the insufficient permission sound when the client has no permissions
- Fixed permission editor overflow
- Fixed the bookmark edit window (bookmarks have failed to save)
- Fixed the bookmark edit window (bookmarksOld have failed to save)
* **18.04.20**
- Recoded the channel tree using React
@ -447,7 +455,7 @@
- Improved the server info modal experience (Correctly showing no permissions)
- Improved "About" modal overflow behaviour
- Allow the client to use the scroll bar without closing the modal within modals
- Improved bookmarks modal for smaller devices
- Improved bookmarksOld modal for smaller devices
- Fixed invalid white space representation
* **10.12.19**
@ -637,7 +645,7 @@
- Added query account management (since server 1.2.32b)
* **18.12.18**
- Added bookmarks and bookmarks management
- Added bookmarksOld and bookmarksOld management
- Added query user visibility button and creation (Query management will follow soon)
- Fixed overflow within the group assignment dialog

View file

@ -40,7 +40,7 @@ const APP_FILE_LIST_SHARED_SOURCE: ProjectResource[] = [
},
{ /* javascript files as manifest.json */
"type": "js",
"search-pattern": /.*\.(js|json|svg)$/,
"search-pattern": /.*\.(js|json|svg|png)$/,
"build-target": "dev|rel",
"path": "js/",

88
package-lock.json generated
View file

@ -8912,6 +8912,11 @@
}
}
},
"jsonschema": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.4.0.tgz",
"integrity": "sha512-/YgW6pRMr6M7C+4o8kS+B/2myEpHCrxO4PEWnqJNBFMjn7EWXqlQ4tGwL6xTHeRplwuZmcAncdvfOad1nT2yMw=="
},
"jsprim": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz",
@ -14431,6 +14436,89 @@
}
}
},
"url-loader": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz",
"integrity": "sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==",
"dev": true,
"requires": {
"loader-utils": "^2.0.0",
"mime-types": "^2.1.27",
"schema-utils": "^3.0.0"
},
"dependencies": {
"@types/json-schema": {
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz",
"integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==",
"dev": true
},
"ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
}
},
"ajv-keywords": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
"dev": true
},
"json5": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz",
"integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==",
"dev": true,
"requires": {
"minimist": "^1.2.5"
}
},
"loader-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
"dev": true,
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
}
},
"mime-db": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.46.0.tgz",
"integrity": "sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ==",
"dev": true
},
"mime-types": {
"version": "2.1.29",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.29.tgz",
"integrity": "sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ==",
"dev": true,
"requires": {
"mime-db": "1.46.0"
}
},
"schema-utils": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.0.0.tgz",
"integrity": "sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA==",
"dev": true,
"requires": {
"@types/json-schema": "^7.0.6",
"ajv": "^6.12.5",
"ajv-keywords": "^3.5.2"
}
}
}
},
"url-parse-lax": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz",

View file

@ -74,6 +74,7 @@
"ts-loader": "^6.2.2",
"tsd": "^0.13.1",
"typescript": "^3.7.0",
"url-loader": "^4.1.1",
"wabt": "^1.0.13",
"webpack": "^4.42.1",
"webpack-bundle-analyzer": "^3.6.1",
@ -101,6 +102,7 @@
"highlight.js": "^10.1.1",
"ip-regex": "^4.2.0",
"jquery": "^3.5.1",
"jsonschema": "^1.4.0",
"jsrender": "^1.0.7",
"moment": "^2.24.0",
"react": "^16.13.1",

View file

@ -14,7 +14,6 @@ import "!style-loader!css-loader?url=false!sass-loader?sourceMap=true!./static/m
import "!style-loader!css-loader?url=false!sass-loader?sourceMap=true!./static/modal-avatar.scss"
import "!style-loader!css-loader?url=false!sass-loader?sourceMap=true!./static/modal-banclient.scss"
import "!style-loader!css-loader?url=false!sass-loader?sourceMap=true!./static/modal-banlist.scss"
import "!style-loader!css-loader?url=false!sass-loader?sourceMap=true!./static/modal-bookmarks.scss"
import "!style-loader!css-loader?url=false!sass-loader?sourceMap=true!./static/modal-channelinfo.scss"
import "!style-loader!css-loader?url=false!sass-loader?sourceMap=true!./static/modal-clientinfo.scss"
import "!style-loader!css-loader?url=false!sass-loader?sourceMap=true!./static/modal-connect.scss"

View file

@ -66,29 +66,29 @@
user-select: $mode;
}
@mixin chat-scrollbar() {
@mixin chat-scrollbar($width: .5em) {
& {
/* for moz */
scrollbar-color: #353535 #555;
scrollbarWidth: .5em;
scrollbarWidth: $width;
}
&::-webkit-scrollbar-track {
border-radius: .25em;
border-radius: $width / 2;
background-color: transparent;
cursor: pointer;
}
&::-webkit-scrollbar {
width: .5em;
height: .5em;
width: $width;
height: $width;
background-color: transparent;
cursor: pointer;
}
&::-webkit-scrollbar-thumb {
border-radius: .25em;
border-radius: $width / 2;
background-color: #555;
}

View file

@ -1,498 +0,0 @@
@import "properties";
@import "mixin";
.modal .modal-bookmark-create {
.property {
margin-top: 5px;
display: flex;
flex-direction: row;
justify-content: stretch;
.key {
flex-grow: 0;
flex-shrink: 0;
width: 150px;
}
select, input {
flex-grow: 1;
flex-shrink: 1;
}
}
.buttons {
text-align: right;
button {
min-width: 200px;
}
margin-bottom: 5px;
}
}
.modal-body.modal-bookmarks {
padding: 0!important;
display: flex!important;
flex-direction: row!important;
justify-content: stretch!important;
min-width: 30em!important;
height: 45em;
width: 80em;
@include user-select(none);
.container-tooltip {
flex-shrink: 0;
flex-grow: 0;
position: relative;
width: 1.6em;
margin-left: .5em;
font-size: .9em;
display: flex;
flex-direction: column;
justify-content: center;
img {
height: 1em;
width: 1em;
align-self: center;
font-size: 1.2em;
}
.tooltip {
display: none;
}
}
.input-boxed {
height: 2em;
}
.left {
min-width: 12em;
width: 30%;
flex-grow: 1;
flex-shrink: 1;
padding: .5em;
background-color: #212125;
display: flex;
flex-direction: column;
justify-content: stretch;
.title {
flex-shrink: 0;
flex-grow: 0;
text-align: center;
font-size: 1.5em;
color: #557edc;
text-transform: uppercase;
@include text-dotdotdot();
}
.container-bookmarks {
flex-shrink: 1;
flex-grow: 1;
min-height: 6em;
display: flex;
flex-direction: column;
justify-content: stretch;
overflow: auto;
@include chat-scrollbar-vertical();
@include chat-scrollbar-horizontal();
.bookmark, .directory {
flex-grow: 0;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: stretch;
border-radius: $border_radius_middle;
padding: .25em .5em;
cursor: pointer;
.icon-container {
flex-grow: 0;
flex-shrink: 0;
align-self: center;
margin-right: .5em;
}
.name {
flex-grow: 1;
flex-shrink: 1;
min-width: 5em;
align-self: center;
@include text-dotdotdot();
}
&:hover {
background-color: #2c2d2f;
}
&.selected {
background-color: #1a1a1b;
}
.link {
flex-grow: 0;
flex-shrink: 0;
position: relative;
width: 1.5em;
$line_width: 2px;
$color: hsla(0, 0%, 35%, 1);
&:not(.hidden) {
&:before {
content: "";
position: absolute;
height: 2.25em; /* connect with the previous one */
width: .75em;
left: .5em; /* icons have a width of 1em */
bottom: calc(.75em - #{$line_width / 2});
border-left: $line_width solid $color;
}
&.connected {
&:before {
border-bottom: $line_width solid $color;
border-bottom-left-radius: .3em;
}
}
}
}
}
.link-start {
.link.connected {
&:before {
height: 1.25em;
}
}
}
.directory {
.name {
//color: #557edc;
}
}
}
.buttons {
flex-shrink: 0;
flex-grow: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
padding-top: .5em;
button {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:not(:first-of-type) {
margin-left: .5em;
}
}
}
}
.right {
min-width: 25em;
width: 30%;
flex-grow: 1;
flex-shrink: 1;
background-color: #2f2f35;
display: flex;
flex-direction: column;
justify-content: flex-start;
.header {
flex-grow: 0;
flex-shrink: 0;
height: 10em;
background: url('../../../img/bookmark_background.png'), url('../../img/bookmark_background.png'), url('img/bookmark_background.png') no-repeat;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: .5em;
.container-name {
font-size: 2em;
color: #fcfcfc;
@include text-dotdotdot();
}
.container-address {
font-size: 1.5em;
color: #fcfcfc;
@include text-dotdotdot();
}
}
.container-settings {
flex-grow: 1;
flex-shrink: 1;
min-height: 10em;
padding: .5em;
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow-y: auto;
overflow-x: hidden;
@include chat-scrollbar-vertical();
.group {
padding: .5em;
border-radius: .2em;
border: 1px solid #1f2122;
background-color: #28292b;
display: flex;
flex-direction: column;
justify-content: flex-start;
> .row {
display: flex;
flex-direction: row;
justify-content: stretch;
.key {
flex-grow: 0;
flex-shrink: 1;
width: 15em;
min-width: 2em;
align-self: center;
color: #557edc;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.value {
flex-grow: 1;
flex-shrink: 1;
min-width: 2em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:not(:first-of-type) {
margin-top: 1em;
}
}
&:not(:first-of-type) {
margin-top: 1em;
}
&.info {
flex-direction: row;
}
.container-image {
flex-grow: 1;
flex-shrink: 1;
max-width: 15em;
max-height: 9em; /* minus one padding */
width: 15em;
display: flex;
flex-direction: column;
justify-content: center;
img {
object-fit: contain;
max-height: 100%;
max-width: 100%;
}
@include transition(.25s ease-in-out);
}
.container-properties {
flex-shrink: 1;
flex-grow: 1;
min-width: 23em;
display: flex;
flex-direction: column;
justify-content: flex-start;
height: inherit;
.row {
flex-grow: 0;
flex-shrink: 0;
height: 1.8em;
display: flex;
flex-direction: row;
justify-content: flex-start;
.key {
flex-shrink: 0;
flex-grow: 0;
color: #557edc;
text-transform: uppercase;
align-self: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 15em;
}
.value {
color: #d6d6d7;
align-self: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.server-region {
> div {
display: inline-block;
}
.country {
margin-right: .25em;
}
}
.connect-count, .connect-never {
display: inline-block;
color: #7a3131;
}
}
}
.container-network {
display: flex;
flex-direction: row;
justify-content: center;
.container-button {
margin-right: 1em;
flex-shrink: 1;
min-width: 5em;
display: flex;
flex-direction: column;
justify-content: flex-end;
button {
height: 2.5em;
width: 12em;
max-width: 100%;
@include text-dotdotdot();
}
}
.right {
flex-grow: 1;
}
}
}
}
}
.buttons {
padding: .5em;
display: flex;
flex-direction: row;
justify-content: flex-start;
.button-duplicate {
margin-right: auto;
}
button {
flex-shrink: 1;
flex-grow: 1;
min-width: 2em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:not(:first-of-type) {
margin-left: .5em;
}
}
}
}
}
@media all and (max-width: 50em) {
.modal-body.modal-bookmarks {
.container-image {
margin: 0!important;
max-width: 0!important;
}
}
}

View file

@ -2035,173 +2035,6 @@
</div>
</script>
<script class="jsrender-template" id="tmpl_manage_bookmarks" type="text/html">
<div>
<div class="left">
<div class="title" title="{{tr 'Your bookmarks' /}}">{{tr "Your bookmarks" /}}</div>
<div class="container-bookmarks">
</div>
<div class="buttons">
<button class="btn btn-danger button-delete" title="{{tr 'Delete' /}}">{{tr "Delete" /}}</button>
<button class="btn btn-purple button-add-folder" title="{{tr 'Add Folder' /}}">{{tr "Add Folder" /}}</button>
<button class="btn btn-success button-add-bookmark" title="{{tr 'Add Bookmark' /}}">{{tr "Add Bookmark" /}}</button>
</div>
</div>
<div class="container-seperator vertical" seperator-id="seperator-bookmarks"></div>
<div class="right">
<div class="header">
<div class="container-name">{{tr "Your bookmarks" /}}</div>
<div class="container-address"></div>
</div>
<div class="container-settings" data-simplebar data-simplebar-auto-hide="false">
<div class="group">
<div class="row">
<div class="key">{{tr "Bookmark Name" /}}</div>
<div class="value">
<div class="input-boxed">
<input class="input-bookmark-name">
<!--
<div class="container-tooltip">
<img src="img/icon_tooltip.svg"/>
<div class="tooltip">
<a>{{tr "The displayed name of this bookmark." /}}</a>
</div>
</div>
-->
</div>
</div>
</div>
<div class="row">
<div class="key">{{tr "Connect Profile" /}}</div>
<div class="value">
<div class="input-boxed">
<select class="input-connect-profile">
</select>
<!--
<div class="container-tooltip">
<img src="img/icon_tooltip.svg"/>
<div class="tooltip">
<a>{{tr "The profile which you're connection with when you're using the bookmark." /}}</a>
</div>
</div>
-->
</div>
</div>
</div>
</div>
<div class="group">
<div class="row">
<div class="key">{{tr "Server Address" /}}</div>
<div class="value">
<div class="input-boxed">
<input class="input-server-address">
<!--
<div class="container-tooltip">
<img src="img/icon_tooltip.svg"/>
<div class="tooltip">
<a>{{tr "The server address of the bookmark. The port is separated via a colon" /}}</a>
</div>
</div>
-->
</div>
</div>
</div>
<div class="row">
<div class="key">{{tr "Server Password" /}}</div>
<div class="value">
<div class="input-boxed">
<input type="password" class="input-server-password">
<!--
<div class="container-tooltip">
<img src="img/icon_tooltip.svg"/>
<div class="tooltip">
<a>{{tr "The server port of the bookmark" /}}</a>
</div>
</div>
-->
</div>
</div>
</div>
<div class="row">
<div class="key">{{tr "Default Channel" /}}</div>
<div class="value">
<div class="input-boxed">
<input class="input-default-channel" placeholder="{{tr 'Not yet implemented' /}}" disabled>
<!--
<div class="container-tooltip">
<img src="img/icon_tooltip.svg"/>
<div class="tooltip">
<a>{{tr "The server port of the bookmark" /}}</a>
</div>
</div>
-->
</div>
</div>
</div>
<div class="row">
<div class="key">{{tr "Channel Password" /}}</div>
<div class="value">
<div class="input-boxed">
<input type="password" class="input-default-channel-password" placeholder="{{tr 'Not yet implemented' /}}" disabled>
<!--
<div class="container-tooltip">
<img src="img/icon_tooltip.svg"/>
<div class="tooltip">
<a>{{tr "The server port of the bookmark" /}}</a>
</div>
</div>
-->
</div>
</div>
</div>
</div>
<div class="group info">
<div class="container-image">
<img src="img/serveredit_1.png">
</div>
<div class="container-properties">
<div class="row">
<a class="key">{{tr "Server name" /}}</a>
<div class="value server-name">
error: name
</div>
</div>
<div class="row">
<a class="key">{{tr "Server region" /}}</a>
<div class="value server-region">
error: region
</div>
</div>
<div class="row">
<a class="key">{{tr "Last ping" /}}</a>
<div class="value server-ping">
error: ping
</div>
</div>
<div class="row">
<a class="key">{{tr "Last client count" /}}</a>
<div class="value server-client-count">
error: last client
</div>
</div>
<div class="row">
<a class="key">{{tr "Your connection" /}}</a>
<div class="value server-connection-count">
error: connection count
</div>
</div>
</div>
</div>
</div>
<div class="buttons">
<button class="btn btn-blue button-duplicate">{{tr "Duplicate" /}}</button>
<button class="btn btn-success button-connect-tab">{{tr "Connect in a new tab" /}}</button>
<button class="btn btn-success button-connect">{{tr "Connect" /}}</button>
</div>
</div>
</div>
</script>
<script class="jsrender-template" id="tmpl_icon_select" type="text/html">
<div class="container-icons">
<div class="left">

View file

@ -0,0 +1,4 @@
<svg id="client-change_nickname" x="384" y="32" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="m3.57 9.56-2.445 5.315 5.316-2.445c-.358-.566-.781-1.103-1.274-1.596s-1.03-.916-1.596-1.274z" fill="#7289da" style="fill:#fffffe"/>
<path d="m14.875 3.687c0-.297-.104-.547-.313-.749l-1.498-1.501c-.203-.207-.452-.311-.751-.311s-.552.101-.759.302l-7.175 7.174c.447.293.877.624 1.284.992.129.117.256.237.381.362s.245.252.363.382c.369.407.699.836.992 1.284l7.175-7.175c.2-.207.301-.461.301-.76z" fill="#7289da" style="fill:#fffffe"/>
</svg>

After

Width:  |  Height:  |  Size: 566 B

View file

@ -1,261 +1,524 @@
import {LogCategory, logError} from "./log";
import {guid} from "./crypto/uid";
import {createErrorModal, createInfoModal, createInputModal} from "./ui/elements/Modal";
import {defaultConnectProfile, findConnectProfile} from "./profiles/ConnectionProfile";
import {ConnectionHandler} from "./ConnectionHandler";
import * as loader from "tc-loader";
import {Stage} from "tc-loader";
import {WritableKeys} from "tc-shared/proto";
import {LogCategory, logDebug, logError, logInfo, logTrace, logWarn} from "tc-shared/log";
import {guid} from "tc-shared/crypto/uid";
import {Registry} from "tc-events";
import {server_connections} from "tc-shared/ConnectionManager";
import {Registry} from "tc-shared/events";
import {spawnConnectModalNew} from "tc-shared/ui/modal/connect/Controller";
import {defaultConnectProfile, findConnectProfile} from "tc-shared/profiles/ConnectionProfile";
import {ConnectionState} from "tc-shared/ConnectionHandler";
import * as _ from "lodash";
/* TODO: much better events? */
export interface BookmarkEvents {
notify_bookmarks_updated: {}
}
type BookmarkBase = {
readonly uniqueId: string,
displayName: string,
export const bookmarkEvents = new Registry<BookmarkEvents>();
export const boorkmak_connect = (mark: Bookmark, new_tab?: boolean) => {
const profile = findConnectProfile(mark.connect_profile) || defaultConnectProfile();
if(profile.valid()) {
const connection = (typeof(new_tab) !== "boolean" || !new_tab) ? server_connections.getActiveConnectionHandler() : server_connections.spawnConnectionHandler();
server_connections.setActiveConnectionHandler(connection);
connection.startConnection(
mark.server_properties.server_address + ":" + mark.server_properties.server_port,
profile,
true,
{
nickname: mark.nickname === "Another TeaSpeak user" || !mark.nickname ? profile.connectUsername() : mark.nickname,
password: mark.server_properties.server_password_hash ? {
password: mark.server_properties.server_password_hash,
hashed: true
} : mark.server_properties.server_password ? {
hashed: false,
password: mark.server_properties.server_password
} : undefined
}
);
} else {
spawnConnectModalNew({
selectedAddress: mark.server_properties.server_address + ":" + mark.server_properties.server_port,
selectedProfile: profile
});
}
previousEntry: string | undefined,
parentEntry: string | undefined,
};
export interface ServerProperties {
server_address: string;
server_port: number;
server_password_hash?: string;
server_password?: string;
export type BookmarkInfo = BookmarkBase & {
readonly type: "entry",
connectOnStartup: boolean,
connectProfile: string,
serverAddress: string,
serverPasswordHash: string | undefined,
defaultChannel: string | undefined,
defaultChannelPasswordHash: string | undefined,
}
export enum BookmarkType {
ENTRY,
DIRECTORY
export type BookmarkDirectory = BookmarkBase & {
readonly type: "directory",
}
export interface Bookmark {
type: BookmarkType.ENTRY;
/* readonly */ parent: DirectoryBookmark;
export type BookmarkEntry = BookmarkInfo | BookmarkDirectory;
server_properties: ServerProperties;
display_name: string;
unique_id: string;
nickname: string;
default_channel?: number | string;
default_channel_password_hash?: string;
default_channel_password?: string;
connect_profile: string;
last_icon_id?: number;
last_icon_server_id?: string;
export interface BookmarkEvents {
notify_bookmark_created: { bookmark: BookmarkEntry },
notify_bookmark_edited: { bookmark: BookmarkEntry, keys: (keyof BookmarkInfo | keyof BookmarkDirectory)[] },
notify_bookmark_deleted: { bookmark: BookmarkEntry, children: BookmarkEntry[] },
notify_bookmarks_imported: { bookmarks: BookmarkEntry[] },
}
export interface DirectoryBookmark {
type: BookmarkType.DIRECTORY;
/* readonly */ parent: DirectoryBookmark;
export type OrderedBookmarkEntry = {
entry: BookmarkEntry,
depth: number,
childCount: number,
};
readonly content: (Bookmark | DirectoryBookmark)[];
unique_id: string;
display_name: string;
}
const kLocalStorageKey = "bookmarks_v2";
export class BookmarkManager {
readonly events: Registry<BookmarkEvents>;
private readonly registeredBookmarks: BookmarkEntry[];
private defaultBookmarkCreated: boolean;
interface BookmarkConfig {
root_bookmark?: DirectoryBookmark;
default_added?: boolean;
}
let _bookmark_config: BookmarkConfig;
function bookmark_config() : BookmarkConfig {
if(_bookmark_config)
return _bookmark_config;
let bookmark_json = localStorage.getItem("bookmarks");
let bookmarks;
try {
bookmarks = JSON.parse(bookmark_json) || {} as BookmarkConfig;
} catch(error) {
logError(LogCategory.BOOKMARKS, tr("Failed to load bookmarks: %o"), error);
bookmarks = {} as any;
constructor() {
this.events = new Registry<BookmarkEvents>();
this.registeredBookmarks = [];
this.defaultBookmarkCreated = false;
this.loadBookmarks();
}
_bookmark_config = bookmarks;
_bookmark_config.root_bookmark = _bookmark_config.root_bookmark || { content: [], display_name: "root", type: BookmarkType.DIRECTORY} as DirectoryBookmark;
if(!_bookmark_config.default_added) {
_bookmark_config.default_added = true;
create_bookmark("TeaSpeak official Test-Server", _bookmark_config.root_bookmark, {
server_address: "ts.teaspeak.de",
server_port: 9987
}, undefined);
save_config();
}
const fix_parent = (parent: DirectoryBookmark, entry: Bookmark | DirectoryBookmark) => {
entry.parent = parent;
if(entry.type === BookmarkType.DIRECTORY)
for(const child of (entry as DirectoryBookmark).content)
fix_parent(entry as DirectoryBookmark, child);
};
for(const entry of _bookmark_config.root_bookmark.content)
fix_parent(_bookmark_config.root_bookmark, entry);
return _bookmark_config;
}
function save_config() {
localStorage.setItem("bookmarks", JSON.stringify(bookmark_config(), (key, value) => {
if(key === "parent")
return undefined;
return value;
}));
}
export function bookmarks() : DirectoryBookmark {
return bookmark_config().root_bookmark;
}
export function bookmarks_flat() : Bookmark[] {
const result: Bookmark[] = [];
const _flat = (bookmark: Bookmark | DirectoryBookmark) => {
if(bookmark.type == BookmarkType.DIRECTORY)
for(const book of (bookmark as DirectoryBookmark).content)
_flat(book);
else
result.push(bookmark as Bookmark);
};
_flat(bookmark_config().root_bookmark);
return result;
}
function find_bookmark_recursive(parent: DirectoryBookmark, uuid: string) : Bookmark | DirectoryBookmark {
for(const entry of parent.content) {
if(entry.unique_id == uuid)
return entry;
if(entry.type == BookmarkType.DIRECTORY) {
const result = find_bookmark_recursive(entry as DirectoryBookmark, uuid);
if(result) return result;
}
}
return undefined;
}
export function find_bookmark(uuid: string) : Bookmark | DirectoryBookmark | undefined {
return find_bookmark_recursive(bookmarks(), uuid);
}
export function parent_bookmark(bookmark: Bookmark) : DirectoryBookmark {
const books: (DirectoryBookmark | Bookmark)[] = [bookmarks()];
while(!books.length) {
const directory = books.pop_front();
if(directory.type == BookmarkType.DIRECTORY) {
const cast = <DirectoryBookmark>directory;
if(cast.content.indexOf(bookmark) != -1)
return cast;
books.push(...cast.content);
}
}
return bookmarks();
}
export function create_bookmark(display_name: string, directory: DirectoryBookmark, server_properties: ServerProperties, nickname: string) : Bookmark {
const bookmark = {
display_name: display_name,
server_properties: server_properties,
nickname: nickname,
type: BookmarkType.ENTRY,
connect_profile: "default",
unique_id: guid(),
parent: directory
} as Bookmark;
directory.content.push(bookmark);
return bookmark;
}
export function create_bookmark_directory(parent: DirectoryBookmark, name: string) : DirectoryBookmark {
const bookmark = {
type: BookmarkType.DIRECTORY,
display_name: name,
content: [],
unique_id: guid(),
parent: parent
} as DirectoryBookmark;
parent.content.push(bookmark);
return bookmark;
}
//TODO test if the new parent is within the old bookmark
export function change_directory(parent: DirectoryBookmark, bookmark: Bookmark | DirectoryBookmark) {
delete_bookmark(bookmark);
parent.content.push(bookmark)
}
export function save_bookmark(bookmark?: Bookmark | DirectoryBookmark) {
bookmarkEvents.fire("notify_bookmarks_updated");
save_config(); /* nvm we dont give a fuck... saving everything */
}
function delete_bookmark_recursive(parent: DirectoryBookmark, bookmark: Bookmark | DirectoryBookmark) {
const index = parent.content.indexOf(bookmark);
if(index != -1)
parent.content.remove(bookmark);
else
for(const entry of parent.content)
if(entry.type == BookmarkType.DIRECTORY)
delete_bookmark_recursive(entry as DirectoryBookmark, bookmark)
}
export function delete_bookmark(bookmark: Bookmark | DirectoryBookmark) {
delete_bookmark_recursive(bookmarks(), bookmark)
}
export function add_server_to_bookmarks(server: ConnectionHandler) {
if(server && server.connected) {
const ce = server.getClient();
const name = ce ? ce.clientNickName() : undefined;
createInputModal(tr("Enter bookmarks name"), tr("Please enter the bookmarks name:<br>"), text => text.length > 0, result => {
if(result) {
const bookmark = create_bookmark(result as string, bookmarks(), {
server_port: server.serverConnection.remote_address().port,
server_address: server.serverConnection.remote_address().host,
server_password: "",
server_password_hash: ""
}, name);
save_bookmark(bookmark);
createInfoModal(tr("Server added"), tr("Server has been successfully added to your bookmarks.")).open();
private loadBookmarks() {
const bookmarksJson = localStorage.getItem(kLocalStorageKey);
if(typeof bookmarksJson !== "string") {
const oldBookmarksJson = localStorage.getItem("bookmarks");
if(typeof oldBookmarksJson === "string") {
logDebug(LogCategory.BOOKMARKS, tr("Found no new bookmarks but found old bookmarks. Trying to import."));
try {
this.importOldBookmarks(oldBookmarksJson);
logInfo(LogCategory.BOOKMARKS, tr("Successfully imported %d old bookmarks."), this.registeredBookmarks.length);
this.saveBookmarks();
} catch (error) {
const saveKey = "bookmarks_v1_save_" + Date.now();
logError(LogCategory.BOOKMARKS, tr("Failed to import old bookmark data. Saving it as %s"), saveKey);
localStorage.setItem(saveKey, oldBookmarksJson);
} finally {
localStorage.removeItem("bookmarks");
}
}
}).open();
} else {
createErrorModal(tr("You have to be connected"), tr("You have to be connected!")).open();
} else {
try {
const storageData = JSON.parse(bookmarksJson);
if(storageData.version !== 2) {
throw tr("bookmark storage has an invalid version");
}
this.defaultBookmarkCreated = storageData.defaultBookmarkCreated;
this.registeredBookmarks.slice(0, this.registeredBookmarks.length);
this.registeredBookmarks.push(...storageData.bookmarks);
logTrace(LogCategory.BOOKMARKS, tr("Loaded %d bookmarks."), this.registeredBookmarks.length);
} catch (error) {
const saveKey = "bookmarks_v2_save_" + Date.now();
logError(LogCategory.BOOKMARKS, tr("Failed to parse bookmarks. Saving them at %s and using a clean setup."), saveKey)
localStorage.setItem(saveKey, bookmarksJson);
localStorage.removeItem("bookmarks_v2");
}
}
if(!this.defaultBookmarkCreated && this.registeredBookmarks.length === 0) {
this.defaultBookmarkCreated = true;
logDebug(LogCategory.BOOKMARKS, tr("No bookmarks found. Registering default bookmark."));
this.createBookmark({
connectOnStartup: false,
connectProfile: "default",
displayName: "Official TeaSpeak - Test server",
parentEntry: undefined,
previousEntry: undefined,
serverAddress: "ts.teaspeak.de",
serverPasswordHash: undefined,
defaultChannel: undefined,
defaultChannelPasswordHash: undefined,
});
this.saveBookmarks();
}
}
}
private importOldBookmarks(jsonData: string) {
const data = JSON.parse(jsonData);
if(typeof data?.root_bookmark !== "object") {
throw tr("missing root bookmark");
}
if(!Array.isArray(data?.root_bookmark?.content)) {
throw tr("Missing root bookmarks content");
}
const registerBookmarks = (parentEntry: string, previousEntry: string, entry: any) : string | undefined => {
if(typeof entry.display_name !== "string") {
logWarn(LogCategory.BOOKMARKS, tr("Missing display_name in old bookmark entry. Skipping entry."));
return undefined;
}
if("content" in entry) {
/* it was a directory */
const directory = this.createDirectory({
previousEntry,
parentEntry,
displayName: entry.display_name,
});
previousEntry = undefined;
entry.content.forEach(entry => {
previousEntry = registerBookmarks(directory.uniqueId, previousEntry, entry) || previousEntry;
});
} else {
/* it was a normal entry */
if(typeof entry.connect_profile !== "string") {
logWarn(LogCategory.BOOKMARKS, tr("Missing connect_profile in old bookmark entry. Skipping entry."));
return undefined;
}
if(typeof entry.server_properties?.server_address !== "string") {
logWarn(LogCategory.BOOKMARKS, tr("Missing server_address in old bookmark entry. Skipping entry."));
return undefined;
}
if(typeof entry.server_properties?.server_port !== "number") {
logWarn(LogCategory.BOOKMARKS, tr("Missing server_port in old bookmark entry. Skipping entry."));
return undefined;
}
let serverAddress;
if(entry.server_properties.server_address.indexOf(":") !== -1) {
serverAddress = `[${entry.server_properties.server_address}]`;
} else {
serverAddress = entry.server_properties.server_address;
}
serverAddress += ":" + entry.server_properties.server_port;
return this.createBookmark({
previousEntry,
parentEntry,
serverAddress,
serverPasswordHash: entry.server_properties?.server_password_hash,
defaultChannel: undefined,
defaultChannelPasswordHash: undefined,
displayName: entry.display_name,
connectProfile: entry.connect_profile,
connectOnStartup: false
}).uniqueId;
}
}
let previousEntry = undefined;
data.root_bookmark.content.forEach(entry => {
previousEntry = registerBookmarks(undefined, previousEntry, entry) || previousEntry;
});
this.defaultBookmarkCreated = true;
}
private saveBookmarks() {
localStorage.setItem(kLocalStorageKey, JSON.stringify({
version: 2,
bookmarks: this.registeredBookmarks,
defaultBookmarkCreated: this.defaultBookmarkCreated,
}))
}
getRegisteredBookmarks() : BookmarkEntry[] {
return this.registeredBookmarks;
}
getOrderedRegisteredBookmarks() : OrderedBookmarkEntry[] {
const unorderedBookmarks = this.registeredBookmarks.slice(0);
const orderedBookmarks: OrderedBookmarkEntry[] = [];
const orderTreeLayer = (entries: BookmarkEntry[]): BookmarkEntry[] => {
if(entries.length === 0) {
return [];
}
const result = [];
while(entries.length > 0) {
let head = entries.find(entry => !entry.previousEntry) || entries[0];
while(head) {
result.push(head);
entries.remove(head);
head = entries.find(entry => entry.previousEntry === head.uniqueId);
}
}
return result;
}
const traverseTree = (parentEntry: string | undefined, depth: number): number => {
const children = unorderedBookmarks.filter(e => e.parentEntry === parentEntry);
children.forEach(child => unorderedBookmarks.remove(child));
const childCount = children.length;
for(const entry of orderTreeLayer(children)) {
let orderedEntry: OrderedBookmarkEntry = {
entry: entry,
depth: depth,
childCount: 0
};
orderedBookmarks.push(orderedEntry);
orderedEntry.childCount = traverseTree(entry.uniqueId, depth + 1);
}
return childCount;
};
traverseTree(undefined, 0);
/* Append all broken/unreachable elements */
while (unorderedBookmarks.length > 0) {
traverseTree(unorderedBookmarks[0].parentEntry, 0);
}
return orderedBookmarks;
}
findBookmark(uniqueId: string) : BookmarkEntry | undefined {
return this.registeredBookmarks.find(entry => entry.uniqueId === uniqueId);
}
createBookmark(properties: Pick<BookmarkInfo, WritableKeys<BookmarkInfo>>) : BookmarkInfo {
this.validateHangInPoint(properties);
const bookmark = Object.assign(properties, {
uniqueId: guid(),
type: "entry"
} as BookmarkInfo);
this.registeredBookmarks.push(bookmark);
this.events.fire("notify_bookmark_created", { bookmark });
this.saveBookmarks();
return bookmark;
}
editBookmark(uniqueId: string, newValues: Partial<Pick<BookmarkInfo, WritableKeys<BookmarkInfo>>>) {
this.doEditBookmark(uniqueId, newValues);
}
createDirectory(properties: Pick<BookmarkInfo, WritableKeys<BookmarkDirectory>>) : BookmarkDirectory {
this.validateHangInPoint(properties);
const bookmark = Object.assign(properties, {
uniqueId: guid(),
type: "directory"
} as BookmarkDirectory);
this.registeredBookmarks.push(bookmark);
this.events.fire("notify_bookmark_created", { bookmark });
this.saveBookmarks();
return bookmark;
}
editDirectory(uniqueId: string, newValues: Partial<Pick<BookmarkDirectory, WritableKeys<BookmarkDirectory>>>) {
this.doEditBookmark(uniqueId, newValues);
}
deleteEntry(uniqueId: string) {
const index = this.registeredBookmarks.findIndex(entry => entry.uniqueId === uniqueId);
if(index === -1) {
return;
}
const [ entry ] = this.registeredBookmarks.splice(index, 1);
const children = [], pendingChildren = [ entry ];
while(pendingChildren[0]) {
const child = pendingChildren.pop_front();
children.push(child);
const childChildren = this.registeredBookmarks.filter(entry => entry.parentEntry === child.uniqueId);
pendingChildren.push(...childChildren);
childChildren.forEach(entry => this.registeredBookmarks.remove(entry));
}
children.pop_front();
this.events.fire("notify_bookmark_deleted", { bookmark: entry, children });
this.saveBookmarks();
}
executeConnect(uniqueId: string, newTab: boolean) {
const bookmark = this.findBookmark(uniqueId);
if(!bookmark || bookmark.type !== "entry") {
return;
}
const connection = newTab ? server_connections.spawnConnectionHandler() : server_connections.getActiveConnectionHandler();
if(!connection) {
return;
}
let profile = findConnectProfile(bookmark.connectProfile) || defaultConnectProfile();
connection.startConnectionNew({
profile: profile,
targetAddress: bookmark.serverAddress,
serverPasswordHashed: true,
serverPassword: bookmark.serverPasswordHash,
defaultChannel: bookmark.defaultChannel,
defaultChannelPassword: bookmark.defaultChannelPasswordHash,
defaultChannelPasswordHashed: true,
token: undefined,
nicknameSpecified: false,
nickname: undefined
}, false).then(undefined);
}
executeAutoConnect() {
let newTab = server_connections.getActiveConnectionHandler().connection_state !== ConnectionState.UNCONNECTED;
for(const entry of this.getOrderedRegisteredBookmarks()) {
if(entry.entry.type !== "entry") {
continue;
}
if(!entry.entry.connectOnStartup) {
continue;
}
this.executeConnect(entry.entry.uniqueId, newTab);
newTab = true;
}
}
exportBookmarks() : string {
return JSON.stringify({
version: 1,
bookmarks: this.registeredBookmarks
});
}
importBookmarks(filePayload: string) : number {
let data;
try {
data = JSON.parse(filePayload)
} catch (error) {
throw tr("failed to parse bookmarks");
}
if(data?.version !== 1) {
throw tr("supplied data contains invalid version");
}
const newBookmarks = data.bookmarks as BookmarkEntry[];
if(!Array.isArray(newBookmarks)) {
throw tr("missing bookmarks");
}
/* TODO: Validate integrity? */
for(const knownBookmark of this.registeredBookmarks) {
const index = newBookmarks.findIndex(entry => entry.uniqueId === knownBookmark.uniqueId);
if(index === -1) {
continue;
}
newBookmarks.splice(index, 1);
}
if(newBookmarks.length === 0) {
return;
}
this.registeredBookmarks.push(...newBookmarks);
newBookmarks.forEach(entry => this.validateHangInPoint(entry));
this.events.fire("notify_bookmarks_imported", { bookmarks: newBookmarks });
return newBookmarks.length;
}
private doEditBookmark(uniqueId: string, newValues: any) {
const bookmarkInfo = this.findBookmark(uniqueId);
if(!bookmarkInfo) {
return;
}
const originalProperties = _.cloneDeep(bookmarkInfo);
for(const key of Object.keys(newValues)) {
bookmarkInfo[key] = newValues[key];
}
this.validateHangInPoint(bookmarkInfo);
const editedKeys = [];
for(const key of Object.keys(newValues)) {
if(_.isEqual(bookmarkInfo[key], originalProperties[key])) {
continue;
}
editedKeys.push(key);
}
if(editedKeys.length === 0) {
return;
}
this.saveBookmarks();
this.events.fire("notify_bookmark_edited", { bookmark: bookmarkInfo, keys: editedKeys });
}
private validateHangInPoint(entry: Partial<BookmarkBase>) {
if(entry.previousEntry) {
const previousEntry = this.findBookmark(entry.previousEntry);
if(!previousEntry) {
logError(LogCategory.BOOKMARKS, tr("New bookmark previous entry does not exists. Clearing it."));
entry.previousEntry = undefined;
} else if(previousEntry.parentEntry !== entry.parentEntry) {
logWarn(LogCategory.BOOKMARKS, tr("Previous entries parent does not match our entries parent. Updating our parent from %s to %s"), entry.parentEntry, previousEntry.parentEntry);
entry.parentEntry = previousEntry.parentEntry;
}
const openList = this.registeredBookmarks.filter(e1 => e1.parentEntry === entry.parentEntry);
let currentEntry = entry;
while(true) {
if(!currentEntry.previousEntry) {
break;
}
const previousEntry = openList.find(entry => entry.uniqueId === currentEntry.previousEntry);
if(!previousEntry) {
logError(LogCategory.BOOKMARKS, tr("Found circular dependency within the previous entry or one of the previous entries does not exists. Clearing out previous entry."));
entry.previousEntry = undefined;
break;
}
openList.remove(previousEntry);
currentEntry = previousEntry;
}
}
if(entry.parentEntry) {
const parentEntry = this.findBookmark(entry.parentEntry);
if(!parentEntry) {
logError(LogCategory.BOOKMARKS, tr("Missing parent entry %s. Clearing it."), entry.parentEntry);
entry.parentEntry = undefined;
}
const openList = this.registeredBookmarks.slice();
let currentEntry = entry;
while(true) {
if(!currentEntry.parentEntry) {
break;
}
const parentEntry = openList.find(entry => entry.uniqueId === currentEntry.parentEntry);
if(!parentEntry) {
logError(LogCategory.BOOKMARKS, tr("Found circular dependency within a parent or one of the parents does not exists. Clearing out parent."));
entry.parentEntry = undefined;
break;
}
openList.remove(parentEntry);
currentEntry = parentEntry;
}
}
if(entry.previousEntry) {
this.registeredBookmarks.forEach(bookmark => {
if(bookmark.previousEntry === entry.previousEntry && bookmark !== entry) {
bookmark.previousEntry = bookmark.uniqueId;
}
});
}
}
}
export let bookmarks: BookmarkManager;
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
name: "initialize bookmarks",
function: async () => {
bookmarks = new BookmarkManager();
(window as any).bookmarks = bookmarks;
},
priority: 20
});

View file

@ -2,9 +2,7 @@ import {CommandResult} from "../connection/ServerConnectionDeclaration";
import {IdentitifyType} from "../profiles/Identity";
import {AbstractServerConnection} from "../connection/ConnectionBase";
import {DisconnectReason} from "../ConnectionHandler";
import {tr} from "../i18n/localize";
import {ConnectParameters} from "tc-shared/ui/modal/connect/Controller";
import {LogCategory, logWarn} from "tc-shared/log";
import {getBackend} from "tc-shared/backend";
export interface HandshakeIdentityHandler {

View file

@ -38,6 +38,9 @@ export type ConnectionHistoryServerInfo = {
clientsOnline: number | -1,
clientsMax: number | -1,
hostBannerUrl: string | undefined,
hostBannerMode: number,
passwordProtected: boolean
}
@ -310,6 +313,9 @@ export class ConnectionHistory {
databaseValue.clientsOnline = info.clientsOnline;
databaseValue.clientsMax = info.clientsMax;
databaseValue.hostBannerUrl = info.hostBannerUrl
databaseValue.hostBannerMode = info.hostBannerMode;
});
}
@ -378,6 +384,9 @@ export class ConnectionHistory {
clientsOnline: value.clientsOnline,
clientsMax: value.clientsMax,
hostBannerUrl: value.hostBannerUrl,
hostBannerMode: typeof value.hostBannerMode === "number" ? value.hostBannerMode : 0,
passwordProtected: value.passwordProtected
};
}
@ -500,7 +509,9 @@ const kConnectServerInfoUpdatePropertyKeys: (keyof ServerProperties)[] = [
"virtualserver_maxclients",
"virtualserver_clientsonline",
"virtualserver_flag_password",
"virtualserver_country_code"
"virtualserver_country_code",
"virtualserver_hostbanner_gfx_url",
"virtualserver_hostbanner_mode"
];
class ConnectionHistoryUpdateListener {
@ -552,6 +563,9 @@ class ConnectionHistoryUpdateListener {
clientsMax: event.server_properties.virtualserver_maxclients,
clientsOnline: event.server_properties.virtualserver_clientsonline,
hostBannerUrl: event.server_properties.virtualserver_hostbanner_gfx_url,
hostBannerMode: event.server_properties.virtualserver_hostbanner_mode,
passwordProtected: event.server_properties.virtualserver_flag_password
}).catch(error => {
logError(LogCategory.GENERAL, tr("Failed to update connect server info: %o"), error);

View file

@ -19,6 +19,7 @@ import {spawnVideoSourceSelectModal} from "tc-shared/ui/modal/video-source/Contr
import {LogCategory, logError, logWarn} from "tc-shared/log";
import {spawnEchoTestModal} from "tc-shared/ui/modal/echo-test/Controller";
import {spawnConnectModalNew} from "tc-shared/ui/modal/connect/Controller";
import {spawnBookmarkModal} from "tc-shared/ui/modal/bookmarks/Controller";
/*
function initialize_sounds(event_registry: Registry<ClientGlobalControlEvents>) {
@ -62,12 +63,7 @@ export function initialize(event_registry: Registry<ClientGlobalControlEvents>)
const connection_handler = event.connection || current_connection_handler;
switch (event.window) {
case "bookmark-manage":
import("../ui/modal/ModalBookmarks").catch(error => {
handle_import_error(error);
return undefined;
}).then(window => {
window?.spawnBookmarkModal();
});
spawnBookmarkModal();
break;
case "query-manage":
if(!connection_handler || !connection_handler.connected) {

View file

@ -121,6 +121,10 @@ export abstract class AbstractIconManager {
return "v2-" + serverUniqueId + "-" + iconId;
}
resolveIconInfo(icon: RemoteIconInfo) : RemoteIcon {
return this.resolveIcon(icon.iconId, icon.serverUniqueId, icon.handlerId);
}
/**
* @param iconId The requested icon
* @param serverUniqueId The server unique id for the icon

37
shared/js/file/Utils.ts Normal file
View file

@ -0,0 +1,37 @@
export const downloadTextAsFile = (text: string, name: string) => {
const payloadBlob = new Blob([ text ], { type: "text/plain" });
const payloadUrl = URL.createObjectURL(payloadBlob);
const element = document.createElement("a");
element.text = "download";
element.setAttribute("href", payloadUrl);
element.setAttribute("download", name);
element.style.display = "none";
document.body.appendChild(element);
element.click();
setTimeout(() => {
element.remove();
URL.revokeObjectURL(payloadUrl);
}, 0);
};
export const requestFileAsText = async (): Promise<string> => {
const element = document.createElement("input");
element.style.display = "none";
element.type = "file";
document.body.appendChild(element);
element.click();
await new Promise(resolve => {
element.onchange = resolve;
});
if (element.files.length !== 1)
return undefined;
const file = element.files[0];
element.remove();
return await file.text();
};

4
shared/js/global.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.png" {
const value: any;
export = value;
}

View file

@ -54,6 +54,9 @@ import {ActionResult} from "tc-services";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import {ErrorCode} from "tc-shared/connection/ErrorCode";
import "./Bookmarks";
import {bookmarks} from "tc-shared/Bookmarks";
assertMainApplication();
let preventWelcomeUI = false;
@ -487,6 +490,8 @@ const task_connect_handler: loader.Task = {
}
preventWelcomeUI = true;
preventExecuteAutoConnect = true;
loader.register_task(loader.Stage.LOADED, {
priority: 0,
function: async () => {
@ -494,6 +499,7 @@ const task_connect_handler: loader.Task = {
},
name: tr("default url connect")
});
loader.register_task(loader.Stage.LOADED, task_teaweb_starter);
},
priority: 10
@ -551,6 +557,19 @@ loader.register_task(loader.Stage.LOADED, {
priority: 2000
});
let preventExecuteAutoConnect = false;
loader.register_task(loader.Stage.LOADED, {
priority: 0,
function: async () => {
if(preventExecuteAutoConnect) {
return;
}
bookmarks.executeAutoConnect();
},
name: tr("bookmark auto connect")
});
/* TODO: Remove this after the image preview has been rewritten into react */
loader.register_task(Stage.JAVASCRIPT_INITIALIZING,{
name: "app init",
@ -564,4 +583,4 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING,{
}
},
priority: 100
});
});

View file

@ -85,6 +85,18 @@ declare global {
}
}
export type IfEquals<X, Y, A=X, B=never> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? A : B;
export type WritableKeys<T> = {
[P in keyof T]-?: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P, never>
}[keyof T];
export type ReadonlyKeys<T> = {
[P in keyof T]: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, never, P>
}[keyof T];
if(!Object.isSimilar) {
Object.isSimilar = function (a, b) {
const aType = typeof a;
@ -292,6 +304,4 @@ if(typeof ($) !== "undefined") {
if(!Object.values) {
Object.values = object => Object.keys(object).map(e => object[e]);
}
export = {};
}

View file

@ -6,10 +6,10 @@ import * as React from "react";
import ReactRenderer from "vendor/xbbcode/renderer/react";
import {Settings, settings} from "tc-shared/settings";
import * as emojiRegex from "emoji-regex";
import emojiRegex from "emoji-regex";
import {getTwenmojiHashFromNativeEmoji} from "tc-shared/text/bbcode/EmojiUtil";
const emojiRegexInstance = (emojiRegex as any)() as RegExp;
const emojiRegexInstance = emojiRegex();
loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
name: "XBBCode emoji init",

View file

@ -15,7 +15,7 @@ import {Settings, settings} from "tc-shared/settings";
import {LogCategory, logWarn} from "tc-shared/log";
const registerLanguage = (name, language: Promise<any>) => {
language.then(lan => hljs.registerLanguage(name, lan)).catch(error => {
language.then(lan => hljs.registerLanguage(name, lan.default)).catch(error => {
logWarn(LogCategory.CHAT, tr("Failed to load language %s (%o)"), name, error);
});
};

View file

@ -4,7 +4,6 @@ import * as contextmenu from "../ui/elements/ContextMenu";
import * as log from "../log";
import {LogCategory, logInfo, LogType} from "../log";
import {Sound} from "../sound/Sounds";
import * as bookmarks from "../bookmarks";
import {openServerInfo} from "../ui/modal/ModalServerInfo";
import {createServerModal} from "../ui/modal/ModalServerEdit";
import {spawnIconSelect} from "../ui/modal/ModalIconSelect";
@ -293,7 +292,6 @@ export class ServerEntry extends ChannelTreeEntry<ServerEvents> {
}
let updatedProperties: Partial<ServerProperties> = {};
let update_bookmarks = false;
for(let variable of variables) {
if(!JSON.map_field_to(this.properties, variable.value, variable.key)) {
/* The value has not been updated */
@ -303,7 +301,6 @@ export class ServerEntry extends ChannelTreeEntry<ServerEvents> {
updatedProperties[variable.key] = variable.value;
if(variable.key == "virtualserver_icon_id") {
this.properties.virtualserver_icon_id = variable.value as any >>> 0;
update_bookmarks = true;
}
}
@ -312,19 +309,6 @@ export class ServerEntry extends ChannelTreeEntry<ServerEvents> {
server_properties: this.properties
});
if(update_bookmarks) {
const bmarks = bookmarks.bookmarks_flat()
.filter(e => e.server_properties.server_address === this.remote_address.host && e.server_properties.server_port == this.remote_address.port)
.filter(e => e.last_icon_id !== this.properties.virtualserver_icon_id || e.last_icon_server_id !== this.properties.virtualserver_unique_identifier);
if(bmarks.length > 0) {
bmarks.forEach(e => {
e.last_icon_id = this.properties.virtualserver_icon_id;
e.last_icon_server_id = this.properties.virtualserver_unique_identifier;
});
bookmarks.save_bookmark();
}
}
group.end();
if(is_self_notify && this.info_request_promise_resolve) {
this.info_request_promise_resolve();

View file

@ -1,7 +1,7 @@
import * as loader from "tc-loader";
import {Stage} from "tc-loader";
import {KeyCode} from "../../PPTListener";
import * as $ from "jquery";
import $ from "jquery";
import {LogCategory, logError} from "tc-shared/log";
export enum ElementType {
@ -304,6 +304,7 @@ export function createModal(data: ModalProperties | any) : Modal {
export class InputModalProperties extends ModalProperties {
maxLength?: number;
defaultValue?: string;
field_title?: string;
field_label?: string;
@ -367,6 +368,11 @@ export function createInputModal(headMessage: BodyCreator, question: BodyCreator
modal.close();
});
if(props.defaultValue) {
input.val(props.defaultValue);
setTimeout(() => input.trigger("change"), 0);
}
modal.open_listener.push(() => input.focus());
modal.close_listener.push(() => button_cancel.trigger('click'));
return modal;

View file

@ -8,7 +8,7 @@ import {Settings} from "tc-shared/settings";
const cssStyle = require("./HostBannerRenderer.scss");
const HostBannerRenderer = React.memo((props: { banner: HostBannerInfoSet, }) => {
export const HostBannerRenderer = React.memo((props: { banner: HostBannerInfoSet, className?: string }) => {
const [ revision, setRevision ] = useState(Date.now());
useEffect(() => {
if(!props.banner.updateInterval) {
@ -35,7 +35,7 @@ const HostBannerRenderer = React.memo((props: { banner: HostBannerInfoSet, }) =>
<div
className={
cssStyle.containerImage + " " + cssStyle["mode-" + props.banner.mode] + " " + cssStyle["state-" + loadingState] + " " +
(withBackground ? cssStyle.withBackground : "")
(withBackground ? cssStyle.withBackground : "") + " " + props.className
}
onClick={() => {
if(props.banner.linkUrl) {

View file

@ -155,10 +155,10 @@ html:root {
border: .05em solid var(--menu-bar-dropdown-border);
border-radius: 0 $border_radius_middle $border_radius_middle $border_radius_middle;
width: 15em; /* fallback */
width: 20em; /* fallback */
width: max-content;
max-width: 25em;
max-width: 30em;
z-index: 1000;
/*box-shadow: 0 12px 16px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19);*/
@ -270,12 +270,6 @@ html:root {
}
}
.buttonBookmarks {
.dropdown {
width: 300px;
}
}
.arrow {
display: inline-block;
border: solid black;

View file

@ -11,17 +11,6 @@ import {server_connections} from "tc-shared/ConnectionManager";
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
import {Settings, settings} from "tc-shared/settings";
import {global_client_actions} from "tc-shared/events/GlobalEvents";
import {
add_server_to_bookmarks,
Bookmark as ServerBookmark,
bookmarkEvents,
bookmarks,
bookmarks_flat,
BookmarkType,
boorkmak_connect,
DirectoryBookmark
} from "tc-shared/bookmarks";
import {LogCategory, logWarn} from "tc-shared/log";
import {createErrorModal, createInputModal} from "tc-shared/ui/elements/Modal";
import {VideoBroadcastType, VideoConnectionStatus} from "tc-shared/connection/VideoConnection";
import {tr} from "tc-shared/i18n/localize";
@ -29,6 +18,10 @@ import {getVideoDriver} from "tc-shared/video/VideoSource";
import {kLocalBroadcastChannels} from "tc-shared/ui/frames/video/Definitions";
import {getRecorderBackend, IDevice} from "tc-shared/audio/recorder";
import {defaultRecorder, defaultRecorderEvents} from "tc-shared/voice/RecorderProfile";
import {bookmarks} from "tc-shared/Bookmarks";
import {connectionHistory} from "tc-shared/connectionlog/History";
import {RemoteIconInfo} from "tc-shared/file/Icons";
import {spawnModalAddCurrentServerToBookmarks} from "tc-shared/ui/modal/bookmarks-add-server/Controller";
class InfoController {
private readonly mode: ControlBarMode;
@ -66,7 +59,7 @@ class InfoController {
this.sendVideoState("screen");
this.sendVideoState("camera");
}));
events.push(bookmarkEvents.on("notify_bookmarks_updated", () => this.sendBookmarks()));
bookmarks.events.on(["notify_bookmark_edited", "notify_bookmark_created", "notify_bookmark_deleted", "notify_bookmarks_imported"], () => this.sendBookmarks());
events.push(getVideoDriver().getEvents().on("notify_device_list_changed", () => this.sendCameraList()));
events.push(getRecorderBackend().getDeviceList().getEvents().on("notify_list_updated", () => this.sendMicrophoneList()));
events.push(defaultRecorderEvents.on("notify_default_recorder_changed", () => {
@ -205,25 +198,54 @@ class InfoController {
});
}
public sendBookmarks() {
const buildInfo = (bookmark: DirectoryBookmark | ServerBookmark) => {
if(bookmark.type === BookmarkType.DIRECTORY) {
return {
uniqueId: bookmark.unique_id,
label: bookmark.display_name,
children: bookmark.content.map(buildInfo)
} as Bookmark;
} else {
return {
uniqueId: bookmark.unique_id,
label: bookmark.display_name,
icon: bookmark.last_icon_id ? { iconId: bookmark.last_icon_id, serverUniqueId: bookmark.last_icon_server_id } : undefined
} as Bookmark;
public async sendBookmarks() {
const bookmarkList = bookmarks.getOrderedRegisteredBookmarks();
const parent: Bookmark[] = [];
const parentStack: Bookmark[][] = [];
while(bookmarkList.length > 0) {
const bookmark = bookmarkList.pop_front();
const parentList = parentStack.pop() || parent;
if(bookmark.entry.type === "entry") {
let icon: RemoteIconInfo;
try {
const connectInfo = await connectionHistory.lastConnectInfo(bookmark.entry.serverAddress, "address");
if(connectInfo) {
const info = await connectionHistory.queryServerInfo(connectInfo.serverUniqueId);
if(info && info.iconId > 0) {
icon = { iconId: info.iconId, serverUniqueId: connectInfo.serverUniqueId };
}
}
} catch (_) {
/* no need for any error handling */
}
parentList.push({
children: undefined,
icon: icon,
label: bookmark.entry.displayName,
uniqueId: bookmark.entry.uniqueId
});
} else if(bookmark.entry.type === "directory") {
const children = [];
parentList.push({
children: children,
icon: undefined,
label: bookmark.entry.displayName,
uniqueId: bookmark.entry.uniqueId
});
for(let i = 0; i < bookmark.childCount; i++) {
parentStack.push(children);
}
}
};
}
this.events.fire_react("notify_bookmarks", {
marks: bookmarks().content.map(buildInfo)
marks: parent
});
}
@ -380,16 +402,8 @@ export function initializeControlBarController(events: Registry<ControlBarEvents
});
events.on("action_bookmark_manage", () => global_client_actions.fire("action_open_window", { window: "bookmark-manage" }));
events.on("action_bookmark_add_current_server", () => add_server_to_bookmarks(infoHandler.getCurrentHandler()));
events.on("action_bookmark_connect", event => {
const bookmark = bookmarks_flat().find(mark => mark.unique_id === event.bookmarkUniqueId);
if(!bookmark) {
logWarn(LogCategory.BOOKMARKS, tr("Tried to connect to a non existing bookmark with id %s"), event.bookmarkUniqueId);
return;
}
boorkmak_connect(bookmark, event.newTab);
});
events.on("action_bookmark_add_current_server", () => spawnModalAddCurrentServerToBookmarks(infoHandler.getCurrentHandler()));
events.on("action_bookmark_connect", event => bookmarks.executeConnect(event.bookmarkUniqueId, event.newTab));
events.on("action_toggle_away", event => {
if(event.away) {

View file

@ -5,17 +5,12 @@ import {ClientIcon} from "svg-sprites/client-icons";
import {global_client_actions} from "tc-shared/events/GlobalEvents";
import {server_connections} from "tc-shared/ConnectionManager";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {
add_server_to_bookmarks,
Bookmark,
bookmarkEvents,
bookmarks,
BookmarkType,
boorkmak_connect,
DirectoryBookmark
} from "tc-shared/bookmarks";
import {getBackend} from "tc-shared/backend";
import {tr} from "tc-shared/i18n/localize";
import {bookmarks} from "tc-shared/Bookmarks";
import {RemoteIconInfo} from "tc-shared/file/Icons";
import {connectionHistory} from "tc-shared/connectionlog/History";
import {spawnModalAddCurrentServerToBookmarks} from "tc-shared/ui/modal/bookmarks-add-server/Controller";
function renderConnectionItems() {
const items: MenuBarEntry[] = [];
@ -57,27 +52,54 @@ function renderConnectionItems() {
return items;
}
function renderBookmarkItems() {
const items: MenuBarEntry[] = [];
async function renderBookmarkItems() {
const bookmarkList = bookmarks.getOrderedRegisteredBookmarks();
const renderBookmark = (bookmark: Bookmark | DirectoryBookmark): MenuBarEntry => {
if(bookmark.type === BookmarkType.ENTRY) {
return {
const bookmarkItems: MenuBarEntry[] = [];
const parentStack: MenuBarEntry[][] = [];
while(bookmarkList.length > 0) {
const bookmark = bookmarkList.pop_front();
const parentList = parentStack.pop() || bookmarkItems;
if(bookmark.entry.type === "entry") {
let icon: RemoteIconInfo;
try {
const connectInfo = await connectionHistory.lastConnectInfo(bookmark.entry.serverAddress, "address");
if(connectInfo) {
const info = await connectionHistory.queryServerInfo(connectInfo.serverUniqueId);
if(info && info.iconId > 0) {
icon = { iconId: info.iconId, serverUniqueId: connectInfo.serverUniqueId };
}
}
} catch (_) {
/* no need for any error handling */
}
parentList.push({
type: "normal",
label: bookmark.display_name,
click: () => boorkmak_connect(bookmark),
icon: bookmark.last_icon_id ? { serverUniqueId: bookmark.last_icon_server_id, iconId: bookmark.last_icon_id } : undefined
};
} else {
return {
label: bookmark.entry.displayName,
icon: icon,
click: () => bookmarks.executeConnect(bookmark.entry.uniqueId, false),
});
} else if(bookmark.entry.type === "directory") {
const children = [];
parentList.push({
type: "normal",
label: bookmark.display_name,
label: bookmark.entry.displayName,
children: children,
icon: ClientIcon.Folder,
children: bookmark.content.map(renderBookmark)
});
for(let i = 0; i < bookmark.childCount; i++) {
parentStack.push(children);
}
}
}
const items: MenuBarEntry[] = [];
items.push({
type: "normal",
icon: ClientIcon.BookmarkManager,
@ -90,13 +112,12 @@ function renderBookmarkItems() {
icon: ClientIcon.BookmarkAdd,
label: tr("Add current server to bookmarks"),
disabled: !server_connections.getActiveConnectionHandler()?.connected,
click: () => add_server_to_bookmarks(server_connections.getActiveConnectionHandler())
click: () => spawnModalAddCurrentServerToBookmarks(server_connections.getActiveConnectionHandler())
});
const rootMarks = bookmarks().content;
if(rootMarks.length !== 0) {
if(bookmarkItems.length !== 0) {
items.push({ type: "separator" });
items.push(...rootMarks.map(renderBookmark));
items.push(...bookmarkItems);
}
return items;
@ -286,6 +307,11 @@ function renderHelpItems() : MenuBarEntry[] {
}
function updateMenuBar() {
/* TODO: Only run one update per time */
doUpdateMenuBar().then(undefined);
}
async function doUpdateMenuBar() {
const items: MenuBarEntry[] = [];
items.push({
@ -297,7 +323,7 @@ function updateMenuBar() {
items.push({
type: "normal",
label: tr("Favorites"),
children: renderBookmarkItems()
children: await renderBookmarkItems()
});
items.push({
@ -335,12 +361,8 @@ class MenuBarUpdateListener {
this.registeredHandlerEvents[event.handlerId]?.forEach(callback => callback());
delete this.registeredHandlerEvents[event.handlerId];
}));
this.generalHandlerEvents.push(server_connections.events().on("notify_active_handler_changed", () => {
updateMenuBar();
}));
this.generalHandlerEvents.push(bookmarkEvents.on("notify_bookmarks_updated", () => {
updateMenuBar();
}))
this.generalHandlerEvents.push(server_connections.events().on("notify_active_handler_changed", () => updateMenuBar()));
this.generalHandlerEvents.push(bookmarks.events.on(["notify_bookmark_deleted", "notify_bookmark_created", "notify_bookmark_edited", "notify_bookmarks_imported"], () => updateMenuBar()));
server_connections.getAllConnectionHandlers().forEach(handler => this.registerHandlerEvents(handler));
}
@ -361,12 +383,14 @@ class MenuBarUpdateListener {
}
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
name: "menu bar entries init",
name: "menu bar init",
function: async () => {
updateMenuBar();
updateListener = new MenuBarUpdateListener();
updateListener.initializeListeners();
},
priority: 50
/* initialize after all other systems have been initialized */
priority: 0
});

View file

@ -2,7 +2,7 @@ import * as React from "react";
import {useContext, useMemo, useRef, useState} from "react";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {Layout} from "react-grid-layout";
import * as GridLayout from "react-grid-layout";
import GridLayout from "react-grid-layout";
import {ErrorBoundary} from "tc-shared/ui/react-elements/ErrorBoundary";
import * as _ from "lodash";
import {FontSizeObserver} from "tc-shared/ui/react-elements/FontSize";

View file

@ -1,5 +1,5 @@
import * as loader from "tc-loader";
import * as moment from "moment";
import moment from "moment";
import {LogCategory, logError, logTrace} from "../log";
import {tr} from "tc-shared/i18n/localize";

View file

@ -4,7 +4,7 @@ import {ConnectionHandler} from "../../ConnectionHandler";
import {base64_encode_ab} from "../../utils/buffers";
import {spawnYesNo} from "../../ui/modal/ModalYesNo";
import {ClientEntry} from "../../tree/Client";
import * as moment from "moment";
import moment from "moment";
import {tr} from "tc-shared/i18n/localize";
const avatar_to_uid = (id: string) => {

View file

@ -7,7 +7,7 @@ import {LogCategory, logError, logInfo} from "../../log";
import * as tooltip from "../../ui/elements/Tooltip";
import * as htmltags from "../../ui/htmltags";
import {format_time, formatMessage} from "../../ui/frames/chat";
import * as moment from "moment";
import moment from "moment";
import {ErrorCode} from "../../connection/ErrorCode";
import {tr} from "tc-shared/i18n/localize";

View file

@ -1,421 +0,0 @@
import {createInputModal, createModal, Modal} from "../../ui/elements/Modal";
import {
Bookmark,
bookmarks,
BookmarkType,
boorkmak_connect,
create_bookmark,
create_bookmark_directory,
delete_bookmark,
DirectoryBookmark,
save_bookmark
} from "../../bookmarks";
import {Regex} from "../../ui/modal/ModalConnect";
import {availableConnectProfiles} from "../../profiles/ConnectionProfile";
import {spawnYesNo} from "../../ui/modal/ModalYesNo";
import {Settings, settings} from "../../settings";
import {LogCategory, logWarn} from "../../log";
import * as i18nc from "../../i18n/country";
import {formatMessage} from "../../ui/frames/chat";
import {generateIconJQueryTag, getIconManager} from "tc-shared/file/Icons";
import {tr} from "tc-shared/i18n/localize";
import {connectionHistory} from "tc-shared/connectionlog/History";
export function spawnBookmarkModal() {
let modal: Modal;
modal = createModal({
header: tr("Manage bookmarks"),
body: () => {
let template = $("#tmpl_manage_bookmarks").renderTag({});
let selected_bookmark: Bookmark | DirectoryBookmark | undefined;
const button_delete = template.find(".button-delete");
const button_add_folder = template.find(".button-add-folder");
const button_add_bookmark = template.find(".button-add-bookmark");
const button_duplicate = template.find(".button-duplicate");
const button_connect = template.find(".button-connect");
const button_connect_tab = template.find(".button-connect-tab");
const label_bookmark_name = template.find(".header .container-name");
const label_server_address = template.find(".header .container-address");
const input_bookmark_name = template.find(".input-bookmark-name");
const input_connect_profile = template.find(".input-connect-profile");
const input_server_address = template.find(".input-server-address");
const input_server_password = template.find(".input-server-password");
const label_server_name = template.find(".server-name");
const label_server_region = template.find(".server-region");
const label_last_ping = template.find(".server-ping");
const label_client_count = template.find(".server-client-count");
const label_connection_count = template.find(".server-connection-count");
const update_buttons = () => {
button_delete.prop("disabled", !selected_bookmark);
button_connect.prop("disabled", !selected_bookmark || selected_bookmark.type !== BookmarkType.ENTRY);
button_connect_tab.prop("disabled", !selected_bookmark || selected_bookmark.type !== BookmarkType.ENTRY);
button_duplicate.prop("disabled", !selected_bookmark || selected_bookmark.type !== BookmarkType.ENTRY);
};
const update_connect_info = () => {
if (selected_bookmark && selected_bookmark.type === BookmarkType.ENTRY) {
const entry = selected_bookmark as Bookmark;
label_server_name.text(tr("Unknown"));
label_server_region.empty().text(tr("Unknown"));
label_client_count.text(tr("Unknown"));
label_connection_count.empty().append(
...formatMessage(tr("You {} connected to that server address"), $.spawn("div").addClass("connect-never").text("never"))
);
const targetAddress = entry.server_properties.server_address + (entry.server_properties.server_port === 9987 ? "" : ":" + entry.server_properties.server_port);
connectionHistory.lastConnectInfo(targetAddress, "address", true).then(async connectionInfo => {
if(!connectionInfo) {
return;
}
const info = await connectionHistory.queryServerInfo(connectionInfo.serverUniqueId);
if(info) {
label_server_name.text(info.name);
label_server_region.empty().append(
$.spawn("div").addClass("country flag-" + (info.country || "xx").toLowerCase()),
$.spawn("div").text(i18nc.country_name(info.country || "xx", tr("Global")))
);
label_client_count.text(info.clientsOnline + "/" + info.clientsMax);
}
{
const count = await connectionHistory.countConnectCount(targetAddress, "address");
if(count > 0) {
label_connection_count.empty().append(
...formatMessage(tr("You've connected {} times"), $.spawn("div").addClass("connect-count").text(count))
);
}
}
});
label_last_ping.text(tr("Average ping isn't yet supported"));
} else {
label_server_name.text("--");
label_server_region.text("--");
label_last_ping.text("--");
label_client_count.text("--");
label_connection_count.text("--");
}
};
const update_selected = () => {
input_bookmark_name.prop("disabled", !selected_bookmark);
input_connect_profile.prop("disabled", !selected_bookmark || selected_bookmark.type !== BookmarkType.ENTRY);
input_server_address.prop("disabled", !selected_bookmark || selected_bookmark.type !== BookmarkType.ENTRY);
input_server_password.prop("disabled", !selected_bookmark || selected_bookmark.type !== BookmarkType.ENTRY);
if (selected_bookmark) {
input_bookmark_name.val(selected_bookmark.display_name);
label_bookmark_name.text(selected_bookmark.display_name);
}
if (selected_bookmark && selected_bookmark.type === BookmarkType.ENTRY) {
const entry = selected_bookmark as Bookmark;
const address = entry.server_properties.server_address + (entry.server_properties.server_port == 9987 ? "" : (":" + entry.server_properties.server_port));
label_server_address.text(address);
input_server_address.val(address);
let profile = input_connect_profile.find("option[value='" + entry.connect_profile + "']");
if (profile.length == 0) {
logWarn(LogCategory.GENERAL, tr("Failed to find bookmark profile %s. Displaying default one."), entry.connect_profile);
profile = input_connect_profile.find("option[value=default]");
}
profile.prop("selected", true);
input_server_password.val(entry.server_properties.server_password_hash || entry.server_properties.server_password ? "WolverinDEV" : "");
} else {
input_server_password.val("");
input_server_address.val("");
input_connect_profile.find("option[value='no-value']").prop('selected', true);
label_server_address.text(" ");
}
update_connect_info();
};
const container_bookmarks = template.find(".container-bookmarks");
const update_bookmark_list = (_current_selected: string) => {
container_bookmarks.empty();
selected_bookmark = undefined;
update_selected();
const hide_links: boolean[] = [];
const build_entry = (entry: Bookmark | DirectoryBookmark, sibling_data: { first: boolean; last: boolean; }, index: number) => {
let container = $.spawn("div")
.addClass(entry.type === BookmarkType.ENTRY ? "bookmark" : "directory")
.addClass(index > 0 ? "linked" : "")
.addClass(sibling_data.first ? "link-start" : "");
for (let i = 0; i < index; i++) {
container.append(
$.spawn("div")
.addClass("link")
.addClass(i + 1 === index ? " connected" : "")
.addClass(hide_links[i + 1] ? "hidden" : "")
);
}
if (entry.type === BookmarkType.ENTRY) {
const bookmark = entry as Bookmark;
container.append(
bookmark.last_icon_id && bookmark.last_icon_server_id ?
generateIconJQueryTag(getIconManager().resolveIcon(bookmark.last_icon_id, bookmark.last_icon_server_id), {animate: false}) :
$.spawn("div").addClass("icon-container icon_em")
);
} else {
container.append(
$.spawn("div").addClass("icon-container icon_em client-folder")
);
}
container.append(
$.spawn("div").addClass("name").attr("title", entry.display_name).text(entry.display_name)
);
container.appendTo(container_bookmarks);
container.on('click', () => {
if (selected_bookmark === entry)
return;
selected_bookmark = entry;
container_bookmarks.find(".selected").removeClass("selected");
container.addClass("selected");
update_buttons();
update_selected();
});
if (entry.unique_id === _current_selected)
container.trigger('click');
hide_links.push(sibling_data.last);
let cindex = 0;
const children = (entry as DirectoryBookmark).content || [];
for (const child of children)
build_entry(child, {first: cindex++ == 0, last: cindex == children.length}, index + 1);
hide_links.pop();
};
let cindex = 0;
const children = bookmarks().content;
for (const bookmark of children)
build_entry(bookmark, {first: cindex++ == 0, last: cindex == children.length}, 0);
};
/* generate profile list */
{
input_connect_profile.append(
$.spawn("option")
.attr("value", "no-value")
.text("")
.css("display", "none")
);
for (const profile of availableConnectProfiles()) {
input_connect_profile.append(
$.spawn("option")
.attr("value", profile.id)
.text(profile.profileName)
);
}
}
/* buttons */
{
button_delete.on('click', () => {
if (!selected_bookmark) return;
if (selected_bookmark.type === BookmarkType.DIRECTORY && (selected_bookmark as DirectoryBookmark).content.length > 0) {
spawnYesNo(tr("Are you sure"), tr("Do you really want to delete this non empty directory?"), answer => {
if (answer) {
delete_bookmark(selected_bookmark);
save_bookmark(selected_bookmark);
update_bookmark_list(undefined);
}
});
} else {
delete_bookmark(selected_bookmark);
save_bookmark(selected_bookmark);
update_bookmark_list(undefined);
}
});
button_add_folder.on('click', () => {
createInputModal(tr("Enter a folder name"), tr("Enter the folder name"), () => {
return true;
}, result => {
if (result) {
const mark = create_bookmark_directory(
selected_bookmark ?
selected_bookmark.type === BookmarkType.DIRECTORY ?
selected_bookmark as DirectoryBookmark :
selected_bookmark.parent :
bookmarks(),
result as string
);
save_bookmark(mark);
update_bookmark_list(mark.unique_id);
}
}).open();
});
button_add_bookmark.on('click', () => {
createInputModal(tr("Enter a bookmark name"), tr("Enter the bookmark name"), () => {
return true;
}, result => {
if (result) {
const mark = create_bookmark(result as string,
selected_bookmark ?
selected_bookmark.type === BookmarkType.DIRECTORY ?
selected_bookmark as DirectoryBookmark :
selected_bookmark.parent :
bookmarks(), {
server_password: "",
server_port: 9987,
server_address: "",
server_password_hash: ""
}, "");
save_bookmark(mark);
update_bookmark_list(mark.unique_id);
}
}).open();
});
button_connect_tab.on('click', () => {
boorkmak_connect(selected_bookmark as Bookmark, true);
modal.close();
}).toggle(!settings.getValue(Settings.KEY_DISABLE_MULTI_SESSION));
button_connect.on('click', () => {
boorkmak_connect(selected_bookmark as Bookmark, false);
modal.close();
});
button_duplicate.on('click', () => {
createInputModal(tr("Enter a bookmark name"), tr("Enter the bookmark name for the duplicate"), text => text.length > 0, result => {
if (result) {
if (!selected_bookmark) return;
const original = selected_bookmark as Bookmark;
const mark = create_bookmark(result as string,
selected_bookmark ?
selected_bookmark.parent :
bookmarks(), original.server_properties, original.nickname);
save_bookmark(mark);
update_bookmark_list(mark.unique_id);
}
}).open();
});
}
/* inputs */
{
input_bookmark_name.on('change keydown', event => {
const name = input_bookmark_name.val() as string;
const valid = name.length > 3;
input_bookmark_name.firstParent(".input-boxed").toggleClass("is-invalid", !valid);
if (event.type === "change" && valid) {
selected_bookmark.display_name = name;
label_bookmark_name.text(name);
save_bookmark(selected_bookmark);
}
});
input_server_address.on('change keyup', () => {
const address = input_server_address.val() as string;
const valid = !!address.match(Regex.IP_V4) || !!address.match(Regex.IP_V6) || !!address.match(Regex.DOMAIN);
input_server_address.firstParent(".input-boxed").toggleClass("is-invalid", !valid);
if (valid) {
const entry = selected_bookmark as Bookmark;
let _v6_end = address.indexOf(']');
let idx = address.lastIndexOf(':');
if (idx != -1 && idx > _v6_end) {
entry.server_properties.server_port = parseInt(address.substr(idx + 1));
entry.server_properties.server_address = address.substr(0, idx);
} else {
entry.server_properties.server_address = address;
entry.server_properties.server_port = 9987;
}
save_bookmark(selected_bookmark);
label_server_address.text(entry.server_properties.server_address + (entry.server_properties.server_port == 9987 ? "" : (":" + entry.server_properties.server_port)));
update_connect_info();
}
});
input_server_password.on("change keydown", () => {
const password = input_server_password.val() as string;
(selected_bookmark as Bookmark).server_properties.server_password = password;
save_bookmark(selected_bookmark);
});
input_connect_profile.on('change', () => {
const id = input_connect_profile.val() as string;
const profile = availableConnectProfiles().find(e => e.id === id);
if (profile) {
(selected_bookmark as Bookmark).connect_profile = id;
save_bookmark(selected_bookmark);
} else {
logWarn(LogCategory.GENERAL, tr("Failed to change connect profile for profile %s to %s"), selected_bookmark.unique_id, id);
}
})
}
/* Arrow key navigation for the bookmark list */
{
let _focused = false;
let _focus_listener;
let _key_listener;
_focus_listener = event => {
_focused = false;
let element = event.target as HTMLElement;
while (element) {
if (element === container_bookmarks[0]) {
_focused = true;
break;
}
element = element.parentNode as HTMLElement;
}
};
_key_listener = event => {
if (!_focused) return;
if (event.key.toLowerCase() === "arrowdown") {
container_bookmarks.find(".selected").next().trigger('click');
} else if (event.key.toLowerCase() === "arrowup") {
container_bookmarks.find(".selected").prev().trigger('click');
}
};
document.addEventListener('click', _focus_listener);
document.addEventListener('keydown', _key_listener);
modal.close_listener.push(() => {
document.removeEventListener('click', _focus_listener);
document.removeEventListener('keydown', _key_listener);
})
}
update_bookmark_list(undefined);
update_buttons();
template.find(".button-close").on('click', () => modal.close());
return template.children();
},
footer: undefined,
width: "40em"
});
modal.htmlTag.dividerfy().find(".modal-body").addClass("modal-bookmarks");
modal.open();
}

View file

@ -4,7 +4,7 @@ import {createInfoModal, createModal, Modal} from "../../ui/elements/Modal";
import {copyToClipboard} from "../../utils/helpers";
import * as i18nc from "../../i18n/country";
import * as tooltip from "../../ui/elements/Tooltip";
import * as moment from "moment";
import moment from "moment";
import {format_number, network} from "../../ui/frames/chat";
import {generateIconJQueryTag, getIconManager} from "tc-shared/file/Icons";
import {tr} from "tc-shared/i18n/localize";

View file

@ -1,7 +1,7 @@
import {ConnectionHandler} from "../../ConnectionHandler";
import {createModal, Modal} from "../../ui/elements/Modal";
import * as htmltags from "../../ui/htmltags";
import * as moment from "moment";
import moment from "moment";
import {renderBBCodeAsJQuery} from "../../text/bbcode";
import {tr} from "tc-shared/i18n/localize";

View file

@ -10,7 +10,7 @@ import {LogCategory, logWarn} from "../../log";
import * as tooltip from "../../ui/elements/Tooltip";
import * as i18nc from "../../i18n/country";
import {format_time, formatMessage} from "../../ui/frames/chat";
import * as moment from "moment";
import moment from "moment";
import {ErrorCode} from "../../connection/ErrorCode";
import {tr} from "tc-shared/i18n/localize";

View file

@ -12,7 +12,6 @@ import {NameIdentity} from "tc-shared/profiles/identities/NameIdentity";
import {LogCategory, logDebug, logError, logTrace, logWarn} from "tc-shared/log";
import * as i18n from "tc-shared/i18n/localize";
import {RepositoryTranslation, TranslationRepository} from "tc-shared/i18n/localize";
import * as events from "tc-shared/events";
import {Registry} from "tc-shared/events";
import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
import * as i18nc from "tc-shared/i18n/country";
@ -1905,7 +1904,7 @@ export namespace modal_settings {
event_registry.on("reload-profile", event => {
event_registry.fire("query-profile-list");
event_registry.fire("select-profile", event.profile_id || selected_profile);
event_registry.fire("select-profile", { profile_id: event.profile_id || selected_profile });
});
}

View file

@ -0,0 +1,5 @@
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
export function spawnModalAddCurrentServerToBookmarks(handler: ConnectionHandler) {
/* TODO! */
}

View file

@ -0,0 +1,7 @@
export interface ModalBookmarksAddServerVariables {
}
export interface ModalBookmarksAddServerEvents {
}

View file

@ -0,0 +1,13 @@
import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions";
import React from "react";
class ModalBookmarksAddServer extends AbstractModal {
renderBody(): React.ReactElement {
return undefined;
}
renderTitle(): string | React.ReactElement {
return undefined;
}
}

View file

@ -0,0 +1,568 @@
import {spawnModal} from "tc-shared/ui/react-elements/modal";
import {IpcUiVariableProvider} from "tc-shared/ui/utils/IpcVariable";
import {
BookmarkConnectInfo,
BookmarkListEntry,
CurrentClientChannel,
ModalBookmarkEvents,
ModalBookmarkVariables
} from "tc-shared/ui/modal/bookmarks/Definitions";
import {Registry} from "tc-events";
import {BookmarkEntry, BookmarkInfo, bookmarks} from "tc-shared/Bookmarks";
import {connectionHistory} from "tc-shared/connectionlog/History";
import {RemoteIconInfo} from "tc-shared/file/Icons";
import {availableConnectProfiles} from "tc-shared/profiles/ConnectionProfile";
import {hashPassword} from "tc-shared/utils/helpers";
import {server_connections} from "tc-shared/ConnectionManager";
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
import {LocalClientEntry} from "tc-shared/tree/Client";
import _ from "lodash";
import {LogCategory, logError} from "tc-shared/log";
class BookmarkModalController {
readonly events: Registry<ModalBookmarkEvents>;
readonly variables: IpcUiVariableProvider<ModalBookmarkVariables>;
private selectedBookmark: BookmarkEntry;
private bookmarkUniqueServerIds: { [key: string]: Promise<string | undefined> } = {};
private currentClientChannels: { [key: string]: CurrentClientChannel } = {};
private registeredListeners: (() => void)[];
private registeredHandlerListeners: { [key: string]: (() => void)[] } = {};
constructor() {
this.events = new Registry<ModalBookmarkEvents>();
this.variables = new IpcUiVariableProvider<ModalBookmarkVariables>();
this.registeredListeners = [];
//this.variables.setArtificialDelay(1500);
this.variables.setVariableProvider("bookmarks", async () => {
const orderedBookmarks: BookmarkListEntry[] = [];
for(const entry of bookmarks.getOrderedRegisteredBookmarks()) {
let icon: RemoteIconInfo = undefined;
try {
const serverUniqueId = await this.getBookmarkServerUniqueId(entry.entry);
if(serverUniqueId) {
const serverInfo = await connectionHistory.queryServerInfo(serverUniqueId);
if(serverInfo.iconId > 0) {
icon = {
iconId: serverInfo.iconId,
serverUniqueId: serverUniqueId
};
}
}
} catch (_) {}
orderedBookmarks.push({
icon,
depth: entry.depth,
type: entry.entry.type === "directory" ? "directory" : "bookmark",
displayName: entry.entry.displayName,
uniqueId: entry.entry.uniqueId,
childCount: entry.childCount
});
}
return orderedBookmarks;
});
this.variables.setVariableProvider("connectProfiles", () => {
return availableConnectProfiles().map(entry => ({
id: entry.id,
name: entry.profileName
}));
});
this.variables.setVariableProvider("currentClientChannel", async bookmarkId => {
const serverUniqueId = await this.getBookmarkServerUniqueId(bookmarkId);
if(!serverUniqueId) {
return;
}
const handler = server_connections.getAllConnectionHandlers().filter(handler => handler.connected && handler.getCurrentServerUniqueId() === serverUniqueId);
return this.currentClientChannels[handler[0]?.handlerId];
});
this.variables.setVariableProvider("bookmarkInfo", bookmarkId => {
if(!bookmarkId || this.selectedBookmark?.uniqueId !== bookmarkId) {
return undefined;
}
return undefined;
});
this.variables.setVariableProvider("bookmarkSelected", () => {
if(!this.selectedBookmark) {
return { id: undefined, type: "empty" };
} else if(this.selectedBookmark.type === "directory") {
return { id: this.selectedBookmark.uniqueId, type: "directory" };
} else {
return { id: this.selectedBookmark.uniqueId, type: "bookmark" };
}
});
this.variables.setVariableEditor("bookmarkSelected", newValue => {
this.selectBookmark(newValue.id);
});
this.variables.setVariableProvider("bookmarkInfo", this.bookmarkInfoProvider(undefined, async (bookmark): Promise<BookmarkConnectInfo> => {
const serverUniqueId = await this.getBookmarkServerUniqueId(bookmark);
if(!serverUniqueId) {
return undefined;
}
const serverInfo = await connectionHistory.queryServerInfo(serverUniqueId);
if(!serverInfo) {
return undefined;
}
return {
clientsOnline: serverInfo.clientsOnline,
clientsMax: serverInfo.clientsMax,
connectCountUniqueId: await connectionHistory.countConnectCount(serverUniqueId, "server-unique-id"),
connectCountAddress: await connectionHistory.countConnectCount(bookmark.serverAddress, "address"),
hostBannerMode: serverInfo.hostBannerMode,
hostBannerUrl: serverInfo.hostBannerUrl,
serverName: serverInfo.name,
serverRegion: serverInfo.country
};
}));
this.variables.setVariableProvider("bookmarkName", bookmarkId => {
return bookmarks.findBookmark(bookmarkId)?.displayName;
});
this.variables.setVariableEditor("bookmarkName", (newValue, bookmarkId) => {
const bookmark = bookmarks.findBookmark(bookmarkId);
switch(bookmark?.type) {
case "directory":
bookmarks.editDirectory(bookmark.uniqueId, { displayName: newValue });
return true;
case "entry":
bookmarks.editBookmark(bookmark.uniqueId, { displayName: newValue });
return true;
default:
return false;
}
});
this.variables.setVariableProvider("bookmarkServerAddress", this.bookmarkInfoProvider(undefined, bookmark => bookmark.serverAddress));
this.variables.setVariableEditorAsync("bookmarkServerAddress", this.bookmarkEditor(async (updates, newValue) => {
updates.serverAddress = newValue;
}));
this.variables.setVariableProvider("bookmarkServerPassword", this.bookmarkInfoProvider(undefined, bookmark => bookmark.serverPasswordHash || ""));
this.variables.setVariableEditorAsync("bookmarkServerPassword", this.bookmarkEditor(async (updates, newValue) => {
if(newValue) {
updates.serverPasswordHash = await hashPassword(newValue);
} else {
updates.serverPasswordHash = "";
}
return updates.serverPasswordHash;
}));
this.variables.setVariableProvider("bookmarkConnectProfile", this.bookmarkInfoProvider(undefined, bookmark => bookmark.connectProfile));
this.variables.setVariableEditorAsync("bookmarkConnectProfile", this.bookmarkEditor(async (updates, newValue) => {
updates.connectProfile = newValue;
}));
this.variables.setVariableProvider("bookmarkConnectOnStartup", this.bookmarkInfoProvider(undefined, bookmark => bookmark.connectOnStartup));
this.variables.setVariableEditorAsync("bookmarkConnectOnStartup", this.bookmarkEditor(async (updates, newValue) => {
updates.connectOnStartup = newValue;
}));
this.variables.setVariableProvider("bookmarkDefaultChannel", this.bookmarkInfoProvider(undefined, bookmark => bookmark.defaultChannel));
this.variables.setVariableEditorAsync("bookmarkDefaultChannel", this.bookmarkEditor(async (updates, newValue) => {
updates.defaultChannel = newValue;
updates.defaultChannelPasswordHash = undefined;
}));
this.variables.setVariableProvider("bookmarkDefaultChannelPassword", this.bookmarkInfoProvider(undefined, bookmark => bookmark.defaultChannelPasswordHash || ""));
this.variables.setVariableEditorAsync("bookmarkDefaultChannelPassword", this.bookmarkEditor(async (updates, newValue) => {
if(newValue) {
updates.defaultChannelPasswordHash = await hashPassword(newValue);
} else {
updates.defaultChannelPasswordHash = "";
}
return updates.defaultChannelPasswordHash;
}));
/* events */
this.events.on("action_delete_bookmark", event => bookmarks.deleteEntry(event.uniqueId));
this.events.on("action_create_bookmark", event => {
if(!event.displayName) {
return;
}
let parentBookmark, previousBookmark;
switch (event.order.type) {
case "parent":
parentBookmark = event.order.entry;
previousBookmark = undefined;
break;
case "previous": {
const previous = bookmarks.findBookmark(event.order.entry);
previousBookmark = event.order.entry;
parentBookmark = previous?.parentEntry;
break;
}
case "selected":
default:
parentBookmark = this.selectedBookmark?.parentEntry;
previousBookmark = this.selectedBookmark?.previousEntry;
break;
}
if(event.entryType === "bookmark") {
bookmarks.createBookmark({
displayName: event.displayName,
parentEntry: parentBookmark,
previousEntry: previousBookmark,
connectOnStartup: false,
connectProfile: "default",
defaultChannelPasswordHash: undefined,
defaultChannel: undefined,
serverAddress: "",
serverPasswordHash: undefined
});
} else {
bookmarks.createDirectory({
displayName: event.displayName,
parentEntry: parentBookmark,
previousEntry: previousBookmark,
});
}
});
this.events.on("action_duplicate_bookmark", event => {
if(!event.displayName) {
return;
}
const bookmark = bookmarks.findBookmark(event.uniqueId);
if(!bookmark || bookmark.type !== "entry") {
return;
}
const newBookmark = bookmarks.createBookmark({
serverAddress: bookmark.serverAddress,
serverPasswordHash: bookmark.serverPasswordHash,
defaultChannel: bookmark.defaultChannel,
defaultChannelPasswordHash: bookmark.defaultChannelPasswordHash,
connectOnStartup: bookmark.connectOnStartup,
connectProfile: bookmark.connectProfile,
displayName: event.displayName,
previousEntry: bookmark.uniqueId,
parentEntry: bookmark.parentEntry
});
this.selectBookmark(newBookmark.uniqueId);
});
this.events.on("action_connect", event => {
bookmarks.executeConnect(event.uniqueId, event.newTab);
});
this.events.on("action_export", () => {
this.events.fire("notify_export_data", { payload: bookmarks.exportBookmarks() });
});
this.events.on("action_import", event => {
if(!event.payload) {
return;
}
try {
const importedBookmarks = bookmarks.importBookmarks(event.payload);
this.events.fire("notify_import_result", { status: "success", importedBookmarks: importedBookmarks });
} catch (error) {
if(typeof error !== "string") {
logError(LogCategory.BOOKMARKS, tr("Failed to import bookmarks: %o"), error);
error = tr("lookup the console for more details");
}
this.events.fire("notify_import_result", { status: "error", message: error });
}
});
}
initialize() {
this.registeredListeners.push(bookmarks.events.on("notify_bookmark_deleted", event => {
this.variables.sendVariable("bookmarks");
if(this.selectedBookmark?.uniqueId === event.bookmark.uniqueId) {
this.selectBookmark(bookmarks.getRegisteredBookmarks()[0]?.uniqueId);
}
}));
this.registeredListeners.push(bookmarks.events.on("notify_bookmark_created", event => {
this.variables.sendVariable("bookmarks");
this.selectBookmark(event.bookmark.uniqueId);
}));
this.registeredListeners.push(bookmarks.events.on("notify_bookmarks_imported", () => this.variables.sendVariable("bookmarks")));
this.registeredListeners.push(bookmarks.events.on("notify_bookmark_edited", event => {
if(event.keys.indexOf("serverAddress") !== -1) {
delete this.bookmarkUniqueServerIds[event.bookmark.uniqueId];
}
if(event.keys.indexOf("displayName") !== -1 ||
event.keys.indexOf("parentEntry") !== -1 ||
event.keys.indexOf("previousEntry") !== -1 ||
event.keys.indexOf("serverAddress") !== -1) {
this.variables.sendVariable("bookmarks");
}
if(event.bookmark.uniqueId === this.selectedBookmark?.uniqueId) {
if(event.keys.indexOf("displayName") !== -1) {
this.variables.sendVariable("bookmarkName", this.selectedBookmark.uniqueId);
}
if(event.keys.indexOf("connectProfile") !== -1) {
this.variables.sendVariable("bookmarkConnectProfile", this.selectedBookmark.uniqueId);
}
if(event.keys.indexOf("connectOnStartup") !== -1) {
this.variables.sendVariable("bookmarkConnectOnStartup", this.selectedBookmark.uniqueId);
}
if(event.keys.indexOf("serverAddress") !== -1) {
this.variables.sendVariable("bookmarkServerAddress", this.selectedBookmark.uniqueId);
this.variables.sendVariable("bookmarkInfo", this.selectedBookmark.uniqueId);
this.variables.sendVariable("currentClientChannel", this.selectedBookmark.uniqueId);
}
if(event.keys.indexOf("serverPasswordHash") !== -1) {
this.variables.sendVariable("bookmarkServerPassword", this.selectedBookmark.uniqueId);
}
if(event.keys.indexOf("defaultChannel") !== -1) {
this.variables.sendVariable("bookmarkDefaultChannel", this.selectedBookmark.uniqueId);
}
if(event.keys.indexOf("defaultChannelPasswordHash") !== -1) {
this.variables.sendVariable("bookmarkDefaultChannelPassword", this.selectedBookmark.uniqueId);
}
}
}));
this.registeredListeners.push(server_connections.events().on("notify_handler_created", event => {
this.registerHandlerListener(event.handler);
}));
this.registeredListeners.push(server_connections.events().on("notify_handler_deleted", event => {
this.unregisterHandlerListener(event.handler);
}));
server_connections.getAllConnectionHandlers().forEach(handler => this.registerHandlerListener(handler));
this.selectBookmark(bookmarks.getRegisteredBookmarks()[0].uniqueId);
}
private registerHandlerListener(handler: ConnectionHandler) {
const events = this.registeredHandlerListeners[handler.handlerId] = [];
events.push(handler.events().on("notify_connection_state_changed", event => {
if(event.newState !== ConnectionState.CONNECTED) {
this.updateHandlerClientChannel(handler, undefined);
}
}));
events.push(handler.channelTree.events.on(["notify_client_moved", "notify_client_enter_view"], event => {
if(!(event.client instanceof LocalClientEntry)) {
return;
}
const targetChannel = event.client.currentChannel();
if(!targetChannel) {
this.updateHandlerClientChannel(handler, undefined);
return;
}
/* TODO: Register to channel rename events maybe? */
this.updateHandlerClientChannel(handler);
}));
this.updateHandlerClientChannel(handler);
}
private unregisterHandlerListener(handler: ConnectionHandler) {
this.updateHandlerClientChannel(handler, undefined);
this.registeredHandlerListeners[handler.handlerId]?.forEach(callback => callback());
delete this.registeredHandlerListeners[handler.handlerId];
}
private updateHandlerClientChannel(handler: ConnectionHandler, newChannel?: CurrentClientChannel) {
if(arguments.length === 1 && handler.connected) {
const channel = handler.getClient().currentChannel();
if(channel) {
let path;
{
path = "";
let current = channel;
while(current) {
path = "/" + current.channelName() + path;
current = current.parent_channel();
}
}
newChannel = {
name: channel.channelName(),
passwordHash: channel.getCachedPasswordHash(),
path: path,
channelId: channel.getChannelId()
};
}
}
const oldChannel = this.registeredHandlerListeners[handler.handlerId];
if(_.isEqual(oldChannel, newChannel)) {
return;
}
if(!newChannel) {
delete this.currentClientChannels[handler.handlerId];
} else {
this.currentClientChannels[handler.handlerId] = newChannel;
}
/* Only update the current bookmark */
const handlerServerUniqueId = handler.getCurrentServerUniqueId();
this.getBookmarkServerUniqueId(this.selectedBookmark).then(serverUniqueId => {
if(serverUniqueId !== handlerServerUniqueId) {
return;
}
this.variables.sendVariable("currentClientChannel", this.selectedBookmark.uniqueId);
});
}
destroy() {
this.registeredListeners.forEach(callback => callback());
this.registeredListeners = [];
Object.keys(this.registeredHandlerListeners)
.forEach(handlerId => this.registeredHandlerListeners[handlerId].forEach(callback => callback()));
this.registeredHandlerListeners = {};
this.events.destroy();
this.variables.destroy();
}
selectBookmark(bookmarkUniqueId: string) {
if(this.selectedBookmark?.uniqueId === bookmarkUniqueId) {
return;
}
this.selectedBookmark = bookmarks.findBookmark(bookmarkUniqueId);
this.variables.sendVariable("bookmarkSelected");
}
private bookmarkInfoProvider<T>(defaultValue: T, callback: (bookmark: BookmarkInfo) => T | Promise<T>) : (bookmarkId: any) => T | Promise<T> {
return bookmarkId => {
if(!bookmarkId) {
return defaultValue;
}
if(bookmarkId === this.selectedBookmark?.uniqueId) {
if(this.selectedBookmark.type === "entry") {
return callback(this.selectedBookmark);
} else {
return defaultValue;
}
} else {
const bookmark = bookmarks.findBookmark(bookmarkId);
if(bookmark?.type === "entry") {
return callback(bookmark);
} else {
return defaultValue;
}
}
}
}
private bookmarkEditor<T, R>(callback: (updates: Partial<BookmarkInfo>, newValue: T, bookmark: BookmarkInfo) => Promise<R>) : (newValue: T, bookmarkId: any) => Promise<R | false> {
return async (newValue, bookmarkId) => {
if(!bookmarkId) {
return;
}
let bookmark: BookmarkInfo;
if(bookmarkId === this.selectedBookmark?.uniqueId) {
if(this.selectedBookmark.type === "entry") {
bookmark = this.selectedBookmark;
}
} else {
const foundBookmark = bookmarks.findBookmark(bookmarkId);
if(foundBookmark?.type === "entry") {
bookmark = foundBookmark;
}
}
if(!bookmark) {
return false;
}
const updates = {};
const result = await callback(updates, newValue, bookmark);
if(Object.keys(updates).length > 0) {
bookmarks.editBookmark(bookmark.uniqueId, updates);
}
return result;
}
}
private getBookmarkServerUniqueId(bookmarkOrId: string | BookmarkEntry) : Promise<string | undefined> {
let bookmarkId = typeof bookmarkOrId === "string" ? bookmarkOrId : bookmarkOrId?.uniqueId;
let bookmark = typeof bookmarkOrId === "string" ? bookmarks.findBookmark(bookmarkOrId) : bookmarkOrId;
if(!bookmark || bookmark.type !== "entry") {
return Promise.resolve(undefined);
}
if(this.bookmarkUniqueServerIds[bookmarkId]) {
return this.bookmarkUniqueServerIds[bookmarkId];
}
return this.bookmarkUniqueServerIds[bookmarkId] = (async () => {
const info = await connectionHistory.lastConnectInfo(bookmark.serverAddress, "address");
if(!info) {
return undefined;
}
return info.serverUniqueId;
})();
}
}
export function spawnBookmarkModal() {
const controller = new BookmarkModalController();
controller.initialize();
const modal = spawnModal("modal-bookmarks", [controller.events.generateIpcDescription(), controller.variables.generateConsumerDescription()], { popoutable: true, popedOut: false });
modal.getEvents().on("destroy", () => controller.destroy());
controller.events.on("action_connect", event => {
if(!event.closeModal) {
return;
}
modal.destroy();
});
modal.show().then(undefined);
}

View file

@ -0,0 +1,77 @@
import {RemoteIconInfo} from "tc-shared/file/Icons";
export type BookmarkListEntry = {
uniqueId: string,
type: "bookmark" | "directory",
displayName: string,
icon: RemoteIconInfo | undefined,
depth: number,
childCount: number,
}
export interface BookmarkConnectInfo {
serverName: string,
serverRegion: string,
clientsOnline: number,
clientsMax: number,
connectCountUniqueId: number,
connectCountAddress: number,
hostBannerUrl: string,
hostBannerMode: number
}
export type CurrentClientChannel = { name: string, channelId: number, path: string, passwordHash: string };
export interface ModalBookmarkVariables {
readonly bookmarks: BookmarkListEntry[],
bookmarkSelected: { type?: "empty" | "bookmark" | "directory", id: string | undefined },
readonly connectProfiles: { id: string, name: string }[],
readonly currentClientChannel: CurrentClientChannel | undefined,
bookmarkName: string,
bookmarkConnectProfile: string,
bookmarkConnectOnStartup: boolean,
bookmarkServerAddress: string,
bookmarkServerPassword: string | undefined,
bookmarkDefaultChannel: string | undefined,
bookmarkDefaultChannelPassword: string | undefined,
bookmarkInfo: BookmarkConnectInfo | undefined,
}
export interface ModalBookmarkEvents {
action_create_bookmark: {
entryType: "bookmark" | "directory",
order: {
type: "previous",
entry: string
} | {
type: "parent",
entry: string
} | {
type: "selected",
},
displayName: string | undefined
},
action_duplicate_bookmark: { uniqueId: string, displayName: string | undefined, originalName: string },
action_delete_bookmark: { uniqueId: string },
action_connect: { uniqueId: string, newTab: boolean, closeModal: boolean },
action_export: {},
action_import: { payload: string | undefined },
notify_export_data: { payload: string }
notify_import_result: {
status: "success",
importedBookmarks: number
} | {
status: "error",
message: string
}
}

View file

@ -0,0 +1,670 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
.container {
display: flex;
flex-direction: row;
justify-content: stretch;
height: 45em;
min-width: 30em;
width: 80em;
max-width: 100%;
flex-shrink: 1;
@include user-select(none);
.inputBoxed {
height: 2em;
}
}
.listContainer {
min-width: 12em;
width: 30%;
flex-grow: 1;
flex-shrink: 1;
padding: .5em;
background-color: #212125;
display: flex;
flex-direction: column;
justify-content: stretch;
.title {
flex-shrink: 0;
flex-grow: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
.text {
flex-shrink: 1;
min-width: 1em;
text-align: left;
font-size: 1.5em;
color: #557edc;
text-transform: uppercase;
@include text-dotdotdot();
}
.containerButton {
flex-shrink: 0;
flex-grow: 0;
display: flex;
flex-direction: column;
justify-content: center;
.button {
cursor: pointer;
font-size: 1.2em;
padding: .2em;
border-radius: .2em;
display: flex;
flex-direction: column;
justify-content: center;
margin-right: .2em;
&:hover {
background-color: #0000004f;
}
}
}
}
.containerBookmarks {
flex-shrink: 1;
flex-grow: 1;
min-height: 6em;
display: flex;
flex-direction: column;
justify-content: stretch;
position: relative;
overflow: auto;
@include chat-scrollbar();
.bookmark, .directory {
position: relative;
flex-grow: 0;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: stretch;
border-radius: $border_radius_middle;
padding: .25em .5em;
cursor: pointer;
.icon {
flex-grow: 0;
flex-shrink: 0;
align-self: center;
margin-right: .5em;
}
.name {
flex-grow: 1;
flex-shrink: 1;
min-width: 5em;
align-self: center;
@include text-dotdotdot();
}
.bookmarkButtons {
opacity: 0;
position: absolute;
right: 0;
top: 0;
bottom: 0;
display: flex;
flex-direction: row;
background: inherit;
@include transition(opacity ease-in-out $button_hover_animation_time);
.button {
display: flex;
flex-direction: column;
justify-content: center;
align-self: center;
margin-right: .2em;
padding: .2em;
border-radius: .2em;
&:hover {
background-color: #0000004f;
}
}
}
&:hover {
background-color: #2c2d2f;
.bookmarkButtons {
opacity: 1;
}
}
&.selected {
background-color: #1a1a1b;
}
.link {
flex-grow: 0;
flex-shrink: 0;
position: relative;
width: 1.5em;
$line_width: 2px;
$color: hsla(0, 0%, 35%, 1);
&:not(.hidden) {
&:before {
content: "";
position: absolute;
height: 2.25em; /* connect with the previous one */
width: .75em;
left: .5em; /* icons have a width of 1em */
bottom: calc(.75em - #{$line_width / 2});
border-left: $line_width solid $color;
}
&.connected {
&:before {
border-bottom: $line_width solid $color;
border-bottom-left-radius: .3em;
}
}
}
}
}
.linkStart + .bookmark, .linkStart + .directory {
.link.connected {
&:before {
height: 1.25em;
}
}
}
}
.buttons {
flex-shrink: 0;
flex-grow: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
padding-top: .5em;
button {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:not(:first-of-type) {
margin-left: .5em;
}
}
}
.overlay {
background-color: #212125;
}
}
.infoContainer {
min-width: 25em;
width: 30%;
flex-grow: 1;
flex-shrink: 1;
background-color: #2f2f35;
display: flex;
flex-direction: column;
justify-content: flex-start;
.header {
flex-grow: 0;
flex-shrink: 0;
height: 10em;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: .5em;
position: relative;
.containerName {
z-index: 1;
position: relative;
padding-right: 1.5em;
display: flex;
flex-direction: row;
.name {
flex-shrink: 1;
flex-grow: 0;
font-size: 2em;
color: #fcfcfc;
@include text-dotdotdot();
text-shadow: 2px 2px #666666;
@include transition(border-color ease-in-out $button_hover_animation_time);
}
.edit {
flex-shrink: 0;
padding-left: .5em;
display: flex;
flex-direction: column;
justify-content: center;
.button {
padding: .2em;
border-radius: .2em;
cursor: pointer;
display: flex;
opacity: .5;
@include transition(all ease-in-out $button_hover_animation_time);
&:hover {
opacity: 1;
background: #0000004f;
}
.icon {
font-size: 1.5em;
}
}
}
&.editing {
padding-right: 0;
.name {
width: 100%;
text-shadow: none;
border: .04em solid white;
border-radius: .15em;
padding-left: .25em;
padding-right: .25em;
background: #0000006f;
overflow: auto;
text-overflow: unset;
@include chat-scrollbar(.5em / 2em);
&.invalid {
border-color: #721c1c;
}
}
}
}
.containerAddress {
z-index: 1;
font-size: 1.5em;
color: #fcfcfc;
@include text-dotdotdot();
}
.hostBanner {
position: absolute;
top: 0;
left: 0;
&.individual {
right: 0;
bottom: 0;
padding: .5em;
&:after {
content: ' ';
background: #00000020;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
}
.renderer {
height: 100%;
width: 100%;
}
}
//background: url("./header_background.png") no-repeat;
}
.containerSettings {
flex-grow: 1;
flex-shrink: 1;
min-height: 10em;
padding: .5em;
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow-y: auto;
overflow-x: hidden;
@include chat-scrollbar-vertical();
.group {
padding: .5em;
border-radius: .2em;
border: 1px solid #1f2122;
background-color: #28292b;
display: flex;
flex-direction: column;
justify-content: flex-start;
> .row {
display: flex;
flex-direction: row;
justify-content: stretch;
.key {
flex-grow: 0;
flex-shrink: 1;
width: 15em;
min-width: 2em;
align-self: center;
color: #557edc;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.value {
flex-grow: 1;
flex-shrink: 1;
min-width: 2em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:not(:first-of-type) {
margin-top: 1em;
}
}
&:not(:first-of-type) {
margin-top: 1em;
}
&.connectInfoContainer {
flex-direction: row;
}
}
}
.buttons {
padding: .5em;
display: flex;
flex-direction: row;
justify-content: flex-start;
.buttonDuplicate {
margin-right: auto;
}
button {
flex-shrink: 1;
flex-grow: 1;
min-width: 2em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:not(:first-of-type) {
margin-left: .5em;
}
}
}
}
.connectInfoContainer {
position: relative;
.containerImage {
flex-grow: 1;
flex-shrink: 1;
max-width: 15em;
max-height: 9em; /* minus one padding */
width: 15em;
display: flex;
flex-direction: column;
justify-content: center;
img {
object-fit: contain;
max-height: 100%;
max-width: 100%;
}
@include transition(.25s ease-in-out);
}
.containerProperties {
flex-shrink: 1;
flex-grow: 1;
min-width: 23em;
display: flex;
flex-direction: column;
justify-content: flex-start;
height: inherit;
.row {
flex-grow: 0;
flex-shrink: 0;
height: 1.8em;
display: flex;
flex-direction: row;
justify-content: flex-start;
.key {
flex-shrink: 0;
flex-grow: 0;
color: #557edc;
text-transform: uppercase;
align-self: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 15em;
}
.value {
color: #d6d6d7;
align-self: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.serverRegion {
> div {
display: inline-block;
}
.country {
margin-right: .25em;
}
}
&.valueConnectCount {
display: flex;
flex-direction: row;
justify-content: flex-start;
.text {
margin-right: .5em;
}
> span {
display: flex;
align-self: center;
}
}
.connectCount, .connectNever {
display: inline-block;
color: #7a3131;
}
}
}
}
.tooltipIcon {
text-align: left;
width: 1em;
height: 1em;
}
.overlay {
background: #28292b;
}
}
.inputIconContainer {
display: flex;
flex-direction: column;
justify-content: center;
.iconContainer {
display: flex;
padding: .2em;
margin-right: .2em;
}
&.enabled {
.iconContainer {
cursor: pointer;
&:hover {
background: #ffffff10;
border-radius: .2em;
}
}
}
}
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: none;
flex-direction: column;
justify-content: center;
&.shown {
display: flex;
}
.text {
color: #666;
text-align: center;
font-size: 1.2em;
}
.buttonCreate {
margin-top: .5em;
max-width: 100%;
flex-grow: 0;
align-self: center;
}
}

View file

@ -0,0 +1,797 @@
import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions";
import {createIpcUiVariableConsumer, IpcVariableDescriptor} from "tc-shared/ui/utils/IpcVariable";
import {
BookmarkConnectInfo,
BookmarkListEntry,
ModalBookmarkEvents,
ModalBookmarkVariables
} from "tc-shared/ui/modal/bookmarks/Definitions";
import {IpcRegistryDescription, Registry} from "tc-events";
import {UiVariableConsumer} from "tc-shared/ui/utils/Variable";
import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n";
import {useContext, useEffect, useRef} from "react";
import {ContextDivider} from "tc-shared/ui/react-elements/ContextDivider";
import {joinClassList, useDependentState, useTr} from "tc-shared/ui/react-elements/Helper";
import {Button} from "tc-shared/ui/react-elements/Button";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {IconRenderer, RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon";
import {getIconManager} from "tc-shared/file/Icons";
import {ClientIcon} from "svg-sprites/client-icons";
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
import {spawnContextMenu} from "tc-shared/ui/ContextMenu";
import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
import {formatMessage} from "tc-shared/ui/frames/chat";
import {createErrorModal, createInfoModal, createInputModal} from "tc-shared/ui/elements/Modal";
import {HostBannerRenderer} from "tc-shared/ui/frames/HostBannerRenderer";
import {ControlledBoxedInputField, ControlledSelect} from "tc-shared/ui/react-elements/InputField";
import {Checkbox} from "tc-shared/ui/react-elements/Checkbox";
import * as React from "react";
import DefaultHeaderImage from "./header_background.png";
import ServerInfoImage from "./serverinfo.png";
import {IconTooltip} from "tc-shared/ui/react-elements/Tooltip";
import {CountryIcon} from "tc-shared/ui/react-elements/CountryIcon";
import {downloadTextAsFile, requestFileAsText} from "tc-shared/file/Utils";
const EventContext = React.createContext<Registry<ModalBookmarkEvents>>(undefined);
const VariableContext = React.createContext<UiVariableConsumer<ModalBookmarkVariables>>(undefined);
const SelectedBookmarkIdContext = React.createContext<{ type: "empty" | "bookmark" | "directory", id: string | undefined }>({ type: "empty", id: undefined });
const SelectedBookmarkInfoContext = React.createContext<BookmarkConnectInfo>(undefined);
const cssStyle = require("./Renderer.scss");
const Link = (props: { connected: boolean }) => (
<div className={joinClassList(
cssStyle.link,
props.connected ? cssStyle.connected : undefined
)} />
);
const BookmarkListEntryRenderer = React.memo((props: { entry: BookmarkListEntry }) => {
const variables = useContext(VariableContext);
const events = useContext(EventContext);
const selectedItem = variables.useVariable("bookmarkSelected", undefined, undefined);
const tryDelete = () => {
if(props.entry.type === "directory" && props.entry.childCount > 0) {
spawnYesNo(tr("Are you sure?"), formatMessage(
tr("Do you really want to delete the directory \"{0}\"?{:br:}The directory contains {1} entries."),
props.entry.displayName, props.entry.childCount
), result => {
if(result) {
events.fire("action_delete_bookmark", { uniqueId: props.entry.uniqueId });
}
}).open();
} else {
events.fire("action_delete_bookmark", { uniqueId: props.entry.uniqueId });
}
};
let icon;
if(props.entry.icon) {
icon = <RemoteIconRenderer key={"icon-" + props.entry.icon.iconId} icon={getIconManager().resolveIconInfo(props.entry.icon)} className={cssStyle.icon} />;
} else if(props.entry.type === "directory") {
icon = <IconRenderer key={"directory"} icon={ClientIcon.Folder} className={cssStyle.icon} />;
} else {
icon = <IconRenderer key={"no-icon"} icon={ClientIcon.ServerGreen} className={cssStyle.icon} />;
}
let links = [];
for(let i = 0; i < props.entry.depth; i++) {
links.push(<Link connected={i + 1 === props.entry.depth} key={"link-" + i} />);
}
let buttons = [];
if(props.entry.type === "bookmark") {
buttons.push(
<div
className={cssStyle.button}
key={"bookmark-duplicate"}
title={tr("Duplicate entry")}
onClick={() => events.fire("action_duplicate_bookmark", { uniqueId: props.entry.uniqueId, displayName: undefined, originalName: props.entry.displayName })}
>
<ClientIconRenderer icon={ClientIcon.BookmarkDuplicate} />
</div>
);
}
buttons.push(
<div className={cssStyle.button} key={"bookmark-remove"} title={tr("Delete entry")} onClick={tryDelete}>
<ClientIconRenderer icon={ClientIcon.Delete} />
</div>
);
return (
<div
key={"entry-" + props.entry.uniqueId}
className={joinClassList(
props.entry.type === "directory" ? cssStyle.directory : cssStyle.bookmark,
props.entry.childCount > 0 ? cssStyle.linkStart : undefined,
selectedItem.remoteValue?.id === props.entry.uniqueId ? cssStyle.selected : undefined,
)}
onClick={() => {
if(selectedItem.remoteValue?.id === props.entry.uniqueId) {
return;
}
selectedItem.setValue({id: props.entry.uniqueId});
}}
onDoubleClick={() => {
if(props.entry.type !== "bookmark") {
return;
}
events.fire("action_connect", { uniqueId: props.entry.uniqueId, newTab: false, closeModal: true });
}}
onContextMenu={event => {
event.preventDefault();
if(selectedItem.remoteValue?.id !== props.entry.uniqueId) {
selectedItem.setValue({ id: props.entry.uniqueId });
}
spawnContextMenu({ pageX: event.pageX, pageY: event.pageY }, [
{
type: "normal",
label: tr("Connect to server"),
visible: props.entry.type === "bookmark",
icon: ClientIcon.Connect,
click: () => events.fire("action_connect", { uniqueId: props.entry.uniqueId, newTab: false, closeModal: true })
},
{
type: "normal",
label: tr("Connect in a new tab"),
visible: props.entry.type === "bookmark",
icon: ClientIcon.Connect,
click: () => events.fire("action_connect", { uniqueId: props.entry.uniqueId, newTab: true, closeModal: true })
},
{
type: "separator",
visible: props.entry.type === "bookmark",
},
{
type: "normal",
label: tr("Duplicate Bookmark"),
visible: props.entry.type === "bookmark",
icon: ClientIcon.BookmarkDuplicate,
click: () => events.fire("action_duplicate_bookmark", { uniqueId: props.entry.uniqueId, displayName: undefined, originalName: props.entry.displayName })
},
{
type: "normal",
label: tr("Add bookmark"),
icon: ClientIcon.BookmarkAdd,
click: () => events.fire("action_create_bookmark", { entryType: "bookmark", order: { type: "parent", entry: props.entry.uniqueId }, displayName: undefined })
},
{
type: "normal",
label: tr("Add directory"),
icon: ClientIcon.BookmarkAddFolder,
click: () => events.fire("action_create_bookmark", { entryType: "directory", order: { type: "previous", entry: props.entry.uniqueId }, displayName: undefined })
},
{
type: "normal",
label: tr("Add sub directory"),
visible: props.entry.type === "directory",
icon: ClientIcon.BookmarkAddFolder,
click: () => events.fire("action_create_bookmark", { entryType: "directory", order: { type: "parent", entry: props.entry.uniqueId }, displayName: undefined })
},
{
type: "separator",
},
{
type: "normal",
label: props.entry.type === "bookmark" ? tr("Delete bookmark") : tr("Delete directory"),
icon: ClientIcon.BookmarkRemove,
click: tryDelete
}
]);
}}
>
{...links}
{icon}
<div className={cssStyle.name} title={props.entry.displayName}>
{props.entry.displayName}
</div>
<div className={cssStyle.bookmarkButtons}>
{...buttons}
</div>
</div>
);
});
const BookmarkList = React.memo(() => {
const events = useContext(EventContext);
const variables = useContext(VariableContext);
const bookmarksInfo = variables.useReadOnly("bookmarks");
const bookmarks = bookmarksInfo.status === "loaded" ? bookmarksInfo.value : [];
return (
<div className={cssStyle.containerBookmarks}>
{bookmarks.map(entry => <BookmarkListEntryRenderer entry={entry} key={"entry-" + entry.uniqueId} />)}
<div key={"overlay-loading"} className={cssStyle.overlay + " " + (bookmarksInfo.status === "loaded" ? "" : cssStyle.shown)}>
<div className={cssStyle.text}><Translatable>loading</Translatable> <LoadingDots /></div>
</div>
<div key={"overlay-no-entries"} className={cssStyle.overlay + " " + (bookmarksInfo.status === "loaded" && bookmarksInfo.value.length === 0 ? cssStyle.shown : "")}>
<div className={cssStyle.text}>
<Translatable>You don't have any bookmarks</Translatable>
</div>
<Button
onClick={() => events.fire("action_create_bookmark", { order: { type: "selected" }, displayName: undefined, entryType: "bookmark" })}
className={cssStyle.buttonCreate}
>
<Translatable>Create new bookmark</Translatable>
</Button>
</div>
</div>
);
});
const BookmarkListContainer = React.memo(() => {
const events = useContext(EventContext);
return (
<div className={cssStyle.listContainer}>
<div className={cssStyle.title} title={useTr("Your bookmarks")}>
<div className={cssStyle.text}><Translatable>Your bookmarks</Translatable></div>
<div className={cssStyle.containerButton}>
<div
className={cssStyle.button}
title={useTr("Add new bookmark")}
onClick={() => events.fire("action_create_bookmark", { entryType: "bookmark", order: { type: "selected" }, displayName: undefined })}
>
<ClientIconRenderer icon={ClientIcon.BookmarkAdd} />
</div>
</div>
</div>
<BookmarkList />
<div className={cssStyle.buttons}>
<Button onClick={() => events.fire("action_export")}>
<Translatable>Export</Translatable>
</Button>
<Button onClick={() => events.fire("action_import")}>
<Translatable>Import</Translatable>
</Button>
</div>
</div>
);
});
const SelectedBookmarkBanner = React.memo(() => {
const bookmarkInfo = useContext(SelectedBookmarkInfoContext);
if(!bookmarkInfo?.hostBannerUrl) {
return (
<img key={"default"} alt={""} src={DefaultHeaderImage} className={cssStyle.hostBanner} />
);
}
return (
<div className={cssStyle.hostBanner + " " + cssStyle.individual} key={"server"}>
<HostBannerRenderer
key={"hostbanner"}
banner={{
imageUrl: bookmarkInfo.hostBannerUrl,
linkUrl: undefined,
mode: "resize-ratio",
updateInterval: 0
}}
className={cssStyle.renderer}
/>
</div>
);
});
const SelectedBookmarkName = React.memo(() => {
const refEditPanel = useRef<HTMLDivElement>();
const selectedBookmarkId = useContext(SelectedBookmarkIdContext);
const variables = useContext(VariableContext);
const nameVariable = variables.useVariable("bookmarkName", selectedBookmarkId.id);
let [ editMode, setEditMode ] = useDependentState(() => false, [ selectedBookmarkId.id ]);
if(selectedBookmarkId.type === "empty") {
editMode = false;
}
useEffect(() => {
if(refEditPanel.current) {
refEditPanel.current.textContent = nameVariable.localValue;
refEditPanel.current.focus();
}
}, [ editMode ]);
if(nameVariable.status === "loading") {
return (
<div key={"name-loading"} className={cssStyle.containerName}>
<div className={cssStyle.name}><Translatable>loading</Translatable> <LoadingDots /></div>
</div>
);
} else if(editMode) {
return (
<div key={"name-edit"} className={cssStyle.containerName + " " + cssStyle.editing}>
<div
ref={refEditPanel}
className={cssStyle.name}
contentEditable={true}
onKeyDown={event => {
if(event.key === "Enter") {
event.preventDefault();
refEditPanel.current?.blur();
} else if(event.key === "Backspace" || event.key === "Delete") {
/* never prevent these */
} else if(event.ctrlKey) {
/* don't prevent this */
} else if(event.currentTarget.textContent?.length >= 32) {
event.preventDefault();
}
}}
onInput={event => {
const value = event.currentTarget.textContent;
const valid = typeof value === "string" && value.length > 0 && value.length <= 32;
refEditPanel.current?.classList.toggle(cssStyle.invalid, !valid);
}}
onBlur={() => {
const value = refEditPanel.current?.textContent;
setEditMode(false);
if(!value || value.length > 32) { return; }
nameVariable.setValue(value);
}}
>
</div>
</div>
);
} else {
return (
<div key={"name-value"} className={cssStyle.containerName}>
<div className={cssStyle.name}>{nameVariable.status === "applying" ? tr("applying") : nameVariable.localValue}</div>
<div className={cssStyle.edit} onClick={() => setEditMode(true)}>
<div className={cssStyle.button}>
<ClientIconRenderer className={cssStyle.icon} icon={ClientIcon.BookmarkEditName} />
</div>
</div>
</div>
);
}
})
const SelectedBookmarkHeader = React.memo(() => {
const selectedBookmarkId = useContext(SelectedBookmarkIdContext);
const variables = useContext(VariableContext);
const addressVariable = variables.useReadOnly("bookmarkServerAddress", selectedBookmarkId.id);
let address;
if(selectedBookmarkId.type === "bookmark") {
if(addressVariable.status === "loading") {
address = <React.Fragment key={"address-loading"}><Translatable>loading</Translatable> <LoadingDots /></React.Fragment>
} else {
address = <React.Fragment key={"address-value"}>{addressVariable.value}</React.Fragment>;
}
}
return (
<div className={cssStyle.header}>
<SelectedBookmarkName />
<div className={cssStyle.containerAddress}>{address}</div>
<SelectedBookmarkBanner />
</div>
)
});
const BookmarkSettingsGroup = React.memo((props: { children, className?: string }) => {
return (
<div className={cssStyle.group + " " + props.className}>
{props.children}
</div>
)
});
const BookmarkSetting = React.memo((props: { children: [React.ReactNode, React.ReactNode] }) => {
return (
<div className={cssStyle.row}>
<div className={cssStyle.key}>
{props.children[0]}
</div>
<div className={cssStyle.value}>
{props.children[1]}
</div>
</div>
)
});
const BookmarkSettingConnectProfile = () => {
const selectedBookmark = useContext(SelectedBookmarkIdContext);
const variables = useContext(VariableContext);
const selectedProfile = variables.useVariable("bookmarkConnectProfile", selectedBookmark.id);
const availableProfiles = variables.useReadOnly("connectProfiles");
let value;
const profiles = [];
let invalid = false;
if(selectedBookmark.type !== "bookmark") {
value = "empty";
} else if(availableProfiles.status !== "loaded") {
value = "loading";
} else if(selectedProfile.status === "loading") {
value = "loading";
} else {
value = selectedProfile.localValue;
profiles.push(...availableProfiles.value.map(entry => (
<option key={"profile-" + entry.id} value={entry.id}>{entry.name}</option>
)));
if(availableProfiles.value.findIndex(entry => entry.id === selectedProfile.localValue) === -1) {
invalid = true;
profiles.push(
<option key={"profile-" + selectedProfile.localValue} value={selectedProfile.localValue} style={{ display: "none" }}>{useTr("Unknown profile") + ": " + selectedProfile.localValue}</option>
);
}
}
return (
<ControlledSelect
type={"boxed"}
value={value}
disabled={availableProfiles.status !== "loaded" || selectedProfile.status !== "loaded" || selectedBookmark.type !== "bookmark"}
onChange={event => selectedProfile.setValue(event.target.value)}
invalid={invalid}
>
<option key={"empty"} value={"empty"} style={{ display: "none" }} />
<option key={"loading"} value={"loading"} style={{ display: "none" }}>{useTr("loading")}</option>
<option key={"applying-value"} value={"applying-value"} style={{ display: "none" }}>{useTr("applying")}</option>
{profiles as any}
</ControlledSelect>
);
};
const BookmarkSettingAutoConnect = () => {
const selectedBookmark = useContext(SelectedBookmarkIdContext);
const variables = useContext(VariableContext);
const value = variables.useVariable("bookmarkConnectOnStartup", selectedBookmark.id, false);
return (
<Checkbox
onChange={newValue => value.setValue(newValue)}
value={value.localValue}
disabled={value.status !== "loaded" || selectedBookmark.type !== "bookmark"}
label={<Translatable>Automatically connect to server on client start</Translatable>}
/>
);
};
const BookmarkSettingServerAddress = React.memo(() => {
const selectedBookmark = useContext(SelectedBookmarkIdContext);
const variables = useContext(VariableContext);
const value = variables.useVariable("bookmarkServerAddress", selectedBookmark.id);
return (
<ControlledBoxedInputField
value={value.localValue}
disabled={selectedBookmark.type !== "bookmark" || value.status !== "loaded"}
onChange={newValue => value.setValue(newValue, true)}
onBlur={() => value.setValue(value.localValue)}
finishOnEnter={true}
/>
);
});
const BookmarkSettingPassword = React.memo((props: { field: "bookmarkServerPassword" | "bookmarkDefaultChannelPassword", disabled: boolean }) => {
const selectedBookmark = useContext(SelectedBookmarkIdContext);
const variables = useContext(VariableContext);
const value = variables.useVariable(props.field, selectedBookmark.id);
let placeholder = "", inputValue = "";
if(props.disabled) {
/* disabled, show nothing */
} else if(value.status === "loaded") {
if(value.localValue && value.localValue === value.remoteValue) {
placeholder = tr("password hidden");
} else {
inputValue = value.localValue;
}
} else if(value.status === "applying") {
if(value.localValue) {
placeholder = tr("hashing password");
} else {
/* we've resetted the password. Don't show "hashing password" */
}
}
const disabled = props.disabled || selectedBookmark.type !== "bookmark" || value.status !== "loaded";
return (
<ControlledBoxedInputField
value={inputValue}
placeholder={placeholder}
disabled={disabled}
onChange={newValue => value.setValue(newValue, true)}
onBlur={() => value.setValue(value.localValue)}
rightIcon={() => (
<div className={cssStyle.inputIconContainer + " " + (disabled ? "" : cssStyle.enabled)}>
<div className={cssStyle.iconContainer} onClick={() => !disabled && value.setValue("")}>
<ClientIconRenderer icon={ClientIcon.Refresh} title={useTr("Reset password")} className={cssStyle.icon} />
</div>
</div>
)}
finishOnEnter={true}
/>
);
});
const BookmarkSettingChannel = React.memo(() => {
const selectedBookmark = useContext(SelectedBookmarkIdContext);
const variables = useContext(VariableContext);
const defaultChannel = variables.useVariable("bookmarkDefaultChannel", selectedBookmark.id);
const currentClientChannel = variables.useReadOnly("currentClientChannel", selectedBookmark.id);
const inputDisabled = selectedBookmark.type !== "bookmark" || defaultChannel.status !== "loaded";
const channelSelectDisabled = inputDisabled || currentClientChannel.status !== "loaded" || !currentClientChannel.value;
let selectCurrentTitle;
if(channelSelectDisabled) {
selectCurrentTitle = tr("Select current channel.\nYou're not connected to the target server.");
} else {
selectCurrentTitle = tr("Select current channel") + ":\n" + currentClientChannel.value?.name;
selectCurrentTitle += "\n\n" + tr("Shift click to use the channel name path.");
}
return (
<ControlledBoxedInputField
value={defaultChannel.localValue}
disabled={inputDisabled}
onChange={newValue => defaultChannel.setValue(newValue, true)}
onBlur={() => defaultChannel.setValue(defaultChannel.localValue)}
rightIcon={() => (
<div className={cssStyle.inputIconContainer + " " + (channelSelectDisabled ? "" : cssStyle.enabled)}>
<div
title={selectCurrentTitle}
className={cssStyle.iconContainer}
onClick={event => {
if(currentClientChannel.status !== "loaded") {
return;
}
if(!currentClientChannel.value) {
return;
}
if(event.shiftKey) {
defaultChannel.setValue(currentClientChannel.value.path);
} else {
defaultChannel.setValue("/" + currentClientChannel.value.channelId);
}
variables.setVariable("bookmarkDefaultChannelPassword", selectedBookmark.id, currentClientChannel.value.passwordHash);
}}
>
<ClientIconRenderer icon={ClientIcon.ChannelEdit} className={cssStyle.icon} />
</div>
</div>
)}
finishOnEnter={true}
/>
);
});
const BookmarkSettingChannelPassword = () => {
const selectedBookmark = useContext(SelectedBookmarkIdContext);
const variables = useContext(VariableContext);
const value = variables.useReadOnly("bookmarkDefaultChannel", selectedBookmark.id, undefined);
return <BookmarkSettingPassword field={"bookmarkDefaultChannelPassword"} disabled={!value} />;
}
const BookmarkInfoRenderer = React.memo(() => {
const bookmarkInfo = useContext(SelectedBookmarkInfoContext);
let connectCount = bookmarkInfo ? Math.max(bookmarkInfo.connectCountUniqueId, bookmarkInfo.connectCountAddress) : -1;
return (
<div className={cssStyle.group + " " + cssStyle.connectInfoContainer}>
<div className={cssStyle.containerImage}>
<img src={ServerInfoImage} alt={""} />
</div>
<div className={cssStyle.containerProperties}>
<div className={cssStyle.row}>
<div className={cssStyle.key}>{useTr("Server name")}</div>
<div className={cssStyle.value}>
{bookmarkInfo?.serverName}
</div>
</div>
<div className={cssStyle.row}>
<div className={cssStyle.key}>{useTr("Server region")}</div>
<div className={cssStyle.value}>
<CountryIcon country={bookmarkInfo?.serverRegion} />
</div>
</div>
<div className={cssStyle.row}>
<div className={cssStyle.key}>{useTr("Last ping")}</div>
<div className={cssStyle.value}>
{useTr("Not yet supported")}
</div>
</div>
<div className={cssStyle.row}>
<div className={cssStyle.key}>{useTr("Last client count")}</div>
<div className={cssStyle.value}>
{bookmarkInfo?.clientsOnline} / {bookmarkInfo?.clientsMax}
</div>
</div>
<div className={cssStyle.row}>
<div className={cssStyle.key}>{useTr("Connection count")}</div>
<div className={cssStyle.value + " " + cssStyle.valueConnectCount}>
<div className={cssStyle.text}>{connectCount === -1 ? tr("fetch error") : connectCount}</div>
<IconTooltip className={cssStyle.tooltipIcon}>
<div style={{ width: "20em" }}>
<VariadicTranslatable text={"Connections to the server unique id: {}"}>
{bookmarkInfo?.connectCountUniqueId}
</VariadicTranslatable>
<br />
<VariadicTranslatable text={"Connections to the address: {}"}>
{bookmarkInfo?.connectCountAddress}
</VariadicTranslatable>
</div>
</IconTooltip>
</div>
</div>
</div>
<div className={cssStyle.overlay + " " + (connectCount === -1 ? cssStyle.shown : "")}>
<div className={cssStyle.text}>
<Translatable>You never connected to that server.</Translatable>
</div>
</div>
</div>
);
});
const BookmarkInfoContainerInner = React.memo(() => (
<div className={cssStyle.infoContainer}>
<SelectedBookmarkHeader />
<div className={cssStyle.containerSettings}>
<BookmarkSettingsGroup>
<BookmarkSetting>
<Translatable>Connect profile</Translatable>
<BookmarkSettingConnectProfile />
</BookmarkSetting>
<BookmarkSettingAutoConnect />
</BookmarkSettingsGroup>
<BookmarkSettingsGroup>
<BookmarkSetting>
<Translatable>Server Address</Translatable>
<BookmarkSettingServerAddress />
</BookmarkSetting>
<BookmarkSetting>
<Translatable>Server Password</Translatable>
<BookmarkSettingPassword field={"bookmarkServerPassword"} disabled={false} />
</BookmarkSetting>
<BookmarkSetting>
<Translatable>Default Channel</Translatable>
<BookmarkSettingChannel />
</BookmarkSetting>
<BookmarkSetting>
<Translatable>Channel password</Translatable>
<BookmarkSettingChannelPassword />
</BookmarkSetting>
</BookmarkSettingsGroup>
<BookmarkInfoRenderer />
</div>
</div>
));
const BookmarkInfoContainer = React.memo(() => {
const variables = useContext(VariableContext);
const selectedBookmark = variables.useReadOnly("bookmarkSelected", undefined, { type: "empty", id: undefined });
const selectedBookmarkInfo = variables.useReadOnly("bookmarkInfo", selectedBookmark.id, undefined);
return (
<SelectedBookmarkIdContext.Provider value={selectedBookmark as any}>
<SelectedBookmarkInfoContext.Provider value={selectedBookmarkInfo}>
<BookmarkInfoContainerInner />
</SelectedBookmarkInfoContext.Provider>
</SelectedBookmarkIdContext.Provider>
)
});
class ModalBookmarks extends AbstractModal {
readonly events: Registry<ModalBookmarkEvents>;
readonly variables: UiVariableConsumer<ModalBookmarkVariables>;
constructor(events: IpcRegistryDescription<ModalBookmarkEvents>, variables: IpcVariableDescriptor<ModalBookmarkVariables>) {
super();
this.events = Registry.fromIpcDescription(events);
this.variables = createIpcUiVariableConsumer(variables);
this.events.on("action_create_bookmark", event => {
if(event.displayName) {
return;
}
createInputModal(tr("Please enter a name"), tr("Please enter the bookmark name"), input => input.length > 0, value => {
if(typeof value !== "string" || !value) {
return;
}
this.events.fire("action_create_bookmark", {
entryType: event.entryType,
order: event.order,
displayName: value
});
}).open();
});
this.events.on("action_duplicate_bookmark", event => {
if(event.displayName) {
return;
}
createInputModal(tr("Please enter a name"), tr("Please enter the new bookmark name"), input => input.length > 0, value => {
if(typeof value !== "string" || !value) {
return;
}
this.events.fire("action_duplicate_bookmark", {
displayName: value,
uniqueId: event.uniqueId,
originalName: event.originalName
});
}, {
defaultValue: event.originalName + " (Copy)"
}).open();
});
this.events.on("notify_export_data", event => {
downloadTextAsFile(event.payload, "bookmarks.json");
});
this.events.on("action_import", event => {
if(event.payload) {
return;
}
requestFileAsText().then(payload => {
if(payload.length === 0) {
this.events.fire("notify_import_result", { status: "error", message: tr("File payload is empty") });
return;
}
this.events.fire("action_import", { payload: payload });
});
})
this.events.on("notify_import_result", event => {
switch (event.status) {
case "error":
createErrorModal(tr("Failed to import bookmarks"), tr("Failed to import bookmarks:") + "\n" + event.message).open();
break;
case "success":
createInfoModal(tr("Successfully imported"), formatMessage(tr("Successfully imported {0} bookmarks."), event.importedBookmarks)).open();
break;
}
});
}
protected onDestroy() {
super.onDestroy();
this.events.destroy();
this.variables.destroy();
}
renderBody(): React.ReactElement {
return (
<EventContext.Provider value={this.events}>
<VariableContext.Provider value={this.variables}>
<div className={cssStyle.container}>
<BookmarkListContainer />
<ContextDivider id={"separator-bookmarks"} direction={"horizontal"} defaultValue={25} />
<BookmarkInfoContainer />
</div>
</VariableContext.Provider>
</EventContext.Provider>
);
}
renderTitle(): string | React.ReactElement {
return <Translatable>Manage bookmarks</Translatable>;
}
}
export = ModalBookmarks;

View file

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View file

@ -17,7 +17,7 @@ import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {server_connections} from "tc-shared/ConnectionManager";
import {parseServerAddress} from "tc-shared/tree/Server";
import {spawnSettingsModal} from "tc-shared/ui/modal/ModalSettings";
import * as ipRegex from "ip-regex";
import ipRegex from "ip-regex";
import {UiVariableProvider} from "tc-shared/ui/utils/Variable";
import {createIpcUiVariableProvider} from "tc-shared/ui/utils/IpcVariable";
import {spawnModal} from "tc-shared/ui/react-elements/modal";

View file

@ -311,17 +311,6 @@
}
}
.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;

View file

@ -17,6 +17,7 @@ import {RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon";
import {UiVariableConsumer} from "tc-shared/ui/utils/Variable";
import {createIpcUiVariableConsumer, IpcVariableDescriptor} from "tc-shared/ui/utils/IpcVariable";
import {AbstractModal} from "tc-shared/ui/react-elements/ModalDefinitions";
import {CountryIcon} from "tc-shared/ui/react-elements/CountryIcon";
const EventContext = React.createContext<Registry<ConnectUiEvents>>(undefined);
const VariablesContext = React.createContext<UiVariableConsumer<ConnectUiVariables>>(undefined);
@ -241,15 +242,6 @@ const ButtonContainer = () => (
</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;

View file

@ -9,6 +9,7 @@ import {Checkbox} from "tc-shared/ui/react-elements/Checkbox";
import {Button} from "tc-shared/ui/react-elements/Button";
import {createErrorModal, createInfoModal} from "tc-shared/ui/elements/Modal";
import {AbstractModal} from "tc-shared/ui/react-elements/ModalDefinitions";
import {downloadTextAsFile, requestFileAsText} from "tc-shared/file/Utils";
const cssStyle = require("./Renderer.scss");
@ -358,36 +359,6 @@ const CssVariableEditor = (props: { events: Registry<CssEditorEvents> }) => {
</div>
)
};
const downloadTextAsFile = (text, name) => {
const element = document.createElement("a");
element.text = "download";
element.href = "data:test/plain;charset=utf-8," + encodeURIComponent(text);
element.download = name;
element.style.display = "none";
document.body.appendChild(element);
element.click();
element.remove();
};
const requestFileAsText = async (): Promise<string> => {
const element = document.createElement("input");
element.style.display = "none";
element.type = "file";
document.body.appendChild(element);
element.click();
await new Promise(resolve => {
element.onchange = resolve;
});
if (element.files.length !== 1)
return undefined;
const file = element.files[0];
element.remove();
return await file.text();
};
class PopoutConversationUI extends AbstractModal {
private readonly events: Registry<CssEditorEvents>;

View file

@ -14,7 +14,7 @@ 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';
import moment from 'moment';
const cssStyle = require("./Renderer.scss");

View file

@ -9,7 +9,7 @@ import {network} from "tc-shared/ui/frames/chat";
import {Table, TableColumn, TableRow, TableRowElement} from "tc-shared/ui/react-elements/Table";
import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import * as Moment from "moment";
import moment from "moment";
import {MenuEntryType, spawn_context_menu} from "tc-shared/ui/elements/ContextMenu";
import {BoxedInputField} from "tc-shared/ui/react-elements/InputField";
import {LogCategory, logWarn} from "tc-shared/log";
@ -1043,7 +1043,7 @@ export class FileBrowserRenderer extends ReactComponentBase<FileListTablePropert
"name": () => <FileName path={this.currentPath} file={directory}/>,
"type": () => <a key={"type"}><Translatable>Directory</Translatable></a>,
"change-date": () => directory.datetime ?
<a>{Moment(directory.datetime).format("DD/MM/YYYY HH:mm")}</a> : undefined
<a>{moment(directory.datetime).format("DD/MM/YYYY HH:mm")}</a> : undefined
},
className: cssStyle.directoryEntry,
userData: directory
@ -1057,7 +1057,7 @@ export class FileBrowserRenderer extends ReactComponentBase<FileListTablePropert
"size": () => <FileSize path={this.currentPath} file={file}/>,
"type": () => <a key={"type"}><Translatable>File</Translatable></a>,
"change-date": () => file.datetime ?
<a key={"date"}>{Moment(file.datetime).format("DD/MM/YYYY HH:mm")}</a> : undefined
<a key={"date"}>{moment(file.datetime).format("DD/MM/YYYY HH:mm")}</a> : undefined
},
className: cssStyle.directoryEntry,
userData: file

View file

@ -5,7 +5,7 @@ html:root {
--checkbox-checkmark: #46c0ec;
--checkbox-background: #272626;
--checkbox-disabled-background: #1a1a1e;
--checkbox-disabled-background: #1a1819;
}
.checkbox {
@ -77,6 +77,9 @@ html:root {
label.disabled > .checkbox, .checkbox:disabled, .checkbox.disabled {
&.checkbox, > .checkbox {
pointer-events: none!important;
box-shadow: none;
border: 1px solid var(--boxed-input-field-border);
background-color: var(--checkbox-disabled-background);
}
}

View file

@ -0,0 +1,10 @@
.countryContainer {
display: inline-flex;
flex-direction: row;
justify-content: flex-start;
:global(.country) {
align-self: center;
margin-right: .25em;
}
}

View file

@ -0,0 +1,15 @@
import * as i18n from "tc-shared/i18n/country";
import {joinClassList, useTr} from "tc-shared/ui/react-elements/Helper";
import * as React from "react";
const cssStyle = require("./CountryIcon.scss");
export const CountryIcon = (props: { country: string, className?: string }) => {
const country = props.country || "xx";
return (
<div className={joinClassList(cssStyle.countryContainer, props.className)}>
<div className={"country flag-" + country} />
{i18n.country_name(country, useTr("Global"))}
</div>
)
};

View file

@ -50,7 +50,7 @@ export const ControlledBoxedInputField = (props: {
props.className
}
onFocus={props.onFocus}
onBlur={() => props.onBlur()}
onBlur={props.onBlur}
>
{props.leftIcon ? props.leftIcon() : ""}
{props.prefix ? <a key={"prefix"} className={cssStyle.prefix}>{props.prefix}</a> : undefined}
@ -462,7 +462,8 @@ export const ControlledSelect = (props: {
cssStyle["size-normal"],
props.invalid ? cssStyle.isInvalid : undefined,
props.className,
cssStyle.noLeftIcon, cssStyle.noRightIcon
cssStyle.noLeftIcon, cssStyle.noRightIcon,
props.disabled ? cssStyle.disabled : undefined
)}
>
{!props.label ? undefined :

View file

@ -51,4 +51,9 @@ html:root {
display: flex;
flex-direction: column;
justify-content: center;
img {
max-height: 100%;
max-width: 100%;
}
}

View file

@ -151,8 +151,8 @@ export class Tooltip extends React.Component<TooltipProperties, TooltipState> {
export const IconTooltip = (props: { children?: React.ReactElement | React.ReactElement[], className?: string }) => (
<Tooltip tooltip={() => props.children}>
<div className={cssStyle.tooltip + " " + props.className}>
<img src="img/icon_tooltip.svg"/>
<div className={cssStyle.iconTooltip + " " + props.className}>
<img src="img/icon_tooltip.svg" alt={""} />
</div>
</Tooltip>
);

View file

@ -26,11 +26,22 @@ type UiVariableEditorPromise<Variables extends UiVariableMap, T extends keyof Va
export abstract class UiVariableProvider<Variables extends UiVariableMap> {
private variableProvider: {[key: string]: (customData: any) => any | Promise<any>} = {};
private variableEditor: {[key: string]: (newValue, customData: any) => any | Promise<any>} = {};
private artificialDelay: number;
protected constructor() { }
protected constructor() {
this.artificialDelay = 0;
}
destroy() { }
getArtificialDelay() : number {
return this.artificialDelay;
}
setArtificialDelay(value: number) {
this.artificialDelay = value;
}
setVariableProvider<T extends keyof Variables>(variable: T, provider: (customData: any) => Variables[T] | Promise<Variables[T]>) {
this.variableProvider[variable as any] = provider;
}
@ -60,14 +71,23 @@ export abstract class UiVariableProvider<Variables extends UiVariableMap> {
}
const result = providers(customData);
if(result instanceof Promise) {
return result
.then(result => this.doSendVariable(variable as any, customData, result))
.catch(error => {
console.error(error);
});
const handleResult = result => {
if(result instanceof Promise) {
return result
.then(result => this.doSendVariable(variable as any, customData, result))
.catch(error => {
console.error(error);
});
} else {
this.doSendVariable(variable as any, customData, result);
}
};
if(this.artificialDelay > 0) {
return new Promise(resolve => setTimeout(resolve, this.artificialDelay)).then(() => handleResult(result));
} else {
this.doSendVariable(variable as any, customData, result);
return handleResult(result);
}
}
@ -229,6 +249,14 @@ export abstract class UiVariableConsumer<Variables extends UiVariableMap> {
}
}
setVariable<T extends keyof WriteableVariables<Variables>>(
variable: T,
customData: any,
newValue: Variables[T]
) {
this.doEditVariable(variable as any, customData, newValue);
}
useVariable<T extends keyof WriteableVariables<Variables>>(
variable: T,
customData?: any,

File diff suppressed because one or more lines are too long

View file

@ -7,6 +7,7 @@
"declarationDir": "../../declarations/shared-app/",
"declaration": true,
"emitDeclarationOnly": true,
"esModuleInterop": true,
"baseUrl": "../../",
"paths": {
"tc-shared/*": ["shared/js/*"],

View file

@ -5,6 +5,7 @@
"module": "commonjs",
"sourceMap": true,
"experimentalDecorators": true,
"esModuleInterop": true,
"plugins": [ /* ttypescript */
{
"transform": "../../tools/trgen/ttsc_transformer.js",

View file

@ -8,6 +8,7 @@
"lib": ["ES7", "dom", "dom.iterable"],
"removeComments": true, /* we dont really need them within the target files */
"jsx": "react",
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
"tc-shared/*": ["shared/js/*"],

View file

@ -224,7 +224,11 @@ export const config = async (target: "web" | "client"): Promise<Configuration> =
options: {
esModule: false
}
}
},
{
test: /\.(png|jpg|jpeg|gif)?$/,
loader: 'file-loader',
},
]
} as any,
resolve: {