diff --git a/ChangeLog.md b/ChangeLog.md
index 7756d564..8a653c29 100644
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,12 +1,29 @@
# Changelog:
-* **11.03.20**
+* **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)
+
+* **18.04.20**
+ - Recoded the channel tree using React
+ - Heavily improved channel tree performance on large servers (fluent scroll & updates)
+ - Automatically scroll to channel tree selection
+ - Fixed client speak indicator
+ - Fixed the message unread indicator only shows up after the second message (as well increase visibility)
+ - Fixed the invalid initialisation of codec workers
+ - Improved context menu subcontainer selection
+ - Fixed client channel permission tab within the permission editor (previously you've been kick from the server)
+ - Added the ability to collapse/expend the channel tree
+ - Removed PHP dependencies from the project. PHP isn't needed anymore
+
+* **11.04.20**
- Only show the host message when its not empty
-* **10.03.20**
+* **10.04.20**
- Improved key code displaying
- Added a keymap system (Hotkeys)
-* **09.03.20**
+* **09.04.20**
- Using React for the client control bar
- Saving last away state and message
- Saving last query show state
diff --git a/auth/.gitignore b/auth/.gitignore
deleted file mode 100644
index df00405d..00000000
--- a/auth/.gitignore
+++ /dev/null
@@ -1,11 +0,0 @@
-#Nope :)
-certs/
-
-#A local link just for browsing the files
-xf/
-
-css/**/*.css
-css/**/*.css.map
-
-js/**/*.js
-js/**/*.js.map
\ No newline at end of file
diff --git a/auth/auth.php b/auth/auth.php
deleted file mode 100644
index 1de297bf..00000000
--- a/auth/auth.php
+++ /dev/null
@@ -1,250 +0,0 @@
-service('XF:User\Login', $username, "");
- if (!$loginService->isLoginLimited()) {
- $error = "";
- $user = $loginService->validate($password, $error);
- if ($user) {
- $response["success"] = true;
- $allowed = false;
- foreach ($allowedXFGroups as $id) {
- foreach ($user->secondary_group_ids as $assigned)
- if ($assigned == $id) {
- $allowed = true;
- break;
- }
- $allowed |= $user->user_group_id == $id;
- if ($allowed) break;
- }
- if ($allowed) {
- $response["allowed"] = true;
-
- try {
- /** @var $session XF\Session\Session */
- $session = $app->session();
- if (!$session->exists()) {
- $session->expunge();
- if (!$session->start(remoteAddress())) {
- $response["success"] = false;
- $response["msg"] = "could not create session";
- goto _return;
- }
- }
- $session->changeUser($user);
- $session->save();
- $response["sessionName"] = $session->getCookieName();
- $response["sessionId"] = $session->getSessionId();
- $response["user_name"] = $user->username;
- } catch (Exception $error) {
- $response["success"] = false;
- $response["msg"] = $error->getMessage();
- }
- goto _return;
- } else {
- $response["allowed"] = false;
- }
- } else {
- $response["msg"] = $error;
- }
- } else {
- $response["msg"] = "Too many login's!";
- }
-
- _return:
- return $response;
- }
-
- function logged_in() {
- return test_session() == 0;
- }
-
- function logout()
- {
- $app = getXF();
- if(!$app) return false;
-
- $session = $app->session();
- $session->expunge();
-
- return true;
- }
-
- /**
- * @param null $sessionId
- * @return int 0 = Success | 1 = Invalid coocie | 2 = invalid session
- */
- function test_session($sessionId = null) {
- $app = getXF();
- if(!$app) return -1;
-
- if(!isset($sessionId)) {
- if (!isset($_COOKIE[$app->session()->getCookieName()]))
- return 1;
- $sessionId = $_COOKIE[$app->session()->getCookieName()];
- }
- $app->session()->expunge();
- if (!$app->session()->start(remoteAddress(), $sessionId) || !$app->session()->exists())
- return 2;
- return 0;
- }
-
- function redirectOnInvalidSession() {
- $app = getXF();
- if(!$app) return;
-
- $status = test_session();
- if ($status != 0) {
- $type = "undefined";
- switch ($status) {
- case 1:
- $type = "nocookie";
- break;
- case 2:
- $type = "expired";
- break;
- default:
- $type = "unknown";
- break;
- }
- header('Location: ' . authPath() . 'login.php?error=' . $type);
- setcookie($app->session()->getCookieName(), "", 1);
- die();
- }
- }
-
- function setup_forum_auth() {
- getXF(); /* Initialize XF */
- }
-
- if(!defined("_AUTH_API_ONLY")) {
- $app = getXF();
- if(!$app) {
- die("failed to start app");
- }
-
- if (isset($_GET["type"])) {
- error_log("Got authX request!");
- if ($_GET["type"] == "login") {
- die(json_encode(checkLogin($_POST["user"], $_POST["pass"])));
- } else if ($_GET["type"] == "logout") {
- logout();
- global $localhost;
- if($localhost)
- header("Location: login.php");
- else
- header("Location: https://web.teaspeak.de/");
-
- $session = $app->session();
- setcookie($session->getCookieName(), '', time() - 3600, '/');
- setcookie("session", '', time() - 3600, '/');
- setcookie("user_data", '', time() - 3600, '/');
- setcookie("user_sign", '', time() - 3600, '/');
- } else die("unknown type!");
- } else if(isset($_POST["action"])) {
- error_log("Got auth post request!");
- if($_POST["action"] === "login") {
- die(json_encode(checkLogin($_POST["user"], $_POST["pass"])));
- } else if ($_POST["action"] === "logout") {
- logout();
- die(json_encode([
- "success" => true
- ]));
- } else if($_POST["action"] === "validate") {
- $app = getXF();
- if(test_session($_POST["token"]) === 0)
- die(json_encode([
- "success" => true,
- "token" => $app->session()->getSessionId()
- ]));
- else
- die(json_encode([
- "success" => false
- ]));
- } else
- die(json_encode([
- "success" => false,
- "msg" => "Invalid action"
- ]));
- }
- }
\ No newline at end of file
diff --git a/auth/css/auth.scss b/auth/css/auth.scss
deleted file mode 100644
index 07cd8efb..00000000
--- a/auth/css/auth.scss
+++ /dev/null
@@ -1,80 +0,0 @@
-body{
- padding:0;
- margin:0;
-}
-.inner {
- position: absolute;
-}
-.inner-container{
- width:400px;
- height:400px;
- position:absolute;
- top:calc(50vh - 200px);
- left:calc(50vw - 200px);
- overflow:hidden;
-}
-.box{
- position:absolute;
- height:100%;
- width:100%;
- font-family:Helvetica;
- color:#fff;
- background:rgba(0,0,0,0.13);
- padding:30px 0px;
- text-align: center;
-}
-.box h1{
- text-align:center;
- margin:30px 0;
- font-size:30px;
-}
-.box input{
- display:block;
- width:300px;
- margin:20px auto;
- padding:15px;
- background:rgba(0,0,0,0.2);
- color:#fff;
- border:0;
-}
-.box input:focus,.box input:active,.box button:focus,.box button:active{
- outline:none;
-}
-.box button {
- background:#742ECC;
- border:0;
- color:#fff;
- padding:10px;
- font-size:20px;
- width:330px;
- margin:20px auto;
- display:block;
- cursor:pointer;
-}
-.box button:disabled {
- background:rgba(0,0,0,0.2);
-}
-.box button:active{
- background:#27ae60;
-}
-.box p{
- font-size:14px;
- text-align:center;
-}
-.box p span{
- cursor:pointer;
- color:#666;
-}
-
-.box .error {
- color: darkred;
- display: none;
-}
-
-#login {
- display: block;
-}
-#success {
- margin-top: 50px;
- display: none;
-}
\ No newline at end of file
diff --git a/auth/js/auth.ts b/auth/js/auth.ts
deleted file mode 100644
index 431bcad1..00000000
--- a/auth/js/auth.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-const btn_login = $("#btn_login");
-
-btn_login.on('click', () => {
- btn_login
- .prop("disabled", true)
- .empty()
- .append($(document.createElement("i")).addClass("fa fa-circle-o-notch fa-spin"));
- submitLogin($("#user").val() as string, $("#pass").val() as string);
-});
-
-function submitLogin(user: string, pass: string) {
- $.ajax({
- url: "auth.php?type=login",
- type: "POST",
- cache: false,
- data: {
- user: user,
- pass: pass
- },
- success: (result: string) => {
- setTimeout(() => {
- let data;
- try {
- data = JSON.parse(result);
- } catch (e) {
- loginFailed("Invalid response: " + result);
- return;
- }
- if (data["success"] == false) {
- loginFailed(data["msg"]);
- return;
- }
- if (data["allowed"] == false) {
- loginFailed("You're not allowed for the closed beta!");
- return;
- }
- $("#login").hide(500);
- $("#success").show(500);
-
- document.cookie = data["sessionName"] + "=" + data["sessionId"] + ";path=/";
- document.cookie = data["cookie_name_data"] + "=" + data["user_data"] + ";path=/";
- document.cookie = data["cookie_name_sign"] + "=" + data["user_sign"] + ";path=/";
- console.log(result);
-
- setTimeout(() => {
- window.location.href = btn_login.attr("target");
- }, 1000 + Math.random() % 1500);
- }, 500 + Math.random() % 500);
- },
- error: function (xhr,status,error) {
- loginFailed("Invalid request (" + status + ") => " + error);
- }
- });
-}
-
-function loginFailed(err: string = "") {
- btn_login
- .prop("disabled", false)
- .empty()
- .append($(document.createElement("a")).text("Login"));
-
- let errTag = $(".box .error");
- if(err !== "") {
- errTag.text(err).show(500);
- } else errTag.hide(500);
-}
-
-//
-
-$("#user").on('keydown', event => {
- if(event.key == "Enter") $("#pass").focus();
-});
-
-$("#pass").on('keydown', event => {
- if(event.key == "Enter") $("#btn_login").trigger("click");
-});
\ No newline at end of file
diff --git a/auth/login.php b/auth/login.php
deleted file mode 100644
index 631108d3..00000000
--- a/auth/login.php
+++ /dev/null
@@ -1,37 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/file.ts b/file.ts
index 58ec51ec..59cc6ccb 100644
--- a/file.ts
+++ b/file.ts
@@ -30,9 +30,9 @@ type ProjectResource = {
}
const APP_FILE_LIST_SHARED_SOURCE: ProjectResource[] = [
- { /* shared html and php files */
+ { /* shared html files */
"type": "html",
- "search-pattern": /^.*([a-zA-Z]+)\.(html|php|json)$/,
+ "search-pattern": /^.*([a-zA-Z]+)\.(html|json)$/,
"build-target": "dev|rel",
"path": "./",
@@ -191,7 +191,7 @@ const APP_FILE_LIST_WEB_SOURCE: ProjectResource[] = [
{ /* web html files */
"web-only": true,
"type": "html",
- "search-pattern": /.*\.(php|html)/,
+ "search-pattern": /.*\.(html)/,
"build-target": "dev|rel",
"path": "./",
@@ -208,56 +208,11 @@ const APP_FILE_LIST_WEB_SOURCE: ProjectResource[] = [
}
];
-//TODO: This isn't needed anymore
-const APP_FILE_LIST_WEB_TEASPEAK: ProjectResource[] = [
- /* special web.teaspeak.de only auth files */
- { /* login page and api */
- "web-only": true,
- "type": "html",
- "search-pattern": /[a-zA-Z_0-9]+\.(php|html)$/,
- "build-target": "dev|rel",
-
- "path": "./",
- "local-path": "./auth/",
- "req-parm": ["-xf"]
- },
- { /* javascript */
- "web-only": true,
- "type": "js",
- "search-pattern": /.*\.js$/,
- "build-target": "dev|rel",
-
- "path": "js/",
- "local-path": "./auth/js/",
- "req-parm": ["-xf"]
- },
- { /* web css files */
- "web-only": true,
- "type": "css",
- "search-pattern": /.*\.css$/,
- "build-target": "dev|rel",
-
- "path": "css/",
- "local-path": "./auth/css/",
- "req-parm": ["-xf"]
- },
- { /* certificates */
- "web-only": true,
- "type": "pem",
- "search-pattern": /.*\.pem$/,
- "build-target": "dev|rel",
-
- "path": "certs/",
- "local-path": "./auth/certs/",
- "req-parm": ["-xf"]
- }
-];
-
//FIXME: This isn't working right now
const CERTACCEPT_FILE_LIST: ProjectResource[] = [
{ /* html files */
"type": "html",
- "search-pattern": /^([a-zA-Z]+)\.(html|php|json)$/,
+ "search-pattern": /^([a-zA-Z]+)\.(html|json)$/,
"build-target": "dev|rel",
"path": "./popup/certaccept/",
@@ -355,7 +310,6 @@ const WEB_APP_FILE_LIST = [
...APP_FILE_LIST_SHARED_SOURCE,
...APP_FILE_LIST_SHARED_VENDORS,
...APP_FILE_LIST_WEB_SOURCE,
- ...APP_FILE_LIST_WEB_TEASPEAK,
...CERTACCEPT_FILE_LIST,
];
@@ -501,8 +455,6 @@ namespace server {
import SearchOptions = generator.SearchOptions;
export type Options = {
port: number;
- php: string;
-
search_options: SearchOptions;
}
@@ -510,7 +462,6 @@ namespace server {
let files: ProjectResource[] = [];
let server: http.Server;
- let php: string;
let options: Options;
const use_https = false;
@@ -518,21 +469,6 @@ namespace server {
options = options_;
files = _files;
- try {
- const info = await exec(options.php + " --version");
- if(info.stderr)
- throw info.stderr;
-
- if(!info.stdout.startsWith("PHP 7."))
- throw "invalid php interpreter version (Require at least 7)";
-
- console.debug("Found PHP interpreter:\n%s", info.stdout);
- php = options.php;
- } catch(error) {
- console.error("failed to validate php interpreter: %o", error);
- throw "invalid php interpreter";
- }
-
if(process.env["ssl_enabled"] || use_https) {
//openssl req -nodes -new -x509 -keyout files_key.pem -out files_cert.pem
const key_file = process.env["ssl_key"] || path.join(__dirname, "files_key.pem");
@@ -566,40 +502,6 @@ namespace server {
}
}
- function serve_php(file: string, query: any, response: http.ServerResponse) {
- if(!fs.existsSync("tmp"))
- fs.mkdirSync("tmp");
- let tmp_script_name = path.join("tmp", Math.random().toFixed(32).substr(2));
- let script = " $value) $_GET[$key] = $value;\n";
- script += "chdir(urldecode(\"" + encodeURIComponent(path.dirname(file)) + "\"));";
- script += "?>";
- fs.writeFileSync(tmp_script_name, script, {flag: 'w'});
- exec(php + " -d auto_prepend_file=" + tmp_script_name + " " + file).then(result => {
- if(result.stderr && !result.stdout) {
- response.writeHead(500);
- response.write("Encountered error while interpreting PHP script:\n");
- response.write(result.stderr);
- response.end();
- return;
- }
-
- response.writeHead(200, "success", {
- "Content-Type": "text/html; charset=utf-8"
- });
- response.write(result.stdout);
- response.end();
- }).catch(error => {
- response.writeHead(500);
- response.write("Received an exception while interpreting PHP script:\n");
- response.write(error.toString());
- response.end();
- }).then(() => fs.unlink(tmp_script_name)).catch(error => {
- console.error("[SERVER] Failed to delete tmp PHP prepend file: %o", error);
- });
- }
-
async function serve_file(pathname: string, query: any, response: http.ServerResponse) {
const file = await generator.search_http_file(files, pathname, options.search_options);
if(!file) {
@@ -612,10 +514,6 @@ namespace server {
let type = mt.lookup(path.extname(file)) || "text/html";
console.log("[SERVER] Serving file %s", file, type);
- if(path.extname(file) === ".php") {
- serve_php(file, query, response);
- return;
- }
const fis = fs.createReadStream(file);
response.writeHead(200, "success", {
@@ -634,19 +532,12 @@ namespace server {
response.writeHead(200, { "info-version": 1 });
response.write("type\thash\tpath\tname\n");
for(const file of await generator.search_files(files, options.search_options))
- if(file.name.endsWith(".php"))
- response.write(file.type + "\t" + file.hash + "\t" + path.dirname(file.target_path) + "\t" + path.basename(file.name, ".php") + ".html" + "\n");
- else
- response.write(file.type + "\t" + file.hash + "\t" + path.dirname(file.target_path) + "\t" + file.name + "\n");
+ response.write(file.type + "\t" + file.hash + "\t" + path.dirname(file.target_path) + "\t" + file.name + "\n");
response.end();
return;
} else if(url.query["type"] === "file") {
let p = path.join(url.query["path"] as string, url.query["name"] as string).replace(/\\/g, "/");
if(!p.startsWith("/")) p = "/" + p;
- if(p.endsWith(".html")) {
- const np = await generator.search_http_file(files, p.substr(0, p.length - 5) + ".php", options.search_options);
- if(np) p = p.substr(0, p.length - 5) + ".php";
- }
serve_file(p, url.query, response);
return;
}
@@ -676,7 +567,7 @@ namespace server {
handle_api_request(request, response, url);
return;
} else if(url.pathname === "/") {
- url.pathname = "/index.php";
+ url.pathname = "/index.html";
}
serve_file(url.pathname, url.query, response);
}
@@ -688,7 +579,7 @@ namespace watcher {
return cp.spawn(process.env.comspec, ["/C", cmd, ...args], {
stdio: "pipe",
cwd: __dirname,
- env: process.env
+ env: Object.assign({ NODE_ENV: "development" }, process.env)
});
else
return cp.spawn(cmd, args, {
@@ -835,19 +726,9 @@ namespace watcher {
}
}
}
-
-function php_exe() : string {
- if(process.env["PHP_EXE"])
- return process.env["PHP_EXE"];
- if(os.platform() === "win32")
- return "php.exe";
- return "php";
-}
-
async function main_serve(target: "client" | "web", mode: "rel" | "dev", port: number) {
await server.launch(target === "client" ? CLIENT_APP_FILE_LIST : WEB_APP_FILE_LIST, {
port: port,
- php: php_exe(),
search_options: {
source_path: __dirname,
parameter: [],
@@ -882,7 +763,6 @@ async function main_develop(node: boolean, target: "client" | "web", port: numbe
try {
await server.launch(target === "client" ? CLIENT_APP_FILE_LIST : WEB_APP_FILE_LIST, {
port: port,
- php: php_exe(),
search_options: {
source_path: __dirname,
parameter: [],
@@ -1125,9 +1005,6 @@ async function main(args: string[]) {
console.log(" node files.js list | List all project files");
console.log(" node files.js develop [port] | Start a developer session. All typescript an SASS files will generated automatically");
console.log(" | You could access your current build via http://localhost:8081");
- console.log("");
- console.log("Influential environment variables:");
- console.log(" PHP_EXE | Path to the PHP CLI interpreter");
}
/* proxy log for better format */
diff --git a/loader/app/index.ts b/loader/app/index.ts
index cfcb8bb5..0f9091a9 100644
--- a/loader/app/index.ts
+++ b/loader/app/index.ts
@@ -4,4 +4,6 @@ window["loader"] = loader_base;
/* let the loader register himself at the window first */
setTimeout(loader.run, 0);
-export {};
\ No newline at end of file
+export {};
+
+//window.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject = function () {};
\ No newline at end of file
diff --git a/loader/app/targets/app.ts b/loader/app/targets/app.ts
index 6a1d810d..4df3e0ff 100644
--- a/loader/app/targets/app.ts
+++ b/loader/app/targets/app.ts
@@ -191,7 +191,6 @@ const loader_style = {
"css/static/overlay-image-preview.css",
"css/static/music/info_plate.css",
"css/static/frame/SelectInfo.css",
- "css/static/control_bar.css",
"css/static/context_menu.css",
"css/static/frame-chat.css",
"css/static/connection_handlers.css",
@@ -345,10 +344,6 @@ loader.register_task(loader.Stage.SETUP, {
const container = document.createElement("div");
container.setAttribute('id', "mouse-move");
- const inner_container = document.createElement("div");
- inner_container.classList.add("container");
- container.append(inner_container);
-
body.append(container);
}
/* tooltip container */
diff --git a/package-lock.json b/package-lock.json
index 6118d580..61eb4d30 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -242,6 +242,16 @@
"@types/sizzle": "*"
}
},
+ "@types/loader-utils": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@types/loader-utils/-/loader-utils-1.1.3.tgz",
+ "integrity": "sha512-euKGFr2oCB3ASBwG39CYJMR3N9T0nanVqXdiH7Zu/Nqddt6SmFRxytq/i2w9LQYNQekEtGBz+pE3qG6fQTNvRg==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*",
+ "@types/webpack": "*"
+ }
+ },
"@types/lodash": {
"version": "4.14.149",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz",
@@ -8421,6 +8431,11 @@
"integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=",
"dev": true
},
+ "resize-observer-polyfill": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+ "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
+ },
"resolve": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.15.1.tgz",
diff --git a/package.json b/package.json
index 582c7fc7..5cb9224f 100644
--- a/package.json
+++ b/package.json
@@ -13,12 +13,10 @@
"csso": "csso",
"tsc": "tsc",
"start": "npm run compile-project-base && node file.js ndevelop",
-
"build-web": "webpack --config webpack-web.config.js",
"develop-web": "npm run compile-project-base && node file.js develop web",
"build-client": "webpack --config webpack-client.config.js",
"develop-client": "npm run compile-project-base && node file.js develop client",
-
"webpack-web": "webpack --config webpack-web.config.js",
"webpack-client": "webpack --config webpack-client.config.js",
"generate-i18n-gtranslate": "node shared/generate_i18n_gtranslate.js"
@@ -33,6 +31,7 @@
"@types/fs-extra": "^8.0.1",
"@types/html-minifier": "^3.5.3",
"@types/jquery": "^3.3.34",
+ "@types/loader-utils": "^1.1.3",
"@types/lodash": "^4.14.149",
"@types/moment": "^2.13.0",
"@types/node": "^12.7.2",
@@ -84,6 +83,7 @@
"moment": "^2.24.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
+ "resize-observer-polyfill": "^1.5.1",
"webrtc-adapter": "^7.5.1"
}
}
diff --git a/scripts/travis.sh b/scripts/travis.sh
index 559b55cc..ede2166c 100644
--- a/scripts/travis.sh
+++ b/scripts/travis.sh
@@ -148,11 +148,21 @@ if [[ -e "$LOG_FILE" ]]; then
fi
chmod +x ./web/native-codec/build.sh
-execute \
- "Building native codes" \
- "Failed to build native opus codec" \
- "docker exec -it emscripten bash -c 'web/native-codec/build.sh'"
+if hash emcmake 2>/dev/null; then
+ hash cmake 2>/dev/null || { echo "Missing cmake. Please install cmake before retrying. (apt-get install cmake)"; exit 1; }
+ hash make 2>/dev/null || { echo "Missing make. Please install build-essential before retrying. (apt-get install build-essential)"; exit 1; }
+ echo "Found installation of emcmake locally. Don't use docker in order to build the native parts."
+ execute \
+ "Building native codes" \
+ "Failed to build native opus codec" \
+ "./web/native-codec/build.sh"
+else
+ execute \
+ "Building native codes" \
+ "Failed to build native opus codec" \
+ "docker exec -it emscripten bash -c 'web/native-codec/build.sh'"
+fi
echo "---------- Web client ----------"
function move_target_file() {
diff --git a/setup_windows.md b/setup_devel_environment.md
similarity index 58%
rename from setup_windows.md
rename to setup_devel_environment.md
index 5edf44b2..256bd173 100644
--- a/setup_windows.md
+++ b/setup_devel_environment.md
@@ -2,38 +2,42 @@
## 1.0 Requirements
The following tools or applications are required to develop the web client:
- [1.1 IDE](#11-ide)
-- [1.2 PHP](#12-php)
-- [1.3 NodeJS](#13-nodejs)
-- [1.3.2 NPM](#132-npm)
-- [1.4 Git bash](#14-git-bash)
+- [1.2 NodeJS](#12-nodejs)
+- [1.2.2 NPM](#122-npm)
+- [1.3 Git bash](#13-git-bash)
+- [1.4 Docker](#14-docker)
### 1.1 IDE
It does not matter which IDE you use,
you could even use a command line text editor for developing.
-### 1.2 PHP
-For having a test environment you require an installation of PHP 5 or grater.
-You could just download PHP from [here](https://windows.php.net/download#php-7.4).
-Note:
-`php.exe` must be accessible via the command line.
-This means you'll have to add the `bin` folder to your `PATH` variable.
-
-### 1.3 NodeJS
+### 1.2 NodeJS
For building and serving you require `nodejs` grater than 8.
Nodejs is easily downloadable from [here]().
Ensure you've added `node.exe` to the environment path!
-### 1.3.2 NPM
+### 1.2.2 NPM
Normally NPM already comes with the NodeJS installer.
So you don't really have to worry about it.
NPM min 6.X is required to develop this project.
With NPM you could easily download all required dependencies by just typing `npm install`.
IMPORTANT: NPM must be available within the PATH environment variable!
-### 1.4 Git bash
+### 1.3 Git bash
For using the `.sh` build scripts you require Git Bash.
A minimum of 4.2 is recommend, but in general every modern version should work.
+### 1.4 Docker
+For building the native scripts you need docker.
+Just install docker from [docker.com](https://docs.docker.com).
+Attention: If you're having Windows make sure you're running linux containers!
+
+In order to setup the later required containers, just execute these commands:
+Make sure you're within the web source directory! If not replace the `$(pwd)` the the path the web client is located at
+```shell script
+docker run -dit --name emscripten -v "$(pwd)":"/src/" trzeci/emscripten:sdk-incoming-64bit bash
+```
+
## 2.0 Project initialization
### 2.1 Cloning the WebClient
@@ -50,26 +54,18 @@ git submodule update --init
### 2.2 Setting up native scripts
TeaWeb uses the Opus audio codec. Because almost no browser supports it out of the box
we've to implement our own de/encoder. For this we're using `emscripten` to compile the codec.
-Because this is a quite time consuming task we already offer prebuild javascript and wasm files.
-So we just need to download them. Just execute the `download_compiled_files.sh` shell script within the `asm` folder.
+In order to build the required javascript and wasm files just executing this in your git bash:
```shell script
-./asm/download_compiled_files.sh
+docker exec -it emscripten bash -c 'web/native-codec/build.sh'
```
-
+
### 2.3 Initializing NPM
To download all required packages simply type:
```shell script
npm install
```
-### 2.4 Initial client compilation
-Before you could start ahead with developing you've to compile everything.
-Just execute the `web_build.sh` script:
-```shell script
-./scripts/web_build.sh development
-```
-
-### 2.5 Starting the development environment
+### 2.4 You're ready to go and start developing
To start the development environment which automatically compiles all your changed
scripts and style sheets you simply have to execute:
```shell script
@@ -78,5 +74,13 @@ npm start web
This will also spin up a temporary web server where you could test out your newest changes.
The server will by default listen on `http://localhost:8081`
-### 2.6 You're ready
-Now you're ready to start ahead and implement your own great ideas.
+### 2.5 Using your UI within the TeaClient
+An explanation how this works will come later. Stay tuned!
+
+### 2.6 Generate a release package
+In order to build your own TeaWeb-Package just execute the `scripts/build.sh` script.
+```shell script
+./scripts/build.sh web rel # Build the web client
+./scripts/web_package.sh rel # Bundle the webclient into one .zip archive
+```
+You could also create a debug packaged just by replacing `rel` with `dev`.
diff --git a/shared/css/generate_packed.sh b/shared/css/generate_packed.sh
index 4e2db23f..35e31b98 100644
--- a/shared/css/generate_packed.sh
+++ b/shared/css/generate_packed.sh
@@ -12,7 +12,6 @@ files=(
"css/static/channel-tree.css"
"css/static/connection_handlers.css"
"css/static/context_menu.css"
- "css/static/control_bar.css"
"css/static/frame-chat.css"
"css/static/server-log.css"
"css/static/scroll.css"
diff --git a/shared/css/static/channel-tree.scss b/shared/css/static/channel-tree.scss
index b466548a..5b8ce460 100644
--- a/shared/css/static/channel-tree.scss
+++ b/shared/css/static/channel-tree.scss
@@ -1,314 +1,3 @@
-@import "properties";
-@import "mixin";
-
-.channel-tree-container {
- height: 100%;
-
- flex-grow: 1;
- flex-shrink: 1;
-
- overflow: hidden;
- overflow-y: auto;
-
- @include chat-scrollbar-vertical();
-}
-
-/* the channel tree */
-.channel-tree {
- @include user-select(none);
- width: 100%;
-
- display: -ms-flex;
- display: flex;
-
- flex-direction: column;
-
- * {
- font-family: sans-serif;
- font-size: 12px;
- white-space: pre;
- line-height: 1;
- }
-
- .tree-entry {
- display: flex;
- flex-direction: row;
- justify-content: stretch;
-
- /* margin-left: 16px; */
- min-height: 16px;
-
- flex-grow: 0;
- flex-shrink: 0;
-
- &.server, > .container-channel, &.client {
- padding-left: 5px;
- padding-right: 5px;
-
- &:hover {
- background-color: $channel_tree_entry_hovered;
- }
-
- &.selected {
- background-color: $channel_tree_entry_selected;
- .channel-name {
- color: whitesmoke;
- }
- }
- }
-
- &.server {
- display: flex;
- flex-direction: row;
- justify-content: stretch;
-
- position: relative;
-
- cursor: pointer;
- margin-left: 0;
-
- .server_type {
- flex-grow: 0;
- flex-shrink: 0;
-
- margin-right: 2px;
- }
-
- .name {
- flex-grow: 1;
- flex-shrink: 1;
-
- align-self: center;
- color: $channel_tree_entry_text_color;
-
- min-width: 0;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .icon_property {
- flex-grow: 0;
- flex-shrink: 0;
- }
- }
-
- &.channel {
- display: flex;
- flex-direction: column;
-
- .container-channel {
- position: relative;
-
- display: flex;
- flex-direction: row;
- justify-content: stretch;
-
- width: 100%;
- min-height: 16px;
-
- align-items: center;
- cursor: pointer;
-
- .channel-type {
- flex-grow: 0;
- flex-shrink: 0;
-
- margin-right: 2px;
- }
-
- .container-channel-name {
- display: flex;
- flex-direction: row;
-
- flex-grow: 1;
- flex-shrink: 1;
-
- justify-content: left;
-
- max-width: 100%; /* important for the repetitive channel name! */
- overflow-x: hidden;
- height: 16px;
-
- &.align-right {
- justify-content: right;
- }
-
- &.align-center, &.align-repetitive {
- justify-content: center;
- }
-
- .channel-name {
- align-self: center;
- color: $channel_tree_entry_text_color;
-
- min-width: 0;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- &.align-repetitive {
- .channel-name {
- text-overflow: clip;
- }
- }
- }
-
- .icons {
- display: flex;
- flex-direction: row;
-
- flex-grow: 0;
- flex-shrink: 0;
- }
-
- &.move-selected {
- border-bottom: 1px solid black;
- }
-
- .show-channel-normal-only {
- display: none;
-
- &.channel-normal {
- display: block;
- }
- }
-
- .icon_no_sound {
- display: flex;
- }
- }
-
- .container-clients {
- display: flex;
- flex-direction: column;
- }
- }
-
- &.client {
- cursor: pointer;
-
- position: relative;
-
- display: flex;
- flex-direction: row;
-
- align-items: center;
-
- > div {
- margin-right: 2px;
- }
-
- .client-name {
- line-height: 16px;
-
- flex-grow: 0;
- flex-shrink: 1;
-
- padding-right: .25em;
- color: $channel_tree_entry_text_color;
-
- &.client-name-own {
- font-weight: bold;
- }
- }
-
- .client-away-message {
- color: $channel_tree_entry_text_color;
- }
-
- .container-icons {
- margin-right: 0; /* override from previous thing */
-
- position: absolute;
- right: 0;
- padding-right: 5px;
-
- display: flex;
- flex-direction: row;
-
- align-items: center;
-
- .container-icons-group {
- display: flex;
- flex-direction: row;
-
- .container-group-icon {
- display: flex;
- flex-direction: column;
- justify-content: center;
- }
- }
- }
-
- &.selected {
- &:focus-within {
- .container-icons {
- background-color: $channel_tree_entry_selected;
- padding-left: 5px;
- z-index: 1001; /* show before client name */
-
- height: 18px;
- }
- }
-
- .client-name {
- &:focus {
- position: absolute;
- color: black;
-
- padding-top: 1px;
- padding-bottom: 1px;
-
- z-index: 1000;
-
- margin-right: -10px;
- margin-left: 18px;
-
- width: 100%;
- }
- }
- }
- }
-
- &.channel .container-channel, &.client, &.server {
- .marker-text-unread {
- position: absolute;
- left: 0;
- top: 0;
- bottom: 0;
-
- width: 1px;
- @include background-color(#a814147F);
-
- opacity: 1;
-
- &:before {
- content: '';
- position: absolute;
-
- left: 0;
- top: 0;
- bottom: 0;
-
- width: 24px;
-
- background: -moz-linear-gradient(left, rgba(168,20,20,.18) 0%, rgba(168,20,20,0) 100%); /* FF3.6-15 */
- background: -webkit-linear-gradient(left, rgba(168,20,20,.18) 0%,rgba(168,20,20,0) 100%); /* Chrome10-25,Safari5.1-6 */
- background: linear-gradient(to right, rgba(168,20,20,.18) 0%,rgba(168,20,20,0) 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
- }
-
- &.hidden {
- opacity: 0;
- }
-
- @include transition(opacity $button_hover_animation_time);
- }
- }
- }
-}
-
/* all icons related to basic_icons */
.clicon {
width:16px;
diff --git a/shared/css/static/context_menu.scss b/shared/css/static/context_menu.scss
index 09a90458..7101d343 100644
--- a/shared/css/static/context_menu.scss
+++ b/shared/css/static/context_menu.scss
@@ -70,7 +70,8 @@
}
.sub-container {
- padding-right: 3px;
+ margin-right: -3px;
+ padding-right: 24px;
position: relative;
&:hover {
@@ -85,7 +86,7 @@
left: 100%;
top: -4px;
position: absolute;
- margin-left: 3px;
+ margin-left: 0;
}
}
}
diff --git a/shared/css/static/control_bar.scss b/shared/css/static/control_bar.scss
deleted file mode 100644
index 14a3f36a..00000000
--- a/shared/css/static/control_bar.scss
+++ /dev/null
@@ -1,287 +0,0 @@
-@import "properties";
-@import "mixin";
-
-$border_color_activated: rgba(255, 255, 255, .75);
-
-/* max height is 2em */
-.control_bar {
- display: flex;
- flex-direction: row;
-
- @include user-select(none);
-
- height: 100%;
- align-items: center;
-
- /* tmp fix for ultra small devices */
- overflow-y: visible;
-
- .divider {
- border-left:2px solid #393838;
- height: calc(100% - 3px);
- margin: 3px;
- }
-
- /* border etc */
- .button, .dropdown-arrow {
- text-align: center;
-
- border: .05em solid rgba(0, 0, 0, 0);
- border-radius: $border_radius_small;
-
- background-color: #454545;
-
- &:hover {
- background-color: #393c43;
- border-color: #4a4c55;
- /*box-shadow: 0 12px 16px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19);*/
- }
-
- &.activated {
- background-color: #2f3841;
- border-color: #005fa1;
-
- &:hover {
- background-color: #263340;
- border-color: #005fa1;
- }
-
- &.button-red {
- background-color: #412f2f;
- border-color: #a10000;
-
- &:hover {
- background-color: #402626;
- border-color: #a10000;
- }
- }
- }
-
- @include transition(background-color $button_hover_animation_time ease-in-out, border-color $button_hover_animation_time ease-in-out);
-
- > .icon_x24 {
- vertical-align: middle;
- }
- }
-
- .button {
- cursor: pointer;
- align-items: center;
-
- margin-right: 5px;
- margin-left: 5px;
-
- &:not(.icon_x24) {
- min-width: 2em;
- max-width: 2em;
- height: 2em;
- }
-
- .icon_em {
- vertical-align: text-top;
- }
-
- &.button-hostbutton {
- img {
- min-width: 1.5em;
- max-width: 1.5em;
-
- height: 1.5em;
- width: 1.5em;
- }
-
- overflow: hidden;
- padding: .25em;
- }
- }
-
- .button-dropdown {
- height: 100%;
- position: relative;
-
- .buttons {
- height: 2em;
-
- align-items: center;
-
- display: flex;
- flex-direction: row;
-
- .dropdown-arrow {
- height: 2em;
-
- display: inline-flex;
- justify-content: space-around;
- width: 1.5em;
- cursor: pointer;
-
- border-radius: 0 $border_radius_small $border_radius_small 0;
- align-items: center;
- border-left: 0;
- }
-
- .button {
- margin-right: 0;
- }
-
- &:hover {
- .button, .dropdown-arrow {
- background-color: #393c43;
- border-color: #4a4c55;
- }
-
- .button {
- border-right-color: transparent;
-
- border-bottom-right-radius: 0;
- border-top-right-radius: 0;
- }
- }
- }
-
-
- .dropdown {
- display: none;
- position: absolute;
- margin-left: 5px;
-
- color: #c4c5c5;
-
- background-color: #2d3032;
- align-items: center;
- border: .05em solid #2c2525;
- border-radius: 0 $border_radius_middle $border_radius_middle $border_radius_middle;
-
- width: 230px;
-
- 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);*/
-
- &.right {
- right: 0;
- }
-
- .icon, .icon-container, .icon_em {
- vertical-align: middle;
- margin-right: 5px;
- }
-
- & > div {
- display: block;
- cursor: pointer;
- padding: 1px 2px 1px 4px;
-
- &:hover {
- background-color: #252729;
- }
- }
-
- & > div:first-of-type {
- border-radius: .1em .1em 0 0;
- }
-
- & > div:last-of-type {
- border-radius: 0 0 .1em .1em;
- }
-
- &.display_left {
- margin-left: -179px;
- border-radius: $border_radius_middle 0 $border_radius_middle $border_radius_middle;
- }
- }
-
- &:hover.dropdownDisplayed, &.force-show {
- .dropdown {
- display: block;
- }
-
- .button, .dropdown-arrow {
- background-color: #393c43;
- border-color: #4a4c55;
-
- border-bottom-right-radius: 0;
- border-bottom-left-radius: 0;
- }
-
- .button {
- border-right-color: transparent;
-
- border-bottom-right-radius: 0;
- border-top-right-radius: 0;
- }
- }
-
-
- hr {
- margin-top: 5px;
- margin-bottom: 5px;
- }
- }
-
- .bookmark-dropdown {
- hr:last-child {
- display: none;
- }
-
- .hidden {
- display: none!important;
- }
-
- .disabled {
-
- }
-
- .bookmark, .directory {
- display: flex!important;
- flex-direction: row;
-
- align-items: center;
- justify-content: stretch;
-
- .name {
- flex-grow: 1;
- flex-shrink: 1;
- }
-
- .icon, .arrow {
- flex-grow: 0;
- flex-shrink: 0;
- }
-
- .arrow {
- margin-right: 5px;
- }
- }
-
- .directory {
- &:hover {
- > .sub-container, > .sub-container .sub-menu {
- display: block;
- }
- }
-
- &:not(:hover) {
- .sub-container {
- display: none;
- }
- }
-
- .sub-container {
- padding-right: 3px;
- position: relative;
- }
-
- .sub-menu {
- display: none;
- left: 100%;
- top: -13px;
- position: absolute;
- margin-left: 3px;
- }
- }
- }
-
- .icon_em {
- font-size: 1.5em;
- }
-}
\ No newline at end of file
diff --git a/shared/css/static/frame-chat.scss b/shared/css/static/frame-chat.scss
index 4079787c..7c0bc66d 100644
--- a/shared/css/static/frame-chat.scss
+++ b/shared/css/static/frame-chat.scss
@@ -1155,6 +1155,8 @@ $bot_thumbnail_height: 9em;
overflow-x: hidden;
overflow-y: auto;
+ @include chat-scrollbar-vertical();
+
display: flex;
flex-direction: row;
justify-content: stretch;
diff --git a/shared/css/static/main-layout.scss b/shared/css/static/main-layout.scss
index 3367846f..d98cd429 100644
--- a/shared/css/static/main-layout.scss
+++ b/shared/css/static/main-layout.scss
@@ -74,6 +74,14 @@ $animation_length: .5s;
flex-grow: 1;
flex-shrink: 1;
+
+ .channel-tree-container {
+ height: 100%;
+ flex-grow: 1;
+ flex-shrink: 1;
+ overflow: hidden;
+ overflow-y: auto;
+ }
}
}
@@ -257,22 +265,6 @@ $animation_seperator_length: .1s;
}
}
-#mouse-move {
- display: none;
- position: absolute;
- z-index: 10000;
-
- .container {
- position: relative;
- display: block;
-
- border: 2px solid gray;
- -webkit-border-radius: 2px;
- -moz-border-radius: 2px;
- border-radius: 2px;
- }
-}
-
html, body {
overflow: hidden;
}
diff --git a/shared/css/static/modal-permissions.scss b/shared/css/static/modal-permissions.scss
index 6d8d5ff6..43336af4 100644
--- a/shared/css/static/modal-permissions.scss
+++ b/shared/css/static/modal-permissions.scss
@@ -166,6 +166,7 @@
width: 25%;
min-width: 10em;
min-height: 10em;
+ overflow: hidden;
background-color: #222226;
diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts
index e75e2b87..ad385448 100644
--- a/shared/js/ConnectionHandler.ts
+++ b/shared/js/ConnectionHandler.ts
@@ -279,7 +279,9 @@ export class ConnectionHandler {
}
const original_address = {host: server_address.host, port: server_address.port};
- if(dns.supported() && !server_address.host.match(Regex.IP_V4) && !server_address.host.match(Regex.IP_V6)) {
+ if(server_address.host === "localhost") {
+ server_address.host = "127.0.0.1";
+ } else if(dns.supported() && !server_address.host.match(Regex.IP_V4) && !server_address.host.match(Regex.IP_V6)) {
const id = ++this._connect_initialize_id;
this.log.log(server_log.Type.CONNECTION_HOSTNAME_RESOLVE, {});
try {
@@ -636,6 +638,7 @@ export class ConnectionHandler {
this.serverConnection.disconnect();
this.side_bar.private_conversations().clear_client_ids();
+ this.side_bar.channel_conversations().set_current_channel(0);
this.hostbanner.update();
if(auto_reconnect) {
@@ -657,6 +660,8 @@ export class ConnectionHandler {
this.startConnection(server_address.host + ":" + server_address.port, profile, false, Object.assign(this.reconnect_properties(profile), {auto_reconnect_attempt: true}));
}, 5000);
}
+
+ this.serverConnection.updateConnectionState(ConnectionState.UNCONNECTED); /* Fix for the native client... */
}
cancel_reconnect(log_event: boolean) {
@@ -807,7 +812,6 @@ export class ConnectionHandler {
}
resize_elements() {
- this.channelTree.handle_resized();
this.invoke_resized_on_activate = false;
}
diff --git a/shared/js/FileManager.ts b/shared/js/FileManager.tsx
similarity index 74%
rename from shared/js/FileManager.ts
rename to shared/js/FileManager.tsx
index 3c9897d7..6dada06d 100644
--- a/shared/js/FileManager.ts
+++ b/shared/js/FileManager.tsx
@@ -1,12 +1,14 @@
import * as log from "tc-shared/log";
-import * as hex from "tc-shared/crypto/hex";
import {LogCategory} from "tc-shared/log";
+import * as hex from "tc-shared/crypto/hex";
import {ChannelEntry} from "tc-shared/ui/channel";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {ServerCommand} from "tc-shared/connection/ConnectionBase";
-import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
+import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
import {ClientEntry} from "tc-shared/ui/client";
import {AbstractCommandHandler} from "tc-shared/connection/AbstractCommandHandler";
+import {Registry} from "tc-shared/events";
+import {format_time} from "tc-shared/ui/frames/chat";
export class FileEntry {
name: string;
@@ -130,7 +132,7 @@ export class RequestFileUpload implements UploadTransfer {
throw "invalid size";
form_data.append("file", new Blob([data], { type: "application/octet-stream" }));
} else {
- const buffer = data;
+ const buffer = data as BufferSource;
if(buffer.byteLength != this.transfer_key.total_size)
throw "invalid size";
@@ -416,11 +418,6 @@ export class FileManager extends AbstractCommandHandler {
}
}
-export class Icon {
- id: number;
- url: string;
-}
-
export enum ImageType {
UNKNOWN,
BITMAP,
@@ -552,34 +549,228 @@ export class CacheManager {
}
}
-export class IconManager {
- private static cache: CacheManager = new CacheManager("icons");
+const icon_cache: CacheManager = new CacheManager("icons");
+export interface IconManagerEvents {
+ notify_icon_state_changed: {
+ icon_id: number,
+ server_unique_id: string,
+ icon: LocalIcon
+ },
+}
+
+//TODO: Invalidate icon after certain time if loading has failed and try to redownload (only if an icon loader has been set!)
+type IconLoader = (icon?: LocalIcon) => Promise;
+export class LocalIcon {
+ readonly icon_id: number;
+ readonly server_unique_id: string;
+ readonly status_change_callbacks: ((icon?: LocalIcon) => void)[] = [];
+
+ status: "loading" | "loaded" | "empty" | "error" | "destroyed";
+
+ loaded_url?: string;
+ error_message?: string;
+
+ private callback_icon_loader: IconLoader;
+
+ constructor(id: number, server: string, loader_or_response: Response | IconLoader | undefined) {
+ this.icon_id = id;
+ this.server_unique_id = server;
+
+ if(id >= 0 && id <= 1000) {
+ /* Internal TeaSpeak icons. These must be handled differently! */
+ this.status = "loaded";
+ } else {
+ this.status = "loading";
+ if(loader_or_response instanceof Response) {
+ this.set_image(loader_or_response).catch(error => {
+ log.error(LogCategory.GENERAL, tr("Icon set image method threw an unexpected error: %o"), error);
+ this.status = "error";
+ this.error_message = "unexpected parse error";
+ this.triggerStatusChange();
+ });
+ } else {
+ this.callback_icon_loader = loader_or_response;
+ this.load().catch(error => {
+ log.error(LogCategory.GENERAL, tr("Icon load method threw an unexpected error: %o"), error);
+ this.status = "error";
+ this.error_message = "unexpected load error";
+ this.triggerStatusChange();
+ }).then(() => {
+ this.callback_icon_loader = undefined; /* release resources captured by possible closures */
+ });
+ }
+ }
+ }
+
+ private triggerStatusChange() {
+ for(const lister of this.status_change_callbacks.slice(0))
+ lister(this);
+ }
+
+ /* called within the CachedIconManager */
+ protected destroy() {
+ if(typeof this.loaded_url === "string" && URL.revokeObjectURL)
+ URL.revokeObjectURL(this.loaded_url);
+
+ this.status = "destroyed";
+ this.loaded_url = undefined;
+ this.error_message = undefined;
+
+ this.triggerStatusChange();
+ this.status_change_callbacks.splice(0, this.status_change_callbacks.length);
+ }
+
+ private async load() {
+ if(!icon_cache.setupped())
+ await icon_cache.setup();
+
+ let response = await icon_cache.resolve_cached("icon_" + this.server_unique_id + "_" + this.icon_id); //TODO age!
+ if(!response) {
+ if(typeof this.callback_icon_loader !== "function") {
+ this.status = "empty";
+ this.triggerStatusChange();
+ return;
+ }
+
+ try {
+ response = await this.callback_icon_loader(this);
+ } catch (error) {
+ log.warn(LogCategory.GENERAL, tr("Failed to download icon %d: %o"), this.icon_id, error);
+ await this.set_error(typeof error === "string" ? error : tr("Failed to load icon"));
+ return;
+ }
+ try {
+ await this.set_image(response);
+ } catch (error) {
+ log.error(LogCategory.GENERAL, tr("Failed to update icon image for icon %d: %o"), this.icon_id, error);
+ await this.set_error(typeof error === "string" ? error : tr("Failed to update icon from downloaded file"));
+ return;
+ }
+ return;
+ }
+
+ this.loaded_url = await response_to_url(response);
+ this.status = "loaded";
+ this.triggerStatusChange();
+ }
+
+ async set_image(response: Response) {
+ if(this.icon_id >= 0 && this.icon_id <= 1000) throw "Could not set image for internal icon";
+
+ const type = image_type(response.headers.get('X-media-bytes'));
+ if(type === ImageType.UNKNOWN) throw "unknown image type";
+
+ const media = media_image_type(type);
+ await icon_cache.put_cache("icon_" + this.server_unique_id + "_" + this.icon_id, response.clone(), "image/" + media);
+
+ this.loaded_url = await response_to_url(response);
+ this.status = "loaded";
+ this.triggerStatusChange();
+ }
+
+ set_error(error: string) {
+ if(this.status === "loaded" || this.status === "destroyed") return;
+ if(this.status === "error" && this.error_message === error) return;
+ this.status = "error";
+ this.error_message = error;
+ this.triggerStatusChange();
+ }
+
+ async await_loading() {
+ await new Promise(resolve => {
+ if(this.status !== "loading") {
+ resolve();
+ return;
+ }
+ const callback = () => {
+ if(this.status === "loading") return;
+
+ this.status_change_callbacks.remove(callback);
+ resolve();
+ };
+ this.status_change_callbacks.push(callback);
+ })
+ }
+}
+
+async function response_to_url(response: Response) {
+ if(!response.headers.has('X-media-bytes'))
+ throw "missing media bytes";
+
+ const type = image_type(response.headers.get('X-media-bytes'));
+ const media = media_image_type(type);
+
+ const blob = await response.blob();
+ if(blob.type !== "image/" + media)
+ return URL.createObjectURL(blob.slice(0, blob.size, "image/" + media));
+ else
+ return URL.createObjectURL(blob)
+}
+
+class CachedIconManager {
+ private loaded_icons: {[id: string]:LocalIcon} = {};
+
+ async clear_cache() {
+ await icon_cache.reset();
+ this.clear_memory_cache();
+ }
+
+ clear_memory_cache() {
+ for(const icon_id of Object.keys(this.loaded_icons))
+ this.loaded_icons[icon_id]["destroy"]();
+ this.loaded_icons = {};
+ }
+
+ load_icon(id: number, server_unique_id: string, fallback_load?: IconLoader) : LocalIcon {
+ const cache_id = server_unique_id + "_" + (id >>> 0);
+ if(this.loaded_icons[cache_id]) return this.loaded_icons[cache_id];
+
+ return (this.loaded_icons[cache_id] = new LocalIcon(id >>> 0, server_unique_id, fallback_load));
+ }
+
+ async put_icon(id: number, server_unique_id: string, icon: Response) {
+ const cache_id = server_unique_id + "_" + (id >>> 0);
+ if(this.loaded_icons[cache_id])
+ await this.loaded_icons[cache_id].set_image(icon);
+ else {
+ const licon = this.loaded_icons[cache_id] = new LocalIcon(id >>> 0, server_unique_id, icon);
+ await new Promise((resolve, reject) => {
+ const cb = () => {
+ licon.status_change_callbacks.remove(cb);
+ if(licon.status === "loaded")
+ resolve();
+ else
+ reject(licon.status === "error" ? licon.error_message || tr("Unknown error") : tr("Invalid status"));
+ };
+
+ licon.status_change_callbacks.push(cb);
+ })
+ }
+ }
+}
+export const icon_cache_loader = new CachedIconManager();
+window.addEventListener("beforeunload", () => {
+ icon_cache_loader.clear_memory_cache();
+});
+
+type IconManagerLoadingData = {
+ result: "success" | "error" | "unset";
+ next_retry?: number;
+ error?: string;
+}
+export class IconManager {
handle: FileManager;
- private _id_urls: {[id:number]:string} = {};
- private _loading_promises: {[id:number]:Promise} = {};
+ readonly events: Registry;
+ private loading_timestamps: {[key: number]: IconManagerLoadingData} = {};
constructor(handle: FileManager) {
this.handle = handle;
+ this.events = new Registry();
}
destroy() {
- if(URL.revokeObjectURL) {
- for(const id of Object.keys(this._id_urls))
- URL.revokeObjectURL(this._id_urls[id]);
- }
- this._id_urls = undefined;
- this._loading_promises = undefined;
- }
-
- async clear_cache() {
- await IconManager.cache.reset();
- if(URL.revokeObjectURL) {
- for(const id of Object.keys(this._id_urls))
- URL.revokeObjectURL(this._id_urls[id]);
- }
- this._id_urls = {};
- this._loading_promises = {};
+ this.loading_timestamps = {};
}
async delete_icon(id: number) : Promise {
@@ -599,83 +790,31 @@ export class IconManager {
return this.handle.download_file("", "/icon_" + id);
}
- private static async _response_url(response: Response) {
- if(!response.headers.has('X-media-bytes'))
- throw "missing media bytes";
-
- const type = image_type(response.headers.get('X-media-bytes'));
- const media = media_image_type(type);
-
- const blob = await response.blob();
- if(blob.type !== "image/" + media)
- return URL.createObjectURL(blob.slice(0, blob.size, "image/" + media));
- else
- return URL.createObjectURL(blob)
- }
-
- async resolved_cached?(id: number) : Promise {
- if(this._id_urls[id])
- return {
- id: id,
- url: this._id_urls[id]
- };
-
- if(!IconManager.cache.setupped())
- await IconManager.cache.setup();
-
- const response = await IconManager.cache.resolve_cached('icon_' + id); //TODO age!
- if(response) {
- const url = await IconManager._response_url(response);
- if(this._id_urls[id])
- URL.revokeObjectURL(this._id_urls[id]);
- return {
- id: id,
- url: url
- };
- }
- return undefined;
- }
-
- private static _static_id_url: {[icon: number]:string} = {};
- private static _static_cached_promise: {[icon: number]:Promise} = {};
- static load_cached_icon(id: number, ignore_age?: boolean) : Promise | Icon {
- if(this._static_id_url[id]) {
- return {
- id: id,
- url: this._static_id_url[id]
- };
- }
-
- if(this._static_cached_promise[id])
- return this._static_cached_promise[id];
-
- return (this._static_cached_promise[id] = (async () => {
- if(!this.cache.setupped())
- await this.cache.setup();
-
- const response = await this.cache.resolve_cached('icon_' + id); //TODO age!
- if(response) {
- const url = await this._response_url(response);
- if(this._static_id_url[id])
- URL.revokeObjectURL(this._static_id_url[id]);
- this._static_id_url[id] = url;
-
- return {
- id: id,
- url: url
- };
+ private async server_icon_loader(icon: LocalIcon) : Promise {
+ const loading_data: IconManagerLoadingData = this.loading_timestamps[icon.icon_id] || (this.loading_timestamps[icon.icon_id] = { result: "unset" });
+ if(loading_data.result === "error") {
+ if(!loading_data.next_retry || loading_data.next_retry > Date.now()) {
+ log.debug(LogCategory.GENERAL, tr("Don't retry icon download from server. We'll try again in %s"),
+ !loading_data.next_retry ? tr("never") : format_time(loading_data.next_retry - Date.now(), tr("1 second")));
+ throw loading_data.error;
}
- })());
- }
+ }
- private async _load_icon(id: number) : Promise {
try {
let download_key: DownloadKey;
try {
- download_key = await this.create_icon_download(id);
+ download_key = await this.create_icon_download(icon.icon_id);
} catch(error) {
- log.error(LogCategory.CLIENT, tr("Could not request download for icon %d: %o"), id, error);
- throw "Failed to request icon";
+ if(error instanceof CommandResult) {
+ if(error.id === ErrorID.FILE_NOT_FOUND)
+ throw tr("Icon could not be found");
+ else if(error.id === ErrorID.PERMISSION_ERROR)
+ throw tr("No permissions to download icon");
+ else
+ throw error.extra_message || error.message;
+ }
+ log.error(LogCategory.CLIENT, tr("Could not request download for icon %d: %o"), icon.icon_id, error);
+ throw typeof error === "string" ? error : tr("Failed to initialize icon download");
}
const downloader = spawn_download_transfer(download_key);
@@ -683,58 +822,21 @@ export class IconManager {
try {
response = await downloader.request_file();
} catch(error) {
- log.error(LogCategory.CLIENT, tr("Could not download icon %d: %o"), id, error);
+ log.error(LogCategory.CLIENT, tr("Could not download icon %d: %o"), icon.icon_id, error);
throw "failed to download icon";
}
- const type = image_type(response.headers.get('X-media-bytes'));
- const media = media_image_type(type);
-
- await IconManager.cache.put_cache('icon_' + id, response.clone(), "image/" + media);
- const url = await IconManager._response_url(response.clone());
- if(this._id_urls[id])
- URL.revokeObjectURL(this._id_urls[id]);
- this._id_urls[id] = url;
-
- this._loading_promises[id] = undefined;
- return {
- id: id,
- url: url
- };
- } catch(error) {
- setTimeout(() => {
- this._loading_promises[id] = undefined;
- }, 1000 * 60); /* try again in 60 seconds */
+ loading_data.result = "success";
+ return response;
+ } catch (error) {
+ loading_data.result = "error";
+ loading_data.error = error as string;
+ loading_data.next_retry = Date.now() + 300 * 1000;
throw error;
}
}
- download_icon(id: number) : Promise {
- return this._loading_promises[id] || (this._loading_promises[id] = this._load_icon(id));
- }
-
- async resolve_icon(id: number) : Promise {
- id = id >>> 0;
- try {
- const result = await this.resolved_cached(id);
- if(result)
- return result;
- throw "";
- } catch(error) { }
-
- try {
- const result = await this.download_icon(id);
- if(result)
- return result;
- throw "load result is empty";
- } catch(error) {
- log.error(LogCategory.CLIENT, tr("Icon download failed of icon %d: %o"), id, error);
- }
-
- throw "icon not found";
- }
-
- static generate_tag(icon: Promise | Icon | undefined, options?: {
+ static generate_tag(icon: LocalIcon | undefined, options?: {
animate?: boolean
}) : JQuery {
options = options || {};
@@ -743,42 +845,50 @@ export class IconManager {
let icon_load_image = $.spawn("div").addClass("icon_loading");
const icon_image = $.spawn("img").attr("width", 16).attr("height", 16).attr("alt", "");
- const _apply = (icon) => {
- let id = icon ? (icon.id >>> 0) : 0;
- if (!icon || id == 0) {
- icon_load_image.remove();
- icon_load_image = undefined;
- return;
- } else if (id < 1000) {
- icon_load_image.remove();
- icon_load_image = undefined;
- icon_container.removeClass("icon_empty").addClass("icon_em client-group_" + id);
- return;
- }
-
- icon_image.attr("src", icon.url);
- icon_container.append(icon_image).removeClass("icon_empty");
-
- if (typeof (options.animate) !== "boolean" || options.animate) {
- icon_image.css("opacity", 0);
-
- icon_load_image.animate({opacity: 0}, 50, function () {
- icon_load_image.remove();
- icon_image.animate({opacity: 1}, 150);
- });
- } else {
- icon_load_image.remove();
- icon_load_image = undefined;
- }
- };
- if(icon instanceof Promise) {
- icon.then(_apply).catch(error => {
- log.error(LogCategory.CLIENT, tr("Could not load icon. Reason: %s"), error);
- icon_load_image.removeClass("icon_loading").addClass("icon client-warning").attr("tag", "Could not load icon");
- });
+ if (icon.icon_id == 0) {
+ icon_load_image = undefined;
+ } else if (icon.icon_id < 1000) {
+ icon_load_image = undefined;
+ icon_container.removeClass("icon_empty").addClass("icon_em client-group_" + icon.icon_id);
} else {
- _apply(icon as Icon);
+ const loading_done = sync => {//TODO: Show error?
+ if(icon.status === "empty") {
+ icon_load_image.remove();
+ icon_load_image = undefined;
+ } else if(icon.status === "error") {
+ //TODO: Error icon?
+ icon_load_image.remove();
+ icon_load_image = undefined;
+ } else {
+ icon_image.attr("src", icon.loaded_url);
+ icon_container.append(icon_image).removeClass("icon_empty");
+
+ if (!sync && (typeof (options.animate) !== "boolean" || options.animate)) {
+ icon_image.css("opacity", 0);
+
+ icon_load_image.animate({opacity: 0}, 50, function () {
+ icon_load_image.remove();
+ icon_image.animate({opacity: 1}, 150);
+ });
+ } else {
+ icon_load_image.remove();
+ icon_load_image = undefined;
+ }
+ }
+ };
+
+ if(icon.status !== "loading")
+ loading_done(true);
+ else {
+ const cb = () => {
+ if(icon.status === "loading") return;
+
+ icon.status_change_callbacks.remove(cb);
+ loading_done(false);
+ };
+ icon.status_change_callbacks.push(cb);
+ }
}
if(icon_load_image)
@@ -790,19 +900,20 @@ export class IconManager {
animate?: boolean
}) : JQuery {
options = options || {};
+ return IconManager.generate_tag(this.load_icon(id), options);
+ }
- id = id >>> 0;
- if(id == 0 || !id)
- return IconManager.generate_tag({id: id, url: ""}, options);
- else if(id < 1000)
- return IconManager.generate_tag({id: id, url: ""}, options);
-
-
- if(this._id_urls[id]) {
- return IconManager.generate_tag({id: id, url: this._id_urls[id]}, options);
- } else {
- return IconManager.generate_tag(this.resolve_icon(id), options);
+ load_icon(id: number) : LocalIcon {
+ const server_uid = this.handle.handle.channelTree.server.properties.virtualserver_unique_identifier;
+ let icon = icon_cache_loader.load_icon(id, server_uid, this.server_icon_loader.bind(this));
+ if(icon.status !== "loading" && icon.status !== "loaded") {
+ this.server_icon_loader(icon).then(response => {
+ return icon.set_image(response);
+ }).catch(error => {
+ console.warn("Failed to update broken cached icon from server: %o", error);
+ })
}
+ return icon;
}
}
@@ -818,7 +929,7 @@ export class AvatarManager {
private static cache: CacheManager;
private _cached_avatars: {[response_avatar_id:number]:Avatar} = {};
- private _loading_promises: {[response_avatar_id:number]:Promise} = {};
+ private _loading_promises: {[response_avatar_id:number]:Promise} = {};
constructor(handle: FileManager) {
this.handle = handle;
diff --git a/shared/js/bookmarks.ts b/shared/js/bookmarks.ts
index 9dee583a..8fcf344a 100644
--- a/shared/js/bookmarks.ts
+++ b/shared/js/bookmarks.ts
@@ -68,6 +68,7 @@ export interface Bookmark {
connect_profile: string;
last_icon_id?: number;
+ last_icon_server_id?: string;
}
export interface DirectoryBookmark {
diff --git a/shared/js/connection/CommandHandler.ts b/shared/js/connection/CommandHandler.ts
index a05c2924..9c3d384c 100644
--- a/shared/js/connection/CommandHandler.ts
+++ b/shared/js/connection/CommandHandler.ts
@@ -21,6 +21,7 @@ import {spawnPoke} from "tc-shared/ui/modal/ModalPoke";
import {PrivateConversationState} from "tc-shared/ui/frames/side/private_conversations";
import {Conversation} from "tc-shared/ui/frames/side/conversations";
import {AbstractCommandHandler, AbstractCommandHandlerBoss} from "tc-shared/connection/AbstractCommandHandler";
+import {batch_updates, BatchUpdateType, flush_batched_updates} from "tc-shared/ui/react-elements/ReactComponentBase";
export class ServerConnectionCommandBoss extends AbstractCommandHandlerBoss {
constructor(connection: AbstractServerConnection) {
@@ -137,7 +138,13 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
handle_command(command: ServerCommand) : boolean {
if(this[command.command]) {
- this[command.command](command.arguments);
+ /* batch all updates the command applies to the channel tree */
+ batch_updates(BatchUpdateType.CHANNEL_TREE);
+ try {
+ this[command.command](command.arguments);
+ } finally {
+ flush_batched_updates(BatchUpdateType.CHANNEL_TREE);
+ }
return true;
}
@@ -297,24 +304,25 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
private createChannelFromJson(json, ignoreOrder: boolean = false) {
let tree = this.connection.client.channelTree;
- let channel = new ChannelEntry(parseInt(json["cid"]), json["channel_name"], tree.findChannel(json["cpid"]));
- tree.insertChannel(channel);
+ let channel = new ChannelEntry(parseInt(json["cid"]), json["channel_name"]);
+ let parent, previous;
if(json["channel_order"] !== "0") {
- let prev = tree.findChannel(json["channel_order"]);
- if(!prev && json["channel_order"] != 0) {
+ previous = tree.findChannel(json["channel_order"]);
+ if(!previous && json["channel_order"] != 0) {
if(!ignoreOrder) {
log.error(LogCategory.NETWORKING, tr("Invalid channel order id!"));
return;
}
}
-
- let parent = tree.findChannel(json["cpid"]);
- if(!parent && json["cpid"] != 0) {
- log.error(LogCategory.NETWORKING, tr("Invalid channel parent"));
- return;
- }
- tree.moveChannel(channel, prev, parent); //TODO test if channel exists!
}
+
+ parent = tree.findChannel(json["cpid"]);
+ if(!parent && json["cpid"] != 0) {
+ log.error(LogCategory.NETWORKING, tr("Invalid channel parent"));
+ return;
+ }
+
+ tree.insertChannel(channel, previous, parent);
if(ignoreOrder) {
for(let ch of tree.channels) {
if(ch.properties.channel_order == channel.channelId) {
@@ -340,16 +348,30 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
channel.updateVariables(...updates);
}
+ private batch_update_finished_timeout;
handleCommandChannelList(json) {
- this.connection.client.channelTree.hide_channel_tree(); /* dont perform channel inserts on the dom to prevent style recalculations */
- log.debug(LogCategory.NETWORKING, tr("Got %d new channels"), json.length);
+ if(this.batch_update_finished_timeout) {
+ clearTimeout(this.batch_update_finished_timeout);
+ this.batch_update_finished_timeout = 0;
+ /* batch update is still active */
+ } else {
+ batch_updates(BatchUpdateType.CHANNEL_TREE);
+ }
+
for(let index = 0; index < json.length; index++)
this.createChannelFromJson(json[index], true);
+
+ this.batch_update_finished_timeout = setTimeout(() => {
+ }, 500);
}
handleCommandChannelListFinished(json) {
- this.connection.client.channelTree.show_channel_tree();
+ if(this.batch_update_finished_timeout) {
+ clearTimeout(this.batch_update_finished_timeout);
+ this.batch_update_finished_timeout = 0;
+ flush_batched_updates(BatchUpdateType.CHANNEL_TREE);
+ }
}
handleCommandChannelCreate(json) {
@@ -795,7 +817,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
this.connection_handler.sound.play(Sound.MESSAGE_RECEIVED, {default_volume: .5});
const client = this.connection_handler.channelTree.findClient(parseInt(json["invokerid"]));
if(client) /* the client itself might be invisible */
- client.flag_text_unread = conversation.is_unread();
+ client.setUnread(conversation.is_unread());
} else {
this.connection_handler.sound.play(Sound.MESSAGE_SEND, {default_volume: .5});
}
@@ -822,7 +844,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
message: json["msg"]
});
if(conversation.is_unread() && channel)
- channel.flag_text_unread = true;
+ channel.setUnread(true);
} else if(mode == 3) {
this.connection_handler.log.log(server_log.Type.GLOBAL_MESSAGE, {
message: json["msg"],
@@ -844,7 +866,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
timestamp: typeof(json["timestamp"]) === "undefined" ? Date.now() : parseInt(json["timestamp"]),
message: json["msg"]
});
- this.connection_handler.channelTree.server.flag_text_unread = conversation.is_unread();
+ this.connection_handler.channelTree.server.setUnread(conversation.is_unread());
}
}
@@ -1000,14 +1022,19 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
}
handleNotifyChannelSubscribed(json) {
- for(const entry of json) {
- const channel = this.connection.client.channelTree.findChannel(entry["cid"]);
- if(!channel) {
- console.warn(tr("Received channel subscribed for not visible channel (cid: %d)"), entry['cid']);
- continue;
- }
+ batch_updates(BatchUpdateType.CHANNEL_TREE);
+ try {
+ for(const entry of json) {
+ const channel = this.connection.client.channelTree.findChannel(parseInt(entry["cid"]));
+ if(!channel) {
+ console.warn(tr("Received channel subscribed for not visible channel (cid: %d)"), entry['cid']);
+ continue;
+ }
- channel.flag_subscribed = true;
+ channel.flag_subscribed = true;
+ }
+ } finally {
+ flush_batched_updates(BatchUpdateType.CHANNEL_TREE);
}
}
diff --git a/shared/js/connection/CommandHelper.ts b/shared/js/connection/CommandHelper.ts
index 869a4afa..45571b7d 100644
--- a/shared/js/connection/CommandHelper.ts
+++ b/shared/js/connection/CommandHelper.ts
@@ -262,7 +262,7 @@ export class CommandHelper extends AbstractCommandHandler {
});
}
- request_playlist_songs(playlist_id: number) : Promise {
+ request_playlist_songs(playlist_id: number, process_result?: boolean) : Promise {
let bulked_response = false;
let bulk_index = 0;
@@ -314,7 +314,7 @@ export class CommandHelper extends AbstractCommandHandler {
};
this.handler_boss.register_single_handler(single_handler);
- this.connection.send_command("playlistsonglist", {playlist_id: playlist_id}).catch(error => {
+ this.connection.send_command("playlistsonglist", {playlist_id: playlist_id}, { process_result: process_result }).catch(error => {
this.handler_boss.remove_single_handler(single_handler);
if(error instanceof CommandResult) {
if(error.id == ErrorID.EMPTY_RESULT) {
diff --git a/shared/js/connection/ConnectionBase.ts b/shared/js/connection/ConnectionBase.ts
index b9e6a159..c18b4044 100644
--- a/shared/js/connection/ConnectionBase.ts
+++ b/shared/js/connection/ConnectionBase.ts
@@ -50,6 +50,8 @@ export abstract class AbstractServerConnection {
//FIXME: Remove this this is currently only some kind of hack
updateConnectionState(state: ConnectionState) {
+ if(state === this.connection_state_) return;
+
const old_state = this.connection_state_;
this.connection_state_ = state;
if(this.onconnectionstatechanged)
diff --git a/shared/js/connection/ServerConnectionDeclaration.ts b/shared/js/connection/ServerConnectionDeclaration.ts
index 362e9163..bde4cc03 100644
--- a/shared/js/connection/ServerConnectionDeclaration.ts
+++ b/shared/js/connection/ServerConnectionDeclaration.ts
@@ -9,6 +9,7 @@ export enum ErrorID {
PLAYLIST_IS_IN_USE = 0x2103,
FILE_ALREADY_EXISTS = 2050,
+ FILE_NOT_FOUND = 2051,
CLIENT_INVALID_ID = 0x0200,
diff --git a/shared/js/events.ts b/shared/js/events.ts
index a7abe1b3..8efb7c89 100644
--- a/shared/js/events.ts
+++ b/shared/js/events.ts
@@ -1,5 +1,4 @@
-import {MusicClientEntry, SongInfo} from "tc-shared/ui/client";
-import {PlaylistSong} from "tc-shared/connection/ServerConnectionDeclaration";
+import {ClientEvents, MusicClientEntry, SongInfo} from "tc-shared/ui/client";
import {guid} from "tc-shared/crypto/uid";
import * as React from "react";
@@ -31,7 +30,7 @@ export class Registry {
handlers: {[key: string]: ((event) => void)[]}
}[] = [];
private debug_prefix = undefined;
- private warn_unhandled_events = true;
+ private warn_unhandled_events = false;
constructor() {
this.registry_uuid = "evreg_data_" + guid();
@@ -74,8 +73,8 @@ export class Registry {
}
}
- off(handler: (event?: Event) => void);
- off(event: T, handler: (event?: Event) => void);
+ off(handler: (event?) => void);
+ off(event: T, handler: (event?: Events[T] & Event) => void);
off(event: (keyof Events)[], handler: (event?: Event) => void);
off(handler_or_events, handler?) {
if(typeof handler_or_events === "function") {
@@ -107,36 +106,49 @@ export class Registry {
this.connections[event].remove(target as any);
}
- fire(event_type: T, data?: Events[T]) {
+ fire(event_type: T, data?: Events[T], overrideTypeKey?: boolean) {
if(this.debug_prefix) console.log("[%s] Trigger event: %s", this.debug_prefix, event_type);
- if(typeof data === "object" && 'type' in data) throw tr("The keyword 'type' is reserved for the event type and should not be passed as argument");
+ if(typeof data === "object" && 'type' in data && !overrideTypeKey) {
+ if((data as any).type !== event_type) {
+ debugger;
+ throw tr("The keyword 'type' is reserved for the event type and should not be passed as argument");
+ }
+ }
const event = Object.assign(typeof data === "undefined" ? SingletonEvent.instance : data, {
type: event_type,
as: function () { return this; }
});
+ this.fire_event(event_type as string, event);
+ }
+
+ private fire_event(type: string, data: any) {
let invoke_count = 0;
- for(const handler of (this.handler[event_type as string] || [])) {
- handler(event);
+ for(const handler of (this.handler[type]?.slice(0) || [])) {
+ handler(data);
invoke_count++;
const reg_data = handler[this.registry_uuid];
if(typeof reg_data === "object" && reg_data.singleshot)
- this.handler[event_type as string].remove(handler);
+ this.handler[type].remove(handler);
}
- for(const evhandler of (this.connections[event_type as string] || [])) {
- evhandler.fire(event_type as any, event as any);
+ for(const evhandler of (this.connections[type]?.slice(0) || [])) {
+ evhandler.fire_event(type, data);
invoke_count++;
}
- if(invoke_count === 0) {
- console.warn(tr("Event handler (%s) triggered event %s which has no consumers."), this.debug_prefix, event_type);
+ if(this.warn_unhandled_events && invoke_count === 0) {
+ console.warn(tr("Event handler (%s) triggered event %s which has no consumers."), this.debug_prefix, type);
}
}
- fire_async(event_type: T, data?: Events[T]) {
- setTimeout(() => this.fire(event_type, data));
+ fire_async(event_type: T, data?: Events[T], callback?: () => void) {
+ setTimeout(() => {
+ this.fire(event_type, data);
+ if(typeof callback === "function")
+ callback();
+ });
}
destroy() {
@@ -219,7 +231,11 @@ export function ReactEventHandler, Event
constructor.prototype.componentWillUnmount = function () {
const registry = registry_callback(this);
if(!registry) throw "Event registry returned for an event object is invalid";
- registry.unregister_handler(this);
+ try {
+ registry.unregister_handler(this);
+ } catch (error) {
+ console.warn("Failed to unregister event handler: %o", error);
+ }
if(typeof willUnmount === "function")
willUnmount.call(this, arguments);
@@ -227,31 +243,6 @@ export function ReactEventHandler, Event
}
}
-export namespace channel_tree {
- export interface client {
- "enter_view": {},
- "left_view": {},
-
- "property_update": {
- properties: string[]
- },
-
- "music_status_update": {
- player_buffered_index: number,
- player_replay_index: number
- },
- "music_song_change": {
- "song": SongInfo
- },
-
- /* TODO: Move this out of the music bots interface? */
- "playlist_song_add": { song: PlaylistSong },
- "playlist_song_remove": { song_id: number },
- "playlist_song_reorder": { song_id: number, previous_song_id: number },
- "playlist_song_loaded": { song_id: number, success: boolean, error_msg?: string, metadata?: string },
- }
-}
-
export namespace sidebar {
export interface music {
"open": {}, /* triggers when frame should be shown */
@@ -289,13 +280,13 @@ export namespace sidebar {
"reorder_begin": { song_id: number; entry: JQuery },
"reorder_end": { song_id: number; canceled: boolean; entry: JQuery; previous_entry?: number },
- "player_time_update": channel_tree.client["music_status_update"],
- "player_song_change": channel_tree.client["music_song_change"],
+ "player_time_update": ClientEvents["music_status_update"],
+ "player_song_change": ClientEvents["music_song_change"],
- "playlist_song_add": channel_tree.client["playlist_song_add"] & { insert_effect?: boolean },
- "playlist_song_remove": channel_tree.client["playlist_song_remove"],
- "playlist_song_reorder": channel_tree.client["playlist_song_reorder"],
- "playlist_song_loaded": channel_tree.client["playlist_song_loaded"] & { html_entry?: JQuery },
+ "playlist_song_add": ClientEvents["playlist_song_add"] & { insert_effect?: boolean },
+ "playlist_song_remove": ClientEvents["playlist_song_remove"],
+ "playlist_song_reorder": ClientEvents["playlist_song_reorder"],
+ "playlist_song_loaded": ClientEvents["playlist_song_loaded"] & { html_entry?: JQuery },
}
}
@@ -700,9 +691,11 @@ export namespace modal {
}
//Some test code
-const eclient = new Registry();
+/*
+const eclient = new Registry();
const emusic = new Registry();
eclient.on("property_update", event => { event.as<"playlist_song_loaded">(); });
eclient.connect("playlist_song_loaded", emusic);
-eclient.connect("playlist_song_loaded", emusic);
\ No newline at end of file
+eclient.connect("playlist_song_loaded", emusic);
+ */
\ No newline at end of file
diff --git a/shared/js/main.tsx b/shared/js/main.tsx
index 30895b87..11046d1e 100644
--- a/shared/js/main.tsx
+++ b/shared/js/main.tsx
@@ -29,8 +29,7 @@ import * as React from "react";
import * as ReactDOM from "react-dom";
import * as cbar from "./ui/frames/control-bar";
import * as global_ev_handler from "./events/ClientGlobalControlHandler";
-import {ClientGlobalControlEvents, global_client_actions} from "tc-shared/events/GlobalEvents";
-import {spawnSettingsModal} from "tc-shared/ui/modal/ModalSettings";
+import {global_client_actions} from "tc-shared/events/GlobalEvents";
/* required import for init */
require("./proto").initialize();
diff --git a/shared/js/permission/GroupManager.ts b/shared/js/permission/GroupManager.ts
index 60004fc0..8475a908 100644
--- a/shared/js/permission/GroupManager.ts
+++ b/shared/js/permission/GroupManager.ts
@@ -6,6 +6,7 @@ import {ServerCommand} from "tc-shared/connection/ConnectionBase";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {AbstractCommandHandler} from "tc-shared/connection/AbstractCommandHandler";
+import {Registry} from "tc-shared/events";
export enum GroupType {
QUERY,
@@ -31,10 +32,21 @@ export class GroupPermissionRequest {
promise: LaterPromise;
}
-export class Group {
- properties: GroupProperties = new GroupProperties();
+export interface GroupEvents {
+ notify_deleted: {},
+ notify_properties_updated: {
+ updated_properties: {[Key in keyof GroupProperties]: GroupProperties[Key]};
+ group_properties: GroupProperties
+ }
+}
+
+export class Group {
readonly handle: GroupManager;
+
+ readonly events: Registry;
+ readonly properties: GroupProperties = new GroupProperties();
+
readonly id: number;
readonly target: GroupTarget;
readonly type: GroupType;
@@ -46,6 +58,8 @@ export class Group {
constructor(handle: GroupManager, id: number, target: GroupTarget, type: GroupType, name: string) {
+ this.events = new Registry();
+
this.handle = handle;
this.id = id;
this.target = target;
@@ -53,20 +67,21 @@ export class Group {
this.name = name;
}
- updateProperty(key, value) {
- if(!JSON.map_field_to(this.properties, value, key))
- return; /* no updates */
+ updateProperties(properties: {key: string, value: string}[]) {
+ let updates = {};
- if(key == "iconid") {
- this.properties.iconid = (new Uint32Array([this.properties.iconid]))[0];
- this.handle.handle.channelTree.clientsByGroup(this).forEach(client => {
- client.updateGroupIcon(this);
- });
- } else if(key == "sortid")
- this.handle.handle.channelTree.clientsByGroup(this).forEach(client => {
- client.update_group_icon_order();
- });
+ for(const { key, value } of properties) {
+ if(!JSON.map_field_to(this.properties, value, key))
+ continue; /* no updates */
+ if(key === "iconid")
+ this.properties.iconid = this.properties.iconid >>> 0;
+ updates[key] = this.properties[key];
+ }
+ this.events.fire("notify_properties_updated", {
+ group_properties: this.properties,
+ updated_properties: updates as any
+ });
}
}
@@ -150,44 +165,45 @@ export class GroupManager extends AbstractCommandHandler {
return;
}
- if(target == GroupTarget.SERVER)
- this.serverGroups = [];
- else
- this.channelGroups = [];
+ let group_list = target == GroupTarget.SERVER ? this.serverGroups : this.channelGroups;
+ const deleted_groups = group_list.slice(0);
- for(let groupData of json) {
+ for(const group_data of json) {
let type : GroupType;
- switch (Number.parseInt(groupData["type"])) {
+ switch (parseInt(group_data["type"])) {
case 0: type = GroupType.TEMPLATE; break;
case 1: type = GroupType.NORMAL; break;
case 2: type = GroupType.QUERY; break;
default:
- log.error(LogCategory.CLIENT, tr("Invalid group type: %o for group %s"), groupData["type"],groupData["name"]);
+ log.error(LogCategory.CLIENT, tr("Invalid group type: %o for group %s"), group_data["type"],group_data["name"]);
continue;
}
- let group = new Group(this,parseInt(target == GroupTarget.SERVER ? groupData["sgid"] : groupData["cgid"]), target, type, groupData["name"]);
- for(let key of Object.keys(groupData)) {
- if(key == "sgid") continue;
- if(key == "cgid") continue;
- if(key == "type") continue;
- if(key == "name") continue;
+ const group_id = parseInt(target == GroupTarget.SERVER ? group_data["sgid"] : group_data["cgid"]);
+ let group_index = deleted_groups.findIndex(e => e.id === group_id);
+ let group: Group;
+ if(group_index === -1) {
+ group = new Group(this, group_id, target, type, group_data["name"]);
+ group_list.push(group);
+ } else
+ group = deleted_groups.splice(group_index, 1)[0];
- group.updateProperty(key, groupData[key]);
- }
+ const property_blacklist = [
+ "sgid", "cgid", "type", "name",
- group.requiredMemberRemovePower = parseInt(groupData["n_member_removep"]);
- group.requiredMemberAddPower = parseInt(groupData["n_member_addp"]);
- group.requiredModifyPower = parseInt(groupData["n_modifyp"]);
+ "n_member_removep", "n_member_addp", "n_modifyp"
+ ];
+ group.updateProperties(Object.keys(group_data).filter(e => property_blacklist.findIndex(a => a === e) === -1).map(e => { return { key: e, value: group_data[e] } }));
- if(target == GroupTarget.SERVER)
- this.serverGroups.push(group);
- else
- this.channelGroups.push(group);
+ group.requiredMemberRemovePower = parseInt(group_data["n_member_removep"]);
+ group.requiredMemberAddPower = parseInt(group_data["n_member_addp"]);
+ group.requiredModifyPower = parseInt(group_data["n_modifyp"]);
}
- for(const client of this.handle.channelTree.clients)
- client.update_displayed_client_groups();
+ for(const deleted of deleted_groups) {
+ group_list.remove(deleted);
+ deleted.events.fire("notify_deleted");
+ }
}
request_permissions(group: Group) : Promise { //database_empty_result
diff --git a/shared/js/permission/PermissionManager.ts b/shared/js/permission/PermissionManager.ts
index 657fa282..9a8aa60e 100644
--- a/shared/js/permission/PermissionManager.ts
+++ b/shared/js/permission/PermissionManager.ts
@@ -492,7 +492,8 @@ export class PermissionManager extends AbstractCommandHandler {
requestClientChannelPermissions(client_id: number, channel_id: number) : Promise {
const keys: PermissionRequestKeys = {
- client_id: client_id
+ client_id: client_id,
+ channel_id: channel_id
};
return this.execute_permission_request("requests_client_channel_permissions", keys, this.execute_client_channel_permission_request.bind(this));
}
diff --git a/shared/js/proto.ts b/shared/js/proto.ts
index 130c5279..4621ff41 100644
--- a/shared/js/proto.ts
+++ b/shared/js/proto.ts
@@ -69,7 +69,7 @@ declare global {
readonly webkitAudioContext: typeof AudioContext;
readonly AudioContext: typeof OfflineAudioContext;
readonly OfflineAudioContext: typeof OfflineAudioContext;
- readonly webkitOfflineAudioContext: typeof webkitOfflineAudioContext;
+ readonly webkitOfflineAudioContext: typeof OfflineAudioContext;
readonly RTCPeerConnection: typeof RTCPeerConnection;
readonly Pointer_stringify: any;
readonly jsrender: any;
diff --git a/shared/js/settings.ts b/shared/js/settings.ts
index 56ce9e3d..faaaf7be 100644
--- a/shared/js/settings.ts
+++ b/shared/js/settings.ts
@@ -156,7 +156,8 @@ export class Settings extends StaticSettings {
static readonly KEY_DISABLE_CONTEXT_MENU: SettingsKey = {
key: 'disableContextMenu',
- description: 'Disable the context menu for the channel tree which allows to debug the DOM easier'
+ description: 'Disable the context menu for the channel tree which allows to debug the DOM easier',
+ default_value: false
};
static readonly KEY_DISABLE_GLOBAL_CONTEXT_MENU: SettingsKey = {
@@ -365,6 +366,13 @@ export class Settings extends StaticSettings {
}
};
+ static readonly FN_SERVER_CHANNEL_COLLAPSED: (channel_id: number) => SettingsKey = channel => {
+ return {
+ key: 'channel_collapsed_' + channel,
+ default_value: false
+ }
+ };
+
static readonly FN_PROFILE_RECORD: (name: string) => SettingsKey = name => {
return {
key: 'profile_record' + name
@@ -485,14 +493,15 @@ export class ServerSettings extends SettingsBase {
server?(key: string | SettingsKey, _default?: T) : T {
if(this._destroyed) throw "destroyed";
- return StaticSettings.resolveKey(Settings.keyify(key), _default, key => this.cacheServer[key]);
+ const kkey = Settings.keyify(key);
+ return StaticSettings.resolveKey(kkey, typeof _default === "undefined" ? kkey.default_value : _default, key => this.cacheServer[key]);
}
changeServer(key: string | SettingsKey, value?: T) {
if(this._destroyed) throw "destroyed";
key = Settings.keyify(key);
- if(this.cacheServer[key.key] == value) return;
+ if(this.cacheServer[key.key] === value) return;
this._server_settings_updated = true;
this.cacheServer[key.key] = StaticSettings.transformOtS(value);
diff --git a/shared/js/stats.ts b/shared/js/stats.ts
index f18df9b7..f06bb29e 100644
--- a/shared/js/stats.ts
+++ b/shared/js/stats.ts
@@ -241,4 +241,21 @@ namespace connection {
handler["notifyinitialized"] = handle_notify_initialized;
handler["notifyusercount"] = handle_notify_user_count;
}
+}
+
+
+
+{
+ var X; /* just declare the identifier so we'll not getting a reference error */
+ const A = () => {
+ console.log("Variable X: %o", X);
+ };
+
+ {
+ class X {
+
+ }
+ A();
+ }
+ A();
}
\ No newline at end of file
diff --git a/shared/js/ui/TreeEntry.ts b/shared/js/ui/TreeEntry.ts
new file mode 100644
index 00000000..a5bfb160
--- /dev/null
+++ b/shared/js/ui/TreeEntry.ts
@@ -0,0 +1,39 @@
+import {Registry} from "tc-shared/events";
+
+export interface ChannelTreeEntryEvents {
+ notify_select_state_change: { selected: boolean },
+ notify_unread_state_change: { unread: boolean }
+}
+
+export class ChannelTreeEntry {
+ readonly events: Registry;
+
+ protected selected_: boolean = false;
+ protected unread_: boolean = false;
+
+ /* called from the channel tree */
+ protected onSelect(singleSelect: boolean) {
+ if(this.selected_ === true) return;
+ this.selected_ = true;
+
+ this.events.fire("notify_select_state_change", { selected: true });
+ }
+
+ /* called from the channel tree */
+ protected onUnselect() {
+ if(this.selected_ === false) return;
+ this.selected_ = false;
+
+ this.events.fire("notify_select_state_change", { selected: false });
+ }
+
+ isSelected() { return this.selected_; }
+
+ setUnread(flag: boolean) {
+ if(this.unread_ === flag) return;
+ this.unread_ = flag;
+
+ this.events.fire("notify_unread_state_change", { unread: flag });
+ }
+ isUnread() { return this.unread_; }
+}
\ No newline at end of file
diff --git a/shared/js/ui/channel.ts b/shared/js/ui/channel.ts
index a4ebd318..85a42f89 100644
--- a/shared/js/ui/channel.ts
+++ b/shared/js/ui/channel.ts
@@ -1,5 +1,5 @@
import {ChannelTree} from "tc-shared/ui/view";
-import {ClientEntry} from "tc-shared/ui/client";
+import {ClientEntry, ClientEvents} from "tc-shared/ui/client";
import * as log from "tc-shared/log";
import {LogCategory, LogType} from "tc-shared/log";
import {PermissionType} from "tc-shared/permission/PermissionType";
@@ -15,6 +15,12 @@ import {openChannelInfo} from "tc-shared/ui/modal/ModalChannelInfo";
import {createChannelModal} from "tc-shared/ui/modal/ModalCreateChannel";
import {formatMessage} from "tc-shared/ui/frames/chat";
+import * as React from "react";
+import {Registry} from "tc-shared/events";
+import {ChannelTreeEntry, ChannelTreeEntryEvents} from "tc-shared/ui/TreeEntry";
+import { ChannelEntryView as ChannelEntryView } from "./tree/Channel";
+import {MenuEntryType} from "tc-shared/ui/elements/ContextMenu";
+
export enum ChannelType {
PERMANENT,
SEMI_PERMANENT,
@@ -69,7 +75,82 @@ export class ChannelProperties {
channel_conversation_history_length: number = -1;
}
-export class ChannelEntry {
+export interface ChannelEvents extends ChannelTreeEntryEvents {
+ notify_properties_updated: {
+ updated_properties: {[Key in keyof ChannelProperties]: ChannelProperties[Key]};
+ channel_properties: ChannelProperties
+ },
+
+ notify_cached_password_updated: {
+ reason: "channel-password-changed" | "password-miss-match" | "password-entered";
+ new_hash?: string;
+ },
+
+ notify_subscribe_state_changed: {
+ channel_subscribed: boolean
+ },
+ notify_collapsed_state_changed: {
+ collapsed: boolean
+ },
+
+ notify_children_changed: {},
+ notify_clients_changed: {}, /* will also be fired when clients haven been reordered */
+}
+
+export class ParsedChannelName {
+ readonly original_name: string;
+ alignment: "center" | "right" | "left" | "normal";
+ repetitive: boolean;
+ text: string; /* does not contain any alignment codes */
+
+ constructor(name: string, has_parent_channel: boolean) {
+ this.original_name = name;
+ this.parse(has_parent_channel);
+ }
+
+ private parse(has_parent_channel: boolean) {
+ this.alignment = "normal";
+
+ parse_type:
+ if(!has_parent_channel && this.original_name.charAt(0) == '[') {
+ let end = this.original_name.indexOf(']');
+ if(end === -1) break parse_type;
+
+ let options = this.original_name.substr(1, end - 1);
+ if(options.indexOf("spacer") === -1) break parse_type;
+ options = options.substr(0, options.indexOf("spacer"));
+
+ if(options.length == 0)
+ options = "l";
+ else if(options.length > 1)
+ options = options[0];
+
+ switch (options) {
+ case "r":
+ this.alignment = "right";
+ break;
+ case "l":
+ this.alignment = "center";
+ break;
+ case "c":
+ this.alignment = "center";
+ break;
+ case "*":
+ this.alignment = "center";
+ this.repetitive = true;
+ break;
+ default:
+ break parse_type;
+ }
+
+ this.text = this.original_name.substr(end + 1);
+ }
+ if(!this.text && this.alignment === "normal")
+ this.text = this.original_name;
+ }
+}
+
+export class ChannelEntry extends ChannelTreeEntry {
channelTree: ChannelTree;
channelId: number;
parent?: ChannelEntry;
@@ -77,16 +158,16 @@ export class ChannelEntry {
channel_previous?: ChannelEntry;
channel_next?: ChannelEntry;
+ child_channel_head?: ChannelEntry;
+
+ readonly events: Registry;
+ readonly view: React.RefObject;
+
+ parsed_channel_name: ParsedChannelName;
- private _channel_name_alignment: string = undefined;
- private _channel_name_formatted: string = undefined;
private _family_index: number = 0;
//HTML DOM elements
- private _tag_root: JQuery; /* container for the channel, client and children tag */
- private _tag_siblings: JQuery; /* container for all sub channels */
- private _tag_clients: JQuery; /* container for all clients */
- private _tag_channel: JQuery; /* container for the channel info itself */
private _destroyed = false;
private _cachedPassword: string;
@@ -95,29 +176,37 @@ export class ChannelEntry {
private _cached_channel_description_promise_resolve: any = undefined;
private _cached_channel_description_promise_reject: any = undefined;
+ private _flag_collapsed: boolean;
private _flag_subscribed: boolean;
private _subscribe_mode: ChannelSubscribeMode;
- constructor(channelId, channelName, parent = null) {
+ private client_list: ClientEntry[] = []; /* this list is sorted correctly! */
+ private readonly client_property_listener;
+
+ constructor(channelId, channelName) {
+ super();
+
+ this.events = new Registry();
+ this.view = React.createRef();
+
this.properties = new ChannelProperties();
this.channelId = channelId;
this.properties.channel_name = channelName;
- this.parent = parent;
this.channelTree = null;
- this.initializeTag();
- this.__updateChannelName();
+ this.parsed_channel_name = new ParsedChannelName("undefined", false);
+
+ this.client_property_listener = (event: ClientEvents["notify_properties_updated"]) => {
+ if(typeof event.updated_properties.client_nickname !== "undefined" || typeof event.updated_properties.client_talk_power !== "undefined")
+ this.reorderClientList(true);
+ };
}
destroy() {
this._destroyed = true;
- if(this._tag_root) {
- this._tag_root.remove(); /* removes also all other tags */
- this._tag_root = undefined;
- }
- this._tag_siblings = undefined;
- this._tag_channel = undefined;
- this._tag_clients = undefined;
+
+ this.client_list.forEach(e => this.unregisterClient(e, true));
+ this.client_list = [];
this._cached_channel_description_promise = undefined;
this._cached_channel_description_promise_resolve = undefined;
@@ -134,7 +223,7 @@ export class ChannelEntry {
}
formattedChannelName() {
- return this._channel_name_formatted || this.properties.channel_name;
+ return this.parsed_channel_name.text;
}
getChannelDescription() : Promise {
@@ -151,58 +240,27 @@ export class ChannelEntry {
});
}
- parent_channel() { return this.parent; }
- hasParent(){ return this.parent != null; }
- getChannelId(){ return this.channelId; }
+ registerClient(client: ClientEntry) {
+ client.events.on("notify_properties_updated", this.client_property_listener);
+ this.client_list.push(client);
+ this.reorderClientList(false);
- children(deep = false) : ChannelEntry[] {
- const result: ChannelEntry[] = [];
- if(this.channelTree == null) return [];
-
- const self = this;
- this.channelTree.channels.forEach(function (entry) {
- let current = entry;
- if(deep) {
- while(current) {
- if(current.parent_channel() == self) {
- result.push(entry);
- break;
- }
- current = current.parent_channel();
- }
- } else
- if(current.parent_channel() == self)
- result.push(entry);
- });
- return result;
+ this.events.fire("notify_clients_changed");
}
- clients(deep = false) : ClientEntry[] {
- const result: ClientEntry[] = [];
- if(this.channelTree == null) return [];
+ unregisterClient(client: ClientEntry, no_event?: boolean) {
+ client.events.off("notify_properties_updated", this.client_property_listener);
+ if(!this.client_list.remove(client))
+ log.warn(LogCategory.CHANNEL, tr("Unregistered unknown client from channel %s"), this.channelName());
- const self = this;
- this.channelTree.clients.forEach(function (entry) {
- let current = entry.currentChannel();
- if(deep) {
- while(current) {
- if(current == self) {
- result.push(entry);
- break;
- }
- current = current.parent_channel();
- }
- } else
- if(current == self)
- result.push(entry);
- });
- return result;
+ if(!no_event)
+ this.events.fire("notify_clients_changed");
}
- clients_ordered() : ClientEntry[] {
- const clients = this.clients(false);
+ private reorderClientList(fire_event: boolean) {
+ const original_list = this.client_list.slice(0);
- clients.sort((a, b) => {
+ this.client_list.sort((a, b) => {
if(a.properties.client_talk_power < b.properties.client_talk_power)
return 1;
if(a.properties.client_talk_power > b.properties.client_talk_power)
@@ -215,16 +273,47 @@ export class ChannelEntry {
return 0;
});
- return clients;
+
+ if(fire_event) {
+ /* only fire if really something has changed ;) */
+ for(let index = 0; index < this.client_list.length; index++) {
+ if(this.client_list[index] !== original_list[index]) {
+ this.events.fire("notify_clients_changed");
+ break;
+ }
+ }
+ }
}
- update_family_index(enforce?: boolean) {
- const current_index = this._family_index;
- const new_index = this.calculate_family_index(true);
- if(current_index == new_index && !enforce) return;
+ parent_channel() { return this.parent; }
+ hasParent(){ return this.parent != null; }
+ getChannelId(){ return this.channelId; }
- this._tag_channel.css("z-index", this._family_index);
- this._tag_channel.css("padding-left", ((this._family_index + 1) * 16 + 10) + "px");
+ children(deep = false) : ChannelEntry[] {
+ const result: ChannelEntry[] = [];
+ let head = this.child_channel_head;
+ while(head) {
+ result.push(head);
+ head = head.channel_next;
+ }
+
+ if(deep)
+ return result.map(e => e.children(true)).reduce((prv, now) => { prv.push(...now); return prv; }, []);
+ return result;
+ }
+
+ clients(deep = false) : ClientEntry[] {
+ const result: ClientEntry[] = this.client_list.slice(0);
+ if(!deep) return result;
+
+ return this.children(true).map(e => e.clients(false)).reduce((prev, cur) => {
+ prev.push(...cur);
+ return cur;
+ }, result);
+ }
+
+ clients_ordered() : ClientEntry[] {
+ return this.client_list;
}
calculate_family_index(enforce_recalculate: boolean = false) : number {
@@ -242,235 +331,13 @@ export class ChannelEntry {
return this._family_index;
}
- private initializeTag() {
- const tag_channel = $.spawn("div").addClass("tree-entry channel");
+ protected onSelect(singleSelect: boolean) {
+ super.onSelect(singleSelect);
+ if(!singleSelect) return;
- {
- const container_entry = $.spawn("div").addClass("container-channel");
-
- container_entry.attr("channel-id", this.channelId);
- container_entry.addClass(this._channel_name_alignment);
-
- /* unread marker */
- {
- container_entry.append(
- $.spawn("div")
- .addClass("marker-text-unread hidden")
- .attr("conversation", this.channelId)
- );
- }
-
- /* channel icon (type) */
- {
- container_entry.append(
- $.spawn("div")
- .addClass("show-channel-normal-only channel-type icon client-channel_green_subscribed")
- );
- }
-
- /* channel name */
- {
- container_entry.append(
- $.spawn("div")
- .addClass("container-channel-name")
- .append(
- $.spawn("a")
- .addClass("channel-name")
- .text(this.channelName())
- )
- )
- }
-
- /* all icons (last element) */
- {
- //Icons
- let container_icons = $.spawn("span").addClass("icons");
-
- //Default icon (5)
- container_icons.append(
- $.spawn("div")
- .addClass("show-channel-normal-only icon_entry icon_default icon client-channel_default")
- .attr("title", tr("Default channel"))
- );
-
- //Password icon (4)
- container_icons.append(
- $.spawn("div")
- .addClass("show-channel-normal-only icon_entry icon_password icon client-register")
- .attr("title", tr("The channel is password protected"))
- );
-
- //Music icon (3)
- container_icons.append(
- $.spawn("div")
- .addClass("show-channel-normal-only icon_entry icon_music icon client-music")
- .attr("title", tr("Music quality"))
- );
-
- //Channel moderated (2)
- container_icons.append(
- $.spawn("div")
- .addClass("show-channel-normal-only icon_entry icon_moderated icon client-moderated")
- .attr("title", tr("Channel is moderated"))
- );
-
- //Channel Icon (1)
- container_icons.append(
- $.spawn("div")
- .addClass("show-channel-normal-only icon_entry channel_icon")
- .attr("title", tr("Channel icon"))
- );
-
- //Default no sound (0)
- let container = $.spawn("div")
- .css("position", "relative")
- .addClass("icon_no_sound");
-
- let noSound = $.spawn("div")
- .addClass("icon_entry icon client-conflict-icon")
- .attr("title", "You don't support the channel codec");
-
- let bg = $.spawn("div")
- .width(10)
- .height(14)
- .css("background", "red")
- .css("position", "absolute")
- .css("top", "1px")
- .css("left", "3px")
- .css("z-index", "-1");
- bg.appendTo(container);
- noSound.appendTo(container);
- container_icons.append(container);
-
- container_icons.appendTo(container_entry);
- }
-
- tag_channel.append(this._tag_channel = container_entry);
- this.update_family_index(true);
- }
- {
- const container_client = $.spawn("div").addClass("container-clients");
-
-
- tag_channel.append(this._tag_clients = container_client);
- }
- {
- const container_children = $.spawn("div").addClass("container-children");
-
-
- tag_channel.append(this._tag_siblings = container_children);
- }
-
- /*
- setInterval(() => {
- let color = (Math.random() * 10000000).toString(16).substr(0, 6);
- tag_channel.css("background", "#" + color);
- }, 150);
- */
-
- this._tag_root = tag_channel;
- }
-
- rootTag() : JQuery {
- return this._tag_root;
- }
-
- channelTag() : JQuery {
- return this._tag_channel;
- }
-
- siblingTag() : JQuery {
- return this._tag_siblings;
- }
- clientTag() : JQuery{
- return this._tag_clients;
- }
-
- private _reorder_timer: number;
- reorderClients(sync?: boolean) {
- if(this._reorder_timer) {
- if(!sync) return;
- clearTimeout(this._reorder_timer);
- this._reorder_timer = undefined;
- } else if(!sync) {
- this._reorder_timer = setTimeout(() => {
- this._reorder_timer = undefined;
- this.reorderClients(true);
- }, 5) as any;
- return;
- }
-
- let clients = this.clients();
-
- if(clients.length > 1) {
- clients.sort((a, b) => {
- if(a.properties.client_talk_power < b.properties.client_talk_power)
- return 1;
- if(a.properties.client_talk_power > b.properties.client_talk_power)
- return -1;
-
- if(a.properties.client_nickname > b.properties.client_nickname)
- return 1;
- if(a.properties.client_nickname < b.properties.client_nickname)
- return -1;
-
- return 0;
- });
- clients.reverse();
-
- for(let index = 0; index + 1 < clients.length; index++)
- clients[index].tag.before(clients[index + 1].tag);
-
- log.debug(LogCategory.CHANNEL, tr("Reordered channel clients: %d"), clients.length);
- for(let client of clients) {
- log.debug(LogCategory.CHANNEL, "- %i %s", client.properties.client_talk_power, client.properties.client_nickname);
- }
- }
- }
-
- initializeListener() {
- const tag_channel = this.channelTag();
- tag_channel.on('click', () => this.channelTree.onSelect(this));
- tag_channel.on('dblclick', () => {
- if($.isArray(this.channelTree.currently_selected)) { //Multiselect
- return;
- }
- this.joinChannel()
- });
-
- let last_touch: number = 0;
- let touch_start: number = 0;
- tag_channel.on('touchend', event => {
- /* if over 250ms then its not a click its more a drag */
- if(Date.now() - touch_start > 250) {
- touch_start = 0;
- return;
- }
- if(Date.now() - last_touch > 750) {
- last_touch = Date.now();
- return;
- }
- last_touch = Date.now();
- /* double touch */
- tag_channel.trigger('dblclick');
- });
- tag_channel.on('touchstart', event => {
- touch_start = Date.now();
- });
-
- if(!settings.static(Settings.KEY_DISABLE_CONTEXT_MENU, false)) {
- this.channelTag().on("contextmenu", (event) => {
- event.preventDefault();
- if($.isArray(this.channelTree.currently_selected)) { //Multiselect
- (this.channelTree.currently_selected_context_callback || ((_) => null))(event);
- return;
- }
-
- this.channelTree.onSelect(this, true);
- this.showContextMenu(event.pageX, event.pageY, () => {
- this.channelTree.onSelect(undefined, true);
- });
- });
+ if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) {
+ this.channelTree.client.side_bar.channel_conversations().set_current_channel(this.channelId);
+ this.channelTree.client.side_bar.show_channel_conversations();
}
}
@@ -516,6 +383,7 @@ export class ChannelEntry {
let trigger_close = true;
+ const collapse_expendable = !!this.child_channel_head || this.client_list.length > 0;
const bold = text => contextmenu.get_provider().html_format_enabled() ? "" + text + "" : text;
contextmenu.spawn_context_menu(x, y, {
type: contextmenu.MenuEntryType.ENTRY,
@@ -579,7 +447,7 @@ export class ChannelEntry {
name: tr("Edit channel"),
invalidPermission: !channelModify,
callback: () => {
- createChannelModal(this.channelTree.client, this, undefined, this.channelTree.client.permissions, (changes?, permissions?) => {
+ createChannelModal(this.channelTree.client, this, this.parent, this.channelTree.client.permissions, (changes?, permissions?) => {
if(changes) {
changes["cid"] = this.channelId;
this.channelTree.client.serverConnection.send_command("channeledit", changes);
@@ -635,6 +503,25 @@ export class ChannelEntry {
});
}
},
+ {
+ type: MenuEntryType.HR,
+ name: "",
+ visible: collapse_expendable
+ },
+ {
+ type: contextmenu.MenuEntryType.ENTRY,
+ icon_class: "client-channel_collapse_all",
+ name: tr("Collapse sub channels"),
+ visible: collapse_expendable,
+ callback: () => this.channelTree.collapse_channels(this)
+ },
+ {
+ type: contextmenu.MenuEntryType.ENTRY,
+ icon_class: "client-channel_expand_all",
+ name: tr("Expend sub channels"),
+ visible: collapse_expendable,
+ callback: () => this.channelTree.expand_channels(this)
+ },
contextmenu.Entry.HR(),
{
type: contextmenu.MenuEntryType.ENTRY,
@@ -649,80 +536,12 @@ export class ChannelEntry {
invalidPermission: !channelCreate,
callback: () => this.channelTree.spawnCreateChannel()
},
- contextmenu.Entry.CLOSE(() => trigger_close ? on_close() : {})
+ contextmenu.Entry.CLOSE(() => trigger_close && on_close ? on_close() : {})
);
}
- handle_frame_resized() {
- if(this._channel_name_formatted === "align-repetitive")
- this.__updateChannelName();
- }
-
- private static NAME_ALIGNMENTS: string[] = ["align-left", "align-center", "align-right", "align-repetitive"];
- private __updateChannelName() {
- this._channel_name_formatted = undefined;
-
- parse_type:
- if(this.parent_channel() == null && this.properties.channel_name.charAt(0) == '[') {
- let end = this.properties.channel_name.indexOf(']');
- if(end == -1) break parse_type;
-
- let options = this.properties.channel_name.substr(1, end - 1);
- if(options.indexOf("spacer") == -1) break parse_type;
- options = options.substr(0, options.indexOf("spacer"));
-
- if(options.length == 0)
- options = "l";
- else if(options.length > 1)
- options = options[0];
-
- switch (options) {
- case "r":
- this._channel_name_alignment = "align-right";
- break;
- case "l":
- this._channel_name_alignment = "align-left";
- break;
- case "c":
- this._channel_name_alignment = "align-center";
- break;
- case "*":
- this._channel_name_alignment = "align-repetitive";
- break;
- default:
- this._channel_name_alignment = undefined;
- break parse_type;
- }
-
- this._channel_name_formatted = this.properties.channel_name.substr(end + 1) || "";
- }
-
- this._tag_channel.find(".show-channel-normal-only").toggleClass("channel-normal", this._channel_name_formatted === undefined);
-
- const tag_container_name = this._tag_channel.find(".container-channel-name");
- tag_container_name.removeClass(ChannelEntry.NAME_ALIGNMENTS.join(" "));
-
- const tag_name = tag_container_name.find(".channel-name");
- let text = this._channel_name_formatted === undefined ? this.properties.channel_name : this._channel_name_formatted;
-
- if(this._channel_name_formatted !== undefined) {
- tag_container_name.addClass(this._channel_name_alignment);
-
- if(this._channel_name_alignment == "align-repetitive" && text.length > 0) {
- while(text.length < 1024 * 8)
- text += text;
- }
- }
-
- tag_name.text(text);
- }
-
- recalculate_repetitive_name() {
- if(this._channel_name_alignment == "align-repetitive")
- this.__updateChannelName();
- }
-
updateVariables(...variables: {key: string, value: string}[]) {
+ /* devel-block(log-channel-property-updates) */
let group = log.group(log.LogType.DEBUG, LogCategory.CHANNEL_PROPERTIES, tr("Update properties (%i) of %s (%i)"), variables.length, this.channelName(), this.getChannelId());
{
@@ -735,6 +554,7 @@ export class ChannelEntry {
});
log.table(LogType.DEBUG, LogCategory.PERMISSIONS, "Clannel update properties", entries);
}
+ /* devel-block-end */
let info_update = false;
for(let variable of variables) {
@@ -743,36 +563,14 @@ export class ChannelEntry {
JSON.map_field_to(this.properties, value, variable.key);
if(key == "channel_name") {
- this.__updateChannelName();
+ this.parsed_channel_name = new ParsedChannelName(value, this.hasParent());
info_update = true;
} else if(key == "channel_order") {
let order = this.channelTree.findChannel(this.properties.channel_order);
this.channelTree.moveChannel(this, order, this.parent);
- } else if(key == "channel_icon_id") {
- /* For more detail lookup client::updateVariables and client_icon_id!
- * ATTENTION: This is required!
- */
- this.properties.channel_icon_id = variable.value as any >>> 0;
-
- let tag = this.channelTag().find(".icons .channel_icon");
- (this.properties.channel_icon_id > 0 ? $.fn.show : $.fn.hide).apply(tag);
- if(this.properties.channel_icon_id > 0) {
- tag.children().detach();
- this.channelTree.client.fileManager.icons.generateTag(this.properties.channel_icon_id).appendTo(tag);
- }
- info_update = true;
- } else if(key == "channel_codec") {
- (this.properties.channel_codec == 5 || this.properties.channel_codec == 3 ? $.fn.show : $.fn.hide).apply(this.channelTag().find(".icons .icon_music"));
- this.channelTag().find(".icons .icon_no_sound").toggle(!(
- this.channelTree.client.serverConnection.support_voice() &&
- this.channelTree.client.serverConnection.voice_connection().decoding_supported(this.properties.channel_codec)
- ));
- } else if(key == "channel_flag_default") {
- (this.properties.channel_flag_default ? $.fn.show : $.fn.hide).apply(this.channelTag().find(".icons .icon_default"));
- } else if(key == "channel_flag_password")
- (this.properties.channel_flag_password ? $.fn.show : $.fn.hide).apply(this.channelTag().find(".icons .icon_password"));
- else if(key == "channel_needed_talk_power")
- (this.properties.channel_needed_talk_power > 0 ? $.fn.show : $.fn.hide).apply(this.channelTag().find(".icons .icon_moderated"));
+ } else if(key === "channel_icon_id") {
+ this.properties.channel_icon_id = variable.value as any >>> 0; /* unsigned 32 bit number! */
+ }
else if(key == "channel_description") {
this._cached_channel_description = undefined;
if(this._cached_channel_description_promise_resolve)
@@ -781,10 +579,6 @@ export class ChannelEntry {
this._cached_channel_description_promise_resolve = undefined;
this._cached_channel_description_promise_reject = undefined;
}
- if(key == "channel_maxclients" || key == "channel_maxfamilyclients" || key == "channel_flag_private" || key == "channel_flag_password") {
- this.updateChannelTypeIcon();
- info_update = true;
- }
if(key == "channel_flag_conversation_private") {
const conversations = this.channelTree.client.side_bar.channel_conversations();
const conversation = conversations.conversation(this.channelId, false);
@@ -792,7 +586,15 @@ export class ChannelEntry {
conversation.set_flag_private(this.properties.channel_flag_conversation_private);
}
}
+ /* devel-block(log-channel-property-updates) */
group.end();
+ /* devel-block-end */
+ {
+ let properties = {};
+ for(const property of variables)
+ properties[property.key] = this.properties[property.key];
+ this.events.fire("notify_properties_updated", { updated_properties: properties as any, channel_properties: this.properties });
+ }
if(info_update) {
const _client = this.channelTree.client.getClient();
@@ -802,28 +604,6 @@ export class ChannelEntry {
}
}
- updateChannelTypeIcon() {
- let tag = this.channelTag().find(".channel-type");
- tag.removeAttr('class');
- tag.addClass("show-channel-normal-only channel-type icon");
-
- if(this._channel_name_formatted === undefined)
- tag.addClass("channel-normal");
-
- let type;
- if(this.properties.channel_flag_password == true && !this._cachedPassword)
- type = "yellow";
- else if(
- (!this.properties.channel_flag_maxclients_unlimited && this.clients().length >= this.properties.channel_maxclients) ||
- (!this.properties.channel_flag_maxfamilyclients_unlimited && this.properties.channel_maxfamilyclients >= 0 && this.clients(true).length >= this.properties.channel_maxfamilyclients)
- )
- type = "red";
- else
- type = "green";
-
- tag.addClass("client-channel_" + type + (this._flag_subscribed ? "_subscribed" : ""));
- }
-
generate_bbcode() {
return "[url=channel://" + this.channelId + "/" + encodeURIComponent(this.properties.channel_name) + "]" + this.formattedChannelName() + "[/url]";
}
@@ -847,11 +627,12 @@ export class ChannelEntry {
!this._cachedPassword &&
!this.channelTree.client.permissions.neededPermission(PermissionType.B_CHANNEL_JOIN_IGNORE_PASSWORD).granted(1)) {
createInputModal(tr("Channel password"), tr("Channel password:"), () => true, text => {
- if(typeof(text) == typeof(true)) return;
- hashPassword(text as string).then(result => {
+ if(typeof(text) !== "string") return;
+
+ hashPassword(text).then(result => {
this._cachedPassword = result;
+ this.events.fire("notify_cached_password_updated", { reason: "password-entered", new_hash: result });
this.joinChannel();
- this.updateChannelTypeIcon();
});
}).open();
} else if(this.channelTree.client.getClient().currentChannel() != this)
@@ -861,7 +642,7 @@ export class ChannelEntry {
if(error instanceof CommandResult) {
if(error.id == 781) { //Invalid password
this._cachedPassword = undefined;
- this.updateChannelTypeIcon();
+ this.events.fire("notify_cached_password_updated", { reason: "password-miss-match" });
}
}
});
@@ -890,7 +671,7 @@ export class ChannelEntry {
if(inherited_subscription_mode) {
this.subscribe_mode = ChannelSubscribeMode.INHERITED;
- unsubscribe = this.flag_subscribed && !this.channelTree.client.client_status.channel_subscribe_all;
+ unsubscribe = this.flag_subscribed && !this.channelTree.client.isSubscribeToAllChannels();
} else {
this.subscribe_mode = ChannelSubscribeMode.UNSUBSCRIBED;
unsubscribe = this.flag_subscribed;
@@ -909,6 +690,21 @@ export class ChannelEntry {
}
}
+ get collapsed() : boolean {
+ if(typeof this._flag_collapsed === "undefined")
+ this._flag_collapsed = this.channelTree.client.settings.server(Settings.FN_SERVER_CHANNEL_COLLAPSED(this.channelId));
+ return this._flag_collapsed;
+ }
+
+ set collapsed(flag: boolean) {
+ if(this._flag_collapsed === flag)
+ return;
+ this._flag_collapsed = flag;
+ this.events.fire("notify_collapsed_state_changed", { collapsed: flag });
+ this.view.current?.forceUpdate();
+ this.channelTree.client.settings.changeServer(Settings.FN_SERVER_CHANNEL_COLLAPSED(this.channelId), flag);
+ }
+
get flag_subscribed() : boolean {
return this._flag_subscribed;
}
@@ -918,7 +714,7 @@ export class ChannelEntry {
return;
this._flag_subscribed = flag;
- this.updateChannelTypeIcon();
+ this.events.fire("notify_subscribe_state_changed", { channel_subscribed: flag });
}
get subscribe_mode() : ChannelSubscribeMode {
@@ -933,10 +729,6 @@ export class ChannelEntry {
this.channelTree.client.settings.changeServer(Settings.FN_SERVER_CHANNEL_SUBSCRIBE_MODE(this.channelId), mode);
}
- set flag_text_unread(flag: boolean) {
- this._tag_channel.find(".marker-text-unread").toggleClass("hidden", !flag);
- }
-
log_data() : server_log.base.Channel {
return {
channel_name: this.channelName(),
diff --git a/shared/js/ui/client.ts b/shared/js/ui/client.ts
index d6a73852..2802e4eb 100644
--- a/shared/js/ui/client.ts
+++ b/shared/js/ui/client.ts
@@ -1,17 +1,16 @@
import * as contextmenu from "tc-shared/ui/elements/ContextMenu";
-import {channel_tree, Registry} from "tc-shared/events";
+import {Registry} from "tc-shared/events";
import {ChannelTree} from "tc-shared/ui/view";
import * as log from "tc-shared/log";
import {LogCategory, LogType} from "tc-shared/log";
import {Settings, settings} from "tc-shared/settings";
-import {KeyCode, SpecialKey} from "tc-shared/PPTListener";
import {Sound} from "tc-shared/sound/Sounds";
import {Group, GroupManager, GroupTarget, GroupType} from "tc-shared/permission/GroupManager";
import PermissionType from "tc-shared/permission/PermissionType";
import {createErrorModal, createInputModal} from "tc-shared/ui/elements/Modal";
import * as htmltags from "tc-shared/ui/htmltags";
import * as server_log from "tc-shared/ui/frames/server_log";
-import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
+import {CommandResult, PlaylistSong} from "tc-shared/connection/ServerConnectionDeclaration";
import {ChannelEntry} from "tc-shared/ui/channel";
import {ConnectionHandler, ViewReasonId} from "tc-shared/ConnectionHandler";
import {voice} from "tc-shared/connection/ConnectionBase";
@@ -25,8 +24,10 @@ import {spawnChangeLatency} from "tc-shared/ui/modal/ModalChangeLatency";
import {spawnPlaylistEdit} from "tc-shared/ui/modal/ModalPlaylistEdit";
import {formatMessage} from "tc-shared/ui/frames/chat";
import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
-import * as ppt from "tc-backend/ppt";
import * as hex from "tc-shared/crypto/hex";
+import { ClientEntry as ClientEntryView } from "./tree/Client";
+import * as React from "react";
+import {ChannelTreeEntry, ChannelTreeEntryEvents} from "tc-shared/ui/TreeEntry";
export enum ClientType {
CLIENT_VOICE,
@@ -135,12 +136,39 @@ export class ClientConnectionInfo {
connection_client_port: number = -1;
}
-export class ClientEntry {
- readonly events: Registry;
+export interface ClientEvents extends ChannelTreeEntryEvents {
+ "notify_enter_view": {},
+ "notify_left_view": {},
+
+ notify_properties_updated: {
+ updated_properties: {[Key in keyof ClientProperties]: ClientProperties[Key]};
+ client_properties: ClientProperties
+ },
+ notify_mute_state_change: { muted: boolean }
+ notify_speak_state_change: { speaking: boolean }
+
+ "music_status_update": {
+ player_buffered_index: number,
+ player_replay_index: number
+ },
+ "music_song_change": {
+ "song": SongInfo
+ },
+
+ /* TODO: Move this out of the music bots interface? */
+ "playlist_song_add": { song: PlaylistSong },
+ "playlist_song_remove": { song_id: number },
+ "playlist_song_reorder": { song_id: number, previous_song_id: number },
+ "playlist_song_loaded": { song_id: number, success: boolean, error_msg?: string, metadata?: string },
+
+}
+
+export class ClientEntry extends ChannelTreeEntry {
+ readonly events: Registry;
+ readonly view: React.RefObject = React.createRef();
protected _clientId: number;
protected _channel: ChannelEntry;
- protected _tag: JQuery;
protected _properties: ClientProperties;
protected lastVariableUpdate: number = 0;
@@ -162,7 +190,8 @@ export class ClientEntry {
channelTree: ChannelTree;
constructor(clientId: number, clientName, properties: ClientProperties = new ClientProperties()) {
- this.events = new Registry();
+ super();
+ this.events = new Registry();
this._properties = properties;
this._properties.client_nickname = clientName;
@@ -172,10 +201,6 @@ export class ClientEntry {
}
destroy() {
- if(this._tag) {
- this._tag.remove();
- this._tag = undefined;
- }
if(this._audio_handle) {
log.warn(LogCategory.AUDIO, tr("Destroying client with an active audio handle. This could cause memory leaks!"));
try {
@@ -240,7 +265,7 @@ export class ClientEntry {
clientId(){ return this._clientId; }
is_muted() { return !!this._audio_muted; }
- set_muted(flag: boolean, update_icon: boolean, force?: boolean) {
+ set_muted(flag: boolean, force: boolean) {
if(this._audio_muted === flag && !force)
return;
@@ -264,13 +289,11 @@ export class ClientEntry {
}
}
- if(update_icon)
- this.updateClientSpeakIcon();
-
+ this.events.fire("notify_mute_state_change", { muted: flag });
for(const client of this.channelTree.clients) {
- if(client === this || client.properties.client_unique_identifier != this.properties.client_unique_identifier)
+ if(client === this || client.properties.client_unique_identifier !== this.properties.client_unique_identifier)
continue;
- client.set_muted(flag, true);
+ client.set_muted(flag, false);
}
}
@@ -278,34 +301,8 @@ export class ClientEntry {
if(this._listener_initialized) return;
this._listener_initialized = true;
- this.tag.on('mouseup', event => {
- if(!this.channelTree.client_mover.is_active()) {
- this.channelTree.onSelect(this);
- }
- });
-
- if(!(this instanceof LocalClientEntry) && !(this instanceof MusicClientEntry))
- this.tag.dblclick(event => {
- if($.isArray(this.channelTree.currently_selected)) { //Multiselect
- return;
- }
- this.open_text_chat();
- });
-
- if(!settings.static(Settings.KEY_DISABLE_CONTEXT_MENU, false)) {
- this.tag.on("contextmenu", (event) => {
- event.preventDefault();
- if($.isArray(this.channelTree.currently_selected)) { //Multiselect
- (this.channelTree.currently_selected_context_callback || ((_) => null))(event);
- return;
- }
-
- this.channelTree.onSelect(this, true);
- this.showContextMenu(event.pageX, event.pageY, () => {});
- return false;
- });
- }
-
+ //FIXME: TODO!
+ /*
this.tag.on('mousedown', event => {
if(event.which != 1) return; //Only the left button
@@ -340,6 +337,19 @@ export class ClientEntry {
this.channelTree.onSelect();
}, event);
});
+ */
+ }
+
+ protected onSelect(singleSelect: boolean) {
+ super.onSelect(singleSelect);
+ if(!singleSelect) return;
+
+ if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CLIENT)) {
+ if(this instanceof MusicClientEntry)
+ this.channelTree.client.side_bar.show_music_player(this);
+ else
+ this.channelTree.client.side_bar.show_client_info(this);
+ }
}
protected contextmenu_info() : contextmenu.MenuEntry[] {
@@ -674,85 +684,18 @@ export class ClientEntry {
icon_class: "client-input_muted_local",
name: tr("Mute client"),
visible: !this._audio_muted,
- callback: () => this.set_muted(true, true)
+ callback: () => this.set_muted(true, false)
}, {
type: contextmenu.MenuEntryType.ENTRY,
icon_class: "client-input_muted_local",
name: tr("Unmute client"),
visible: this._audio_muted,
- callback: () => this.set_muted(false, true)
+ callback: () => this.set_muted(false, false)
},
contextmenu.Entry.CLOSE(() => trigger_close && on_close ? on_close() : {})
);
}
- get tag() : JQuery {
- if(this._tag) return this._tag;
-
- let container_client = $.spawn("div")
- .addClass("tree-entry client")
- .attr("client-id", this.clientId());
-
-
- /* unread marker */
- {
- container_client.append(
- $.spawn("div")
- .addClass("marker-text-unread hidden")
- .attr("private-conversation", this._clientId)
- );
- }
- container_client.append(
- $.spawn("div")
- .addClass("icon_client_state")
- .attr("title", "Client state")
- );
-
- container_client.append(
- $.spawn("div")
- .addClass("group-prefix")
- .attr("title", "Server groups prefixes")
- .hide()
- );
- container_client.append(
- $.spawn("div")
- .addClass("client-name")
- .text(this.clientNickName())
- );
- container_client.append(
- $.spawn("div")
- .addClass("group-suffix")
- .attr("title", "Server groups suffix")
- .hide()
- );
- container_client.append(
- $.spawn("div")
- .addClass("client-away-message")
- .text(this.clientNickName())
- );
-
- let container_icons = $.spawn("div").addClass("container-icons");
-
- container_icons.append(
- $.spawn("div")
- .addClass("icon icon_talk_power client-input_muted")
- .hide()
- );
- container_icons.append(
- $.spawn("div")
- .addClass("container-icons-group")
- );
- container_icons.append(
- $.spawn("div")
- .addClass("container-icon-client")
- );
- container_client.append(container_icons);
-
- this._tag = container_client;
- this.initializeListener();
- return this._tag;
- }
-
static bbcodeTag(id: number, name: string, uid: string) : string {
return "[url=client://" + id + "/" + uid + "~" + encodeURIComponent(name) + "]" + name + "[/url]";
}
@@ -777,80 +720,18 @@ export class ClientEntry {
set speaking(flag) {
if(flag === this._speaking) return;
this._speaking = flag;
- this.updateClientSpeakIcon();
+ this.events.fire("notify_speak_state_change", { speaking: flag });
}
- updateClientStatusIcons() {
- let talk_power = this.properties.client_talk_power >= this._channel.properties.channel_needed_talk_power;
- if(talk_power)
- this.tag.find(".icon_talk_power").hide();
- else
- this.tag.find(".icon_talk_power").show();
- }
-
- updateClientSpeakIcon() {
- let icon: string = "";
- let clicon: string = "";
-
- if(this.properties.client_type_exact == ClientType.CLIENT_QUERY) {
- icon = "client-server_query";
- console.log("Server query!");
- } else {
- if (this.properties.client_away) {
- icon = "client-away";
- } else if (this._audio_muted && !(this instanceof LocalClientEntry)) {
- icon = "client-input_muted_local";
- } else if(!this.properties.client_output_hardware) {
- icon = "client-hardware_output_muted";
- } else if(this.properties.client_output_muted) {
- icon = "client-output_muted";
- } else if(!this.properties.client_input_hardware) {
- icon = "client-hardware_input_muted";
- } else if(this.properties.client_input_muted) {
- icon = "client-input_muted";
- } else {
- if(this._speaking) {
- if(this.properties.client_is_channel_commander)
- clicon = "client_cc_talk";
- else
- clicon = "client_talk";
- } else {
- if(this.properties.client_is_channel_commander)
- clicon = "client_cc_idle";
- else
- clicon = "client_idle";
- }
- }
- }
-
-
- if(clicon.length > 0)
- this.tag.find(".icon_client_state").attr('class', 'icon_client_state clicon ' + clicon);
- else if(icon.length > 0)
- this.tag.find(".icon_client_state").attr('class', 'icon_client_state icon ' + icon);
- else
- this.tag.find(".icon_client_state").attr('class', 'icon_client_state icon_empty');
- }
-
- updateAwayMessage() {
- let tag = this.tag.find(".client-away-message");
- if(this.properties.client_away == true && this.properties.client_away_message){
- tag.text("[" + this.properties.client_away_message + "]");
- tag.show();
- } else {
- tag.hide();
- }
- }
+ isSpeaking() { return this._speaking; }
updateVariables(...variables: {key: string, value: string}[]) {
- let group = log.group(log.LogType.DEBUG, LogCategory.CLIENT, tr("Update properties (%i) of %s (%i)"), variables.length, this.clientNickName(), this.clientId());
- let update_icon_status = false;
- let update_icon_speech = false;
- let update_away = false;
let reorder_channel = false;
let update_avatar = false;
+ /* devel-block(log-client-property-updates) */
+ let group = log.group(log.LogType.DEBUG, LogCategory.CLIENT, tr("Update properties (%i) of %s (%i)"), variables.length, this.clientNickName(), this.clientId());
{
const entries = [];
for(const variable of variables)
@@ -861,6 +742,7 @@ export class ClientEntry {
});
log.table(LogType.DEBUG, LogCategory.PERMISSIONS, "Client update properties", entries);
}
+ /* devel-block-end */
for(const variable of variables) {
const old_value = this._properties[variable.key];
@@ -878,8 +760,6 @@ export class ClientEntry {
}
}
- this.tag.find(".client-name").text(variable.value);
-
const chat = this.channelTree.client.side_bar;
const conversation = chat.private_conversations().find_conversation({
name: this.clientNickName(),
@@ -893,32 +773,19 @@ export class ClientEntry {
conversation.set_client_name(variable.value);
reorder_channel = true;
}
- if(
- variable.key == "client_away" ||
- variable.key == "client_input_hardware" ||
- variable.key == "client_output_hardware" ||
- variable.key == "client_output_muted" ||
- variable.key == "client_input_muted" ||
- variable.key == "client_is_channel_commander"){
- update_icon_speech = true;
- }
- if(variable.key == "client_away_message" || variable.key == "client_away") {
- update_away = true;
- }
if(variable.key == "client_unique_identifier") {
this._audio_volume = parseFloat(this.channelTree.client.settings.server("volume_client_" + this.clientUid(), "1"));
const mute_status = this.channelTree.client.settings.server("mute_client_" + this.clientUid(), false);
- this.set_muted(mute_status, false, mute_status); /* force only needed when we want to mute the client */
+ this.set_muted(mute_status, mute_status); /* force only needed when we want to mute the client */
if(this._audio_handle)
this._audio_handle.set_volume(this._audio_muted ? 0 : this._audio_volume);
- update_icon_speech = true;
log.debug(LogCategory.CLIENT, tr("Loaded client (%s) server specific properties. Volume: %o Muted: %o."), this.clientUid(), this._audio_volume, this._audio_muted);
}
if(variable.key == "client_talk_power") {
reorder_channel = true;
- update_icon_status = true;
+ //update_icon_status = true; DONE
}
if(variable.key == "client_icon_id") {
/* yeah we like javascript. Due to JS wiered integer behaviour parsing for example fails for 18446744073409829863.
@@ -926,24 +793,12 @@ export class ClientEntry {
* In opposite "18446744073409829863" >>> 0 evaluates to 3995244544, which is the icon id :)
*/
this.properties.client_icon_id = variable.value as any >>> 0;
- this.updateClientIcon();
}
- if(variable.key =="client_channel_group_id" || variable.key == "client_servergroups")
- this.update_displayed_client_groups();
else if(variable.key == "client_flag_avatar")
update_avatar = true;
}
/* process updates after variables have been set */
- if(this._channel && reorder_channel)
- this._channel.reorderClients();
- if(update_icon_speech)
- this.updateClientSpeakIcon();
- if(update_icon_status)
- this.updateClientStatusIcons();
- if(update_away)
- this.updateAwayMessage();
-
const side_bar = this.channelTree.client.side_bar;
{
const client_info = side_bar.client_info();
@@ -959,44 +814,15 @@ export class ClientEntry {
conversation.update_avatar();
}
+ /* devel-block(log-client-property-updates) */
group.end();
- this.events.fire("property_update", {
- properties: variables.map(e => e.key)
- });
- }
+ /* devel-block-end */
- update_displayed_client_groups() {
- this.tag.find(".container-icons-group").children().remove();
-
- for(let id of this.assignedServerGroupIds())
- this.updateGroupIcon(this.channelTree.client.groups.serverGroup(id));
- this.update_group_icon_order();
- this.updateGroupIcon(this.channelTree.client.groups.channelGroup(this.properties.client_channel_group_id));
-
- let prefix_groups: string[] = [];
- let suffix_groups: string[] = [];
- for(const group_id of this.assignedServerGroupIds()) {
- const group = this.channelTree.client.groups.serverGroup(group_id);
- if(!group) continue;
-
- if(group.properties.namemode == 1)
- prefix_groups.push(group.name);
- else if(group.properties.namemode == 2)
- suffix_groups.push(group.name);
- }
-
- const tag_group_prefix = this.tag.find(".group-prefix");
- const tag_group_suffix = this.tag.find(".group-suffix");
- if(prefix_groups.length > 0) {
- tag_group_prefix.text("[" + prefix_groups.join("][") + "]").show();
- } else {
- tag_group_prefix.hide()
- }
-
- if(suffix_groups.length > 0) {
- tag_group_suffix.text("[" + suffix_groups.join("][") + "]").show();
- } else {
- tag_group_suffix.hide()
+ {
+ let properties = {};
+ for(const property of variables)
+ properties[property.key] = this.properties[property.key];
+ this.events.fire("notify_properties_updated", { updated_properties: properties as any, client_properties: this.properties });
}
}
@@ -1013,35 +839,6 @@ export class ClientEntry {
}));
}
- updateClientIcon() {
- this.tag.find(".container-icon-client").children().remove();
- if(this.properties.client_icon_id > 0) {
- this.channelTree.client.fileManager.icons.generateTag(this.properties.client_icon_id).attr("title", "Client icon")
- .appendTo(this.tag.find(".container-icon-client"));
- }
- }
-
- updateGroupIcon(group: Group) {
- if(!group) return;
-
- const container = this.tag.find(".container-icons-group");
- container.find(".icon_group_" + group.id).remove();
-
- if (group.properties.iconid > 0) {
- container.append(
- $.spawn("div").attr('group-power', group.properties.sortid)
- .addClass("container-group-icon icon_group_" + group.id)
- .append(this.channelTree.client.fileManager.icons.generateTag(group.properties.iconid)).attr("title", group.name)
- );
- }
- }
-
- update_group_icon_order() {
- const container = this.tag.find(".container-icons-group");
-
- container.append(...[...container.children()].sort((a, b) => parseInt(a.getAttribute("group-power")) - parseInt(b.getAttribute("group-power"))));
- }
-
assignedServerGroupIds() : number[] {
let result = [];
for(let id of this.properties.client_servergroups.split(",")){
@@ -1101,13 +898,6 @@ export class ClientEntry {
}
}
- update_family_index() {
- if(!this._channel) return;
- const index = this._channel.calculate_family_index();
-
- this.tag.css('padding-left', (5 + (index + 2) * 16) + "px");
- }
-
log_data() : server_log.base.Client {
return {
client_unique_id: this.properties.client_unique_identifier,
@@ -1143,10 +933,6 @@ export class ClientEntry {
this._info_connection_promise_resolve = undefined;
this._info_connection_promise_reject = undefined;
}
-
- set flag_text_unread(flag: boolean) {
- this._tag.find(".marker-text-unread").toggleClass("hidden", !flag);
- }
}
export class LocalClientEntry extends ClientEntry {
@@ -1195,64 +981,42 @@ export class LocalClientEntry extends ClientEntry {
}
initializeListener(): void {
- if(this._listener_initialized)
- this.tag.off();
- this._listener_initialized = false; /* could there be a better system */
super.initializeListener();
- this.tag.find(".client-name").addClass("client-name-own");
+ }
- this.tag.on('dblclick', () => {
- if(Array.isArray(this.channelTree.currently_selected)) { //Multiselect
- return;
- }
- this.openRename();
+ renameSelf(new_name: string) : Promise {
+ const old_name = this.properties.client_nickname;
+ this.updateVariables({ key: "client_nickname", value: new_name }); /* change it locally */
+ return this.handle.serverConnection.command_helper.updateClient("client_nickname", new_name).then((e) => {
+ settings.changeGlobal(Settings.KEY_CONNECT_USERNAME, new_name);
+ this.channelTree.client.log.log(server_log.Type.CLIENT_NICKNAME_CHANGED, {
+ client: this.log_data(),
+ old_name: old_name,
+ new_name: new_name,
+ own_client: true
+ });
+ return true;
+ }).catch((e: CommandResult) => {
+ this.updateVariables({ key: "client_nickname", value: old_name }); /* change it back */
+ this.channelTree.client.log.log(server_log.Type.CLIENT_NICKNAME_CHANGE_FAILED, {
+ reason: e.extra_message
+ });
+ return false;
});
}
openRename() : void {
- this.channelTree.client_mover.enabled = false;
-
- const elm = this.tag.find(".client-name");
- elm.attr("contenteditable", "true");
- elm.removeClass("client-name-own");
- elm.css("background-color", "white");
- elm.focus();
- this.renaming = true;
-
- elm.on('keypress', event => {
- if(event.keyCode == KeyCode.KEY_RETURN) {
- $(event.target).trigger("focusout");
- return false;
+ const view = this.channelTree.view.current;
+ if(!view) return; //TODO: Fallback input modal
+ view.scrollEntryInView(this, () => {
+ const own_view = this.view.current;
+ if(!own_view) {
+ return; //TODO: Fallback input modal
}
- });
- elm.on('focusout', event => {
- this.channelTree.client_mover.enabled = true;
-
- if(!this.renaming) return;
- this.renaming = false;
-
- elm.css("background-color", "");
- elm.removeAttr("contenteditable");
- elm.addClass("client-name-own");
- let text = elm.text().toString();
- if(this.clientNickName() == text) return;
-
- elm.text(this.clientNickName());
- const old_name = this.clientNickName();
- this.handle.serverConnection.command_helper.updateClient("client_nickname", text).then((e) => {
- settings.changeGlobal(Settings.KEY_CONNECT_USERNAME, text);
- this.channelTree.client.log.log(server_log.Type.CLIENT_NICKNAME_CHANGED, {
- client: this.log_data(),
- old_name: old_name,
- new_name: text,
- own_client: true
- });
- }).catch((e: CommandResult) => {
- this.channelTree.client.log.log(server_log.Type.CLIENT_NICKNAME_CHANGE_FAILED, {
- reason: e.extra_message
- });
- this.openRename();
+ own_view.setState({
+ rename: true,
+ renameInitialName: this.properties.client_nickname
});
});
}
diff --git a/shared/js/ui/client_move.ts b/shared/js/ui/client_move.ts
deleted file mode 100644
index da548c80..00000000
--- a/shared/js/ui/client_move.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-import {ChannelTree} from "tc-shared/ui/view";
-import * as log from "tc-shared/log";
-import {LogCategory} from "tc-shared/log";
-import {ClientEntry} from "tc-shared/ui/client";
-import {ChannelEntry} from "tc-shared/ui/channel";
-
-export class ClientMover {
- static readonly listener_root = $(document);
- static readonly move_element = $("#mouse-move");
- readonly channel_tree: ChannelTree;
-
- selected_client: ClientEntry | ClientEntry[];
-
- hovered_channel: HTMLDivElement;
- callback: (channel?: ChannelEntry) => any;
-
- enabled: boolean = true;
-
- private _bound_finish;
- private _bound_move;
- private _active: boolean = false;
-
- private origin_point: {x: number, y: number} = undefined;
-
- constructor(tree: ChannelTree) {
- this.channel_tree = tree;
- }
-
- is_active() { return this._active; }
-
- private hover_text() {
- if($.isArray(this.selected_client)) {
- return this.selected_client.filter(client => !!client).map(client => client.clientNickName()).join(", ");
- } else if(this.selected_client) {
- return (this.selected_client).clientNickName();
- } else
- return "";
- }
-
- private bbcode_text() {
- if($.isArray(this.selected_client)) {
- return this.selected_client.filter(client => !!client).map(client => client.create_bbcode()).join(", ");
- } else if(this.selected_client) {
- return (this.selected_client).create_bbcode();
- } else
- return "";
- }
-
- activate(client: ClientEntry | ClientEntry[], callback: (channel?: ChannelEntry) => any, event: any) {
- this.finish_listener(undefined);
-
- if(!this.enabled)
- return false;
-
- this.selected_client = client;
- this.callback = callback;
- log.debug(LogCategory.GENERAL, tr("Starting mouse move"));
-
- ClientMover.listener_root.on('mouseup', this._bound_finish = this.finish_listener.bind(this)).on('mousemove', this._bound_move = this.move_listener.bind(this));
-
- {
- const content = ClientMover.move_element.find(".container");
- content.empty();
- content.append($.spawn("a").text(this.hover_text()));
- }
- this.move_listener(event);
- }
-
- private move_listener(event) {
- if(!this.enabled)
- return;
-
- //console.log("Mouse move: " + event.pageX + " - " + event.pageY);
- if(!event.pageX || !event.pageY) return;
- if(!this.origin_point)
- this.origin_point = {x: event.pageX, y: event.pageY};
-
- ClientMover.move_element.css({
- "top": (event.pageY - 1) + "px",
- "left": (event.pageX + 10) + "px"
- });
-
- if(!this._active) {
- const d_x = this.origin_point.x - event.pageX;
- const d_y = this.origin_point.y - event.pageY;
- this._active = Math.sqrt(d_x * d_x + d_y * d_y) > 5 * 5;
-
- if(this._active) {
- if($.isArray(this.selected_client)) {
- this.channel_tree.onSelect(this.selected_client[0], true);
- for(const client of this.selected_client.slice(1))
- this.channel_tree.onSelect(client, false, true);
- } else {
- this.channel_tree.onSelect(this.selected_client, true);
- }
-
- ClientMover.move_element.show();
- }
- }
-
- const elements = document.elementsFromPoint(event.pageX, event.pageY);
- while(elements.length > 0) {
- if(elements[0].classList.contains("container-channel")) break;
- elements.pop_front();
- }
-
- if(this.hovered_channel) {
- this.hovered_channel.classList.remove("move-selected");
- this.hovered_channel = undefined;
- }
- if(elements.length > 0) {
- elements[0].classList.add("move-selected");
- this.hovered_channel = elements[0] as HTMLDivElement;
- }
- }
-
- private finish_listener(event) {
- ClientMover.move_element.hide();
- log.debug(LogCategory.GENERAL, tr("Finishing mouse move"));
-
- const channel_id = this.hovered_channel ? parseInt(this.hovered_channel.getAttribute("channel-id")) : 0;
- ClientMover.listener_root.unbind('mouseleave', this._bound_finish);
- ClientMover.listener_root.unbind('mouseup', this._bound_finish);
- ClientMover.listener_root.unbind('mousemove', this._bound_move);
- if(this.hovered_channel) {
- this.hovered_channel.classList.remove("move-selected");
- this.hovered_channel = undefined;
- }
-
- this.origin_point = undefined;
- if(!this._active) {
- this.selected_client = undefined;
- this.callback = undefined;
- return;
- }
-
- this._active = false;
- if(this.callback) {
- if(!channel_id)
- this.callback(undefined);
- else {
- this.callback(this.channel_tree.findChannel(channel_id));
- }
- this.callback = undefined;
- }
-
- /* test for the chat box */
- {
- const elements = document.elementsFromPoint(event.pageX, event.pageY);
- console.error(elements);
- while(elements.length > 0) {
- if(elements[0].classList.contains("client-chat-box-field")) break;
- elements.pop_front();
- }
-
- if(elements.length > 0) {
- const element = $(elements[0]);
- element.val((element.val() || "") + this.bbcode_text());
- }
- }
- }
-
- deactivate() {
- this.callback = undefined;
- this.finish_listener(undefined);
- }
-}
\ No newline at end of file
diff --git a/shared/js/ui/frames/MenuBar.ts b/shared/js/ui/frames/MenuBar.ts
index bc6b15a2..dee7a447 100644
--- a/shared/js/ui/frames/MenuBar.ts
+++ b/shared/js/ui/frames/MenuBar.ts
@@ -1,4 +1,4 @@
-import {Icon, IconManager} from "tc-shared/FileManager";
+import {icon_cache_loader, IconManager, LocalIcon} from "tc-shared/FileManager";
import {spawnBookmarkModal} from "tc-shared/ui/modal/ModalBookmarks";
import {
add_server_to_bookmarks,
@@ -33,7 +33,7 @@ export interface MenuItem {
delete_item(item: MenuItem | HRItem);
items() : (MenuItem | HRItem)[];
- icon(klass?: string | Promise | Icon) : string;
+ icon(klass?: string | LocalIcon) : string; //FIXME: Native client must work as well!
label(value?: string) : string;
visible(value?: boolean) : boolean;
disabled(value?: boolean) : boolean;
@@ -178,7 +178,7 @@ namespace html {
return this;
}
- icon(klass?: string | Promise | Icon): string {
+ icon(klass?: string | LocalIcon): string {
this._label_icon_tag.children().remove();
if(typeof(klass) === "string")
$.spawn("div").addClass("icon_em " + klass).appendTo(this._label_icon_tag);
@@ -288,7 +288,8 @@ export function rebuild_bookmarks() {
} else {
const bookmark = entry as Bookmark;
const item = root.append_item(bookmark.display_name);
- item.icon(IconManager.load_cached_icon(bookmark.last_icon_id || 0));
+
+ item.icon(icon_cache_loader.load_icon(bookmark.last_icon_id, bookmark.last_icon_server_id));
item.click(() => boorkmak_connect(bookmark));
}
};
diff --git a/shared/js/ui/frames/control-bar/button.tsx b/shared/js/ui/frames/control-bar/button.tsx
index 3a85f432..21878ce2 100644
--- a/shared/js/ui/frames/control-bar/button.tsx
+++ b/shared/js/ui/frames/control-bar/button.tsx
@@ -27,7 +27,7 @@ export interface ButtonProperties {
}
export class Button extends ReactComponentBase {
- protected default_state(): ButtonState {
+ protected defaultState(): ButtonState {
return {
switched: false,
dropdownShowed: false,
@@ -66,13 +66,13 @@ export class Button extends ReactComponentBase {
}
private onMouseEnter() {
- this.updateState({
+ this.setState({
dropdownShowed: true
});
}
private onMouseLeave() {
- this.updateState({
+ this.setState({
dropdownShowed: false
});
}
@@ -81,6 +81,6 @@ export class Button extends ReactComponentBase {
const new_state = !(this.state.switched || this.props.switched);
const result = this.props.onToggle?.call(undefined, new_state);
if(this.props.autoSwitch)
- this.updateState({ switched: typeof result === "boolean" ? result : new_state });
+ this.setState({ switched: typeof result === "boolean" ? result : new_state });
}
}
\ No newline at end of file
diff --git a/shared/js/ui/frames/control-bar/dropdown.tsx b/shared/js/ui/frames/control-bar/dropdown.tsx
index 0ec541b2..d93c6703 100644
--- a/shared/js/ui/frames/control-bar/dropdown.tsx
+++ b/shared/js/ui/frames/control-bar/dropdown.tsx
@@ -1,10 +1,11 @@
import * as React from "react";
import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
import {IconRenderer} from "tc-shared/ui/react-elements/Icon";
+import {LocalIcon} from "tc-shared/FileManager";
const cssStyle = require("./button.scss");
export interface DropdownEntryProperties {
- icon?: string | JQuery;
+ icon?: string | LocalIcon;
text: JSX.Element | string;
onClick?: (event) => void;
@@ -12,7 +13,7 @@ export interface DropdownEntryProperties {
}
export class DropdownEntry extends ReactComponentBase {
- protected default_state() { return {}; }
+ protected defaultState() { return {}; }
render() {
if(this.props.children) {
@@ -41,7 +42,7 @@ export interface DropdownContainerProperties { }
export interface DropdownContainerState { }
export class DropdownContainer extends ReactComponentBase {
- protected default_state() {
+ protected defaultState() {
return { };
}
diff --git a/shared/js/ui/frames/control-bar/index.scss b/shared/js/ui/frames/control-bar/index.scss
index 78da44ec..ddbb6808 100644
--- a/shared/js/ui/frames/control-bar/index.scss
+++ b/shared/js/ui/frames/control-bar/index.scss
@@ -16,6 +16,7 @@ html:root {
height: 100%;
align-items: center;
background: var(--menu-bar-background);
+ border-radius: 5px;
/* tmp fix for ultra small devices */
overflow-y: visible;
diff --git a/shared/js/ui/frames/control-bar/index.tsx b/shared/js/ui/frames/control-bar/index.tsx
index 10824f28..528c45d9 100644
--- a/shared/js/ui/frames/control-bar/index.tsx
+++ b/shared/js/ui/frames/control-bar/index.tsx
@@ -16,7 +16,7 @@ import {
DirectoryBookmark,
find_bookmark
} from "tc-shared/bookmarks";
-import {IconManager} from "tc-shared/FileManager";
+import {icon_cache_loader, IconManager} from "tc-shared/FileManager";
import * as contextmenu from "tc-shared/ui/elements/ContextMenu";
import {createInputModal} from "tc-shared/ui/elements/Modal";
import {default_recorder} from "tc-shared/voice/RecorderProfile";
@@ -32,7 +32,7 @@ export interface ConnectionState {
@ReactEventHandler(obj => obj.props.event_registry)
class ConnectButton extends ReactComponentBase<{ multiSession: boolean; event_registry: Registry }, ConnectionState> {
- protected default_state(): ConnectionState {
+ protected defaultState(): ConnectionState {
return {
connected: false,
connectedAnywhere: false
@@ -84,7 +84,7 @@ class ConnectButton extends ReactComponentBase<{ multiSession: boolean; event_re
@EventHandler("update_connect_state")
private handleStateUpdate(state: ConnectionState) {
- this.updateState(state);
+ this.setState(state);
}
}
@@ -96,7 +96,7 @@ class BookmarkButton extends ReactComponentBase<{ event_registry: Registry
@@ -146,7 +146,7 @@ class BookmarkButton extends ReactComponentBase<{ event_registry: Registry boorkmak_connect(bookmark, true),
visible: !settings.static_global(Settings.KEY_DISABLE_MULTI_SESSION)
}, contextmenu.Entry.CLOSE(() => {
- this.button_ref.current?.updateState({ dropdownForceShow: false });
+ this.button_ref.current?.setState({ dropdownForceShow: false });
}));
}
@@ -177,7 +177,7 @@ export interface AwayState {
@ReactEventHandler(obj => obj.props.event_registry)
class AwayButton extends ReactComponentBase<{ event_registry: Registry }, AwayState> {
- protected default_state(): AwayState {
+ protected defaultState(): AwayState {
return {
away: false,
awayAnywhere: false,
@@ -226,7 +226,7 @@ class AwayButton extends ReactComponentBase<{ event_registry: Registry("update_away_state")
private handleStateUpdate(state: AwayState) {
- this.updateState(state);
+ this.setState(state);
}
}
@@ -236,7 +236,7 @@ export interface ChannelSubscribeState {
@ReactEventHandler(obj => obj.props.event_registry)
class ChannelSubscribeButton extends ReactComponentBase<{ event_registry: Registry }, ChannelSubscribeState> {
- protected default_state(): ChannelSubscribeState {
+ protected defaultState(): ChannelSubscribeState {
return { subscribeEnabled: false };
}
@@ -247,7 +247,7 @@ class ChannelSubscribeButton extends ReactComponentBase<{ event_registry: Regist
@EventHandler("update_subscribe_state")
private handleStateUpdate(state: ChannelSubscribeState) {
- this.updateState(state);
+ this.setState(state);
}
}
@@ -258,7 +258,7 @@ export interface MicrophoneState {
@ReactEventHandler(obj => obj.props.event_registry)
class MicrophoneButton extends ReactComponentBase<{ event_registry: Registry }, MicrophoneState> {
- protected default_state(): MicrophoneState {
+ protected defaultState(): MicrophoneState {
return {
enabled: false,
muted: false
@@ -278,7 +278,7 @@ class MicrophoneButton extends ReactComponentBase<{ event_registry: Registry("update_microphone_state")
private handleStateUpdate(state: MicrophoneState) {
- this.updateState(state);
+ this.setState(state);
}
}
@@ -288,7 +288,7 @@ export interface SpeakerState {
@ReactEventHandler(obj => obj.props.event_registry)
class SpeakerButton extends ReactComponentBase<{ event_registry: Registry }, SpeakerState> {
- protected default_state(): SpeakerState {
+ protected defaultState(): SpeakerState {
return {
muted: false
};
@@ -304,7 +304,7 @@ class SpeakerButton extends ReactComponentBase<{ event_registry: Registry("update_speaker_state")
private handleStateUpdate(state: SpeakerState) {
- this.updateState(state);
+ this.setState(state);
}
}
@@ -314,7 +314,7 @@ export interface QueryState {
@ReactEventHandler(obj => obj.props.event_registry)
class QueryButton extends ReactComponentBase<{ event_registry: Registry }, QueryState> {
- protected default_state() {
+ protected defaultState() {
return {
queryShown: false
};
@@ -340,7 +340,7 @@ class QueryButton extends ReactComponentBase<{ event_registry: Registry("update_query_state")
private handleStateUpdate(state: QueryState) {
- this.updateState(state);
+ this.setState(state);
}
}
@@ -352,7 +352,7 @@ export interface HostButtonState {
@ReactEventHandler(obj => obj.props.event_registry)
class HostButton extends ReactComponentBase<{ event_registry: Registry }, HostButtonState> {
- protected default_state() {
+ protected defaultState() {
return {
url: undefined,
target_url: undefined
@@ -382,7 +382,7 @@ class HostButton extends ReactComponentBase<{ event_registry: Registry("update_host_button")
private handleStateUpdate(state: HostButtonState) {
- this.updateState(state);
+ this.setState(state);
}
}
@@ -446,6 +446,7 @@ export class ControlBar extends React.Component {
const events = target.events();
events.off("notify_state_updated", this.connection_handler_callbacks.notify_state_updated);
events.off("notify_connection_state_changed", this.connection_handler_callbacks.notify_connection_state_changed);
+ //FIXME: Add the host button here!
}
private registerConnectionHandlerEvents(target: ConnectionHandler) {
@@ -455,7 +456,6 @@ export class ControlBar extends React.Component {
}
componentDidMount(): void {
- console.error(server_connections.events());
server_connections.events().on("notify_active_handler_changed", this.connection_manager_callbacks.active_handler_changed);
this.event_registry.fire("set_connection_handler", { handler: server_connections.active_connection() });
}
diff --git a/shared/js/ui/frames/side/conversations.ts b/shared/js/ui/frames/side/conversations.ts
index b2a95fda..58c27cab 100644
--- a/shared/js/ui/frames/side/conversations.ts
+++ b/shared/js/ui/frames/side/conversations.ts
@@ -140,8 +140,12 @@ export class Conversation {
this._first_unread_message = undefined;
const ctree = this.handle.handle.handle.channelTree;
- if(ctree && ctree.tag_tree())
- ctree.tag_tree().find(".marker-text-unread[conversation='" + this.channel_id + "']").addClass("hidden");
+ if(ctree && ctree.tag_tree()) {
+ if(this.channel_id === 0)
+ ctree.server.setUnread(false);
+ else
+ ctree.findChannel(this.channel_id).setUnread(false);
+ }
}
this._first_unread_message_pointer.html_element.detach();
}
@@ -276,6 +280,9 @@ export class Conversation {
return; /* we already have that message */
}
}
+ if(this._last_messages.length === 0)
+ _new_message = true;
+
if(!spliced && this._last_messages.length < this._view_max_messages) {
this._last_messages.push(message);
}
diff --git a/shared/js/ui/frames/side/music_info.ts b/shared/js/ui/frames/side/music_info.ts
index 01f6d3a8..9246a9a6 100644
--- a/shared/js/ui/frames/side/music_info.ts
+++ b/shared/js/ui/frames/side/music_info.ts
@@ -1,6 +1,6 @@
import {Frame, FrameContent} from "tc-shared/ui/frames/chat_frame";
import * as events from "tc-shared/events";
-import {MusicClientEntry, SongInfo} from "tc-shared/ui/client";
+import {ClientEvents, MusicClientEntry, SongInfo} from "tc-shared/ui/client";
import {voice} from "tc-shared/connection/ConnectionBase";
import PlayerState = voice.PlayerState;
import {LogCategory} from "tc-shared/log";
@@ -328,9 +328,9 @@ export class MusicInfo {
});
/* bot property listener */
- const callback_property = event => this.events.fire("bot_property_update", { properties: event.properties });
- const callback_time_update = event => this.events.fire("player_time_update", event);
- const callback_song_change = event => this.events.fire("player_song_change", event);
+ const callback_property = (event: ClientEvents["notify_properties_updated"]) => this.events.fire("bot_property_update", { properties: Object.keys(event.updated_properties) });
+ const callback_time_update = (event: ClientEvents["music_status_update"]) => this.events.fire("player_time_update", event, true);
+ const callback_song_change = (event: ClientEvents["music_song_change"]) => this.events.fire("player_song_change", event, true);
this.events.on("bot_change", event => {
if(event.old) {
event.old.events.off(callback_property);
@@ -339,7 +339,7 @@ export class MusicInfo {
event.old.events.disconnect_all(this.events);
}
if(event.new) {
- event.new.events.on("property_update", callback_property);
+ event.new.events.on("notify_properties_updated", callback_property);
event.new.events.on("music_status_update", callback_time_update);
event.new.events.on("music_song_change", callback_song_change);
@@ -736,7 +736,7 @@ export class MusicInfo {
this._current_bot.updateClientVariables(true).catch(error => {
log.warn(LogCategory.CLIENT, tr("Failed to update music bot variables: %o"), error);
}).then(() => {
- this.handle.handle.serverConnection.command_helper.request_playlist_songs(this._current_bot.properties.client_playlist_id).then(songs => {
+ this.handle.handle.serverConnection.command_helper.request_playlist_songs(this._current_bot.properties.client_playlist_id, false).then(songs => {
this.playlist_subscribe(false); /* we're allowed to see the playlist */
if(!songs) {
this._container_playlist.find(".overlay-empty").removeClass("hidden");
diff --git a/shared/js/ui/frames/side/private_conversations.ts b/shared/js/ui/frames/side/private_conversations.ts
index a441ffec..8ab94587 100644
--- a/shared/js/ui/frames/side/private_conversations.ts
+++ b/shared/js/ui/frames/side/private_conversations.ts
@@ -543,7 +543,7 @@ export class PrivateConveration {
} else {
const ctree = this.handle.handle.handle.channelTree;
if(ctree && ctree.tag_tree() && this.client_id)
- ctree.tag_tree().find(".marker-text-unread[private-conversation='" + this.client_id + "']").addClass("hidden");
+ ctree.findClient(this.client_id)?.setUnread(false);
if(this._spacer_unread_message) {
this._destroy_view_entry(this._spacer_unread_message.tag_unread);
diff --git a/shared/js/ui/modal/ModalBookmarks.ts b/shared/js/ui/modal/ModalBookmarks.ts
index fa36810d..27fba3a9 100644
--- a/shared/js/ui/modal/ModalBookmarks.ts
+++ b/shared/js/ui/modal/ModalBookmarks.ts
@@ -2,18 +2,21 @@ import {createInputModal, createModal, Modal} from "tc-shared/ui/elements/Modal"
import {
Bookmark,
bookmarks,
- BookmarkType, boorkmak_connect, create_bookmark, create_bookmark_directory,
+ BookmarkType,
+ boorkmak_connect,
+ create_bookmark,
+ create_bookmark_directory,
delete_bookmark,
DirectoryBookmark,
save_bookmark
} from "tc-shared/bookmarks";
import {connection_log, Regex} from "tc-shared/ui/modal/ModalConnect";
-import {IconManager} from "tc-shared/FileManager";
+import {icon_cache_loader, IconManager} from "tc-shared/FileManager";
import {profiles} from "tc-shared/profiles/ConnectionProfile";
import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
import {Settings, settings} from "tc-shared/settings";
-import {LogCategory} from "tc-shared/log";
import * as log from "tc-shared/log";
+import {LogCategory} from "tc-shared/log";
import * as i18nc from "tc-shared/i18n/country";
import {formatMessage} from "tc-shared/ui/frames/chat";
import * as top_menu from "../frames/MenuBar";
@@ -107,8 +110,11 @@ export function spawnBookmarkModal() {
input_server_address.val(address);
let profile = input_connect_profile.find("option[value='" + entry.connect_profile + "']");
- if(profile.length == 0)
+ console.error("%o - %s", profile, entry.connect_profile);
+ if(profile.length == 0) {
+ log.warn(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" : "");
@@ -147,7 +153,7 @@ export function spawnBookmarkModal() {
const bookmark = entry as Bookmark;
container.append(
bookmark.last_icon_id ?
- IconManager.generate_tag(IconManager.load_cached_icon(bookmark.last_icon_id || 0), {animate: false}) :
+ IconManager.generate_tag(icon_cache_loader.load_icon(bookmark.last_icon_id, bookmark.last_icon_server_id), {animate: false}) :
$.spawn("div").addClass("icon-container icon_em")
);
} else {
@@ -287,6 +293,7 @@ export function spawnBookmarkModal() {
if(event.type === "change" && valid) {
selected_bookmark.display_name = name;
label_bookmark_name.text(name);
+ save_bookmark(selected_bookmark);
}
});
@@ -306,17 +313,25 @@ export function spawnBookmarkModal() {
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", event => {
+ 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', event => {
const id = input_connect_profile.val() as string;
const profile = profiles().find(e => e.id === id);
if(profile) {
(selected_bookmark as Bookmark).connect_profile = id;
+ save_bookmark(selected_bookmark);
} else {
log.warn(LogCategory.GENERAL, tr("Failed to change connect profile for profile %s to %s"), selected_bookmark.unique_id, id);
}
diff --git a/shared/js/ui/modal/ModalConnect.ts b/shared/js/ui/modal/ModalConnect.ts
index c149d016..ec941542 100644
--- a/shared/js/ui/modal/ModalConnect.ts
+++ b/shared/js/ui/modal/ModalConnect.ts
@@ -5,7 +5,7 @@ import * as loader from "tc-loader";
import {createModal} from "tc-shared/ui/elements/Modal";
import {ConnectionProfile, default_profile, find_profile, profiles} from "tc-shared/profiles/ConnectionProfile";
import {KeyCode} from "tc-shared/PPTListener";
-import {IconManager} from "tc-shared/FileManager";
+import {icon_cache_loader, IconManager} from "tc-shared/FileManager";
import * as i18nc from "tc-shared/i18n/country";
import {spawnSettingsModal} from "tc-shared/ui/modal/ModalSettings";
import {server_connections} from "tc-shared/ui/frames/connection_handlers";
@@ -15,7 +15,10 @@ export namespace connection_log {
//TODO: Save password data
export type ConnectionData = {
name: string;
+
icon_id: number;
+ server_unique_id: string;
+
country: string;
clients_online: number;
clients_total: number;
@@ -45,6 +48,7 @@ export namespace connection_log {
country: 'unknown',
name: 'Unknown',
icon_id: 0,
+ server_unique_id: "unknown",
total_connection: 0,
flag_password: false,
@@ -289,7 +293,7 @@ export function spawnConnectModal(options: {
})
).append(
$.spawn("div").addClass("column name").append([
- IconManager.generate_tag(IconManager.load_cached_icon(entry.icon_id)),
+ IconManager.generate_tag(icon_cache_loader.load_icon(entry.icon_id, entry.server_unique_id)),
$.spawn("a").text(entry.name)
])
).append(
diff --git a/shared/js/ui/modal/permission/HTMLPermissionEditor.ts b/shared/js/ui/modal/permission/HTMLPermissionEditor.ts
index 262a5e7d..34a56cc7 100644
--- a/shared/js/ui/modal/permission/HTMLPermissionEditor.ts
+++ b/shared/js/ui/modal/permission/HTMLPermissionEditor.ts
@@ -10,7 +10,7 @@ import * as contextmenu from "tc-shared/ui/elements/ContextMenu";
import {createInfoModal} from "tc-shared/ui/elements/Modal";
import {copy_to_clipboard} from "tc-shared/utils/helpers";
import PermissionType from "tc-shared/permission/PermissionType";
-import {IconManager} from "tc-shared/FileManager";
+import {icon_cache_loader, IconManager} from "tc-shared/FileManager";
import {LogCategory} from "tc-shared/log";
import * as log from "tc-shared/log";
import {
@@ -712,7 +712,7 @@ export class HTMLPermissionEditor extends AbstractPermissionEditor {
let resolve: Promise>;
if(icon_id >= 0 && icon_id <= 1000)
- resolve = Promise.resolve(IconManager.generate_tag({id: icon_id, url: ""}));
+ resolve = Promise.resolve(IconManager.generate_tag(icon_cache_loader.load_icon(icon_id, "general")));
else
resolve = this.icon_resolver(permission ? permission.get_value() : 0).then(e => $(e));
diff --git a/shared/js/ui/modal/permission/ModalPermissionEdit.ts b/shared/js/ui/modal/permission/ModalPermissionEdit.ts
index 538f28de..a2c6546f 100644
--- a/shared/js/ui/modal/permission/ModalPermissionEdit.ts
+++ b/shared/js/ui/modal/permission/ModalPermissionEdit.ts
@@ -64,18 +64,18 @@ export function spawnPermissionEdit(connection: Conne
const permission_editor: AbstractPermissionEditor = (() => {
const editor = new HTMLPermissionEditor();
editor.initialize(connection.permissions.groupedPermissions());
- editor.icon_resolver = id => connection.fileManager.icons.resolve_icon(id).then(async icon => {
- if(!icon)
- return undefined;
+ editor.icon_resolver = async id => {
+ const icon = connection.fileManager.icons.load_icon(id);
+ await icon.await_loading();
const tag = document.createElement("img");
await new Promise((resolve, reject) => {
tag.onerror = reject;
tag.onload = resolve;
- tag.src = icon.url;
+ tag.src = icon.loaded_url || "nope";
});
return tag;
- });
+ };
editor.icon_selector = current_icon => new Promise(resolve => {
spawnIconSelect(connection, id => resolve(new Int32Array([id])[0]), current_icon);
});
diff --git a/shared/js/ui/modal/settings/Keymap.tsx b/shared/js/ui/modal/settings/Keymap.tsx
index c65ae536..c44f0aed 100644
--- a/shared/js/ui/modal/settings/Keymap.tsx
+++ b/shared/js/ui/modal/settings/Keymap.tsx
@@ -61,7 +61,7 @@ interface KeyActionEntryProperties {
@ReactEventHandler(e => e.props.eventRegistry)
class KeyActionEntry extends ReactComponentBase {
- protected default_state() : KeyActionEntryState {
+ protected defaultState() : KeyActionEntryState {
return {
assignedKey: undefined,
selected: false,
@@ -133,7 +133,7 @@ class KeyActionEntry extends ReactComponentBase("set_selected_action")
private handleSelectedChange(event: KeyMapEvents["set_selected_action"]) {
- this.updateState({
+ this.setState({
selected: this.props.action === event.action
});
}
@@ -143,7 +143,7 @@ class KeyActionEntry extends ReactComponentBase("set_keymap_result")
@@ -177,12 +177,12 @@ class KeyActionEntry extends ReactComponentBase {
- protected default_state(): { collapsed: boolean } {
+ protected defaultState(): { collapsed: boolean } {
return { collapsed: false }
}
@@ -213,7 +213,7 @@ class KeyActionGroup extends ReactComponentBase {
- protected default_state(): {} {
+ protected defaultState(): {} {
return {};
}
@@ -251,7 +251,7 @@ interface ButtonBarState {
@ReactEventHandler(e => e.props.event_registry)
class ButtonBar extends ReactComponentBase<{ event_registry: Registry }, ButtonBarState> {
- protected default_state(): ButtonBarState {
+ protected defaultState(): ButtonBarState {
return {
active_action: undefined,
loading: true,
@@ -271,7 +271,7 @@ class ButtonBar extends ReactComponentBase<{ event_registry: Registry("set_selected_action")
private handleSetSelectedAction(event: KeyMapEvents["set_selected_action"]) {
- this.updateState({
+ this.setState({
active_action: event.action,
loading: true
}, () => {
@@ -281,7 +281,7 @@ class ButtonBar extends ReactComponentBase<{ event_registry: Registry("query_keymap_result")
private handleQueryKeymapResult(event: KeyMapEvents["query_keymap_result"]) {
- this.updateState({
+ this.setState({
loading: false,
has_key: event.status === "success" && !!event.key
});
diff --git a/shared/js/ui/react-elements/Button.tsx b/shared/js/ui/react-elements/Button.tsx
index a6ccae44..bc09085b 100644
--- a/shared/js/ui/react-elements/Button.tsx
+++ b/shared/js/ui/react-elements/Button.tsx
@@ -16,7 +16,7 @@ export interface ButtonState {
}
export class Button extends ReactComponentBase {
- protected default_state(): ButtonState {
+ protected defaultState(): ButtonState {
return {
disabled: undefined
};
diff --git a/shared/js/ui/react-elements/Icon.tsx b/shared/js/ui/react-elements/Icon.tsx
index 0e09e627..7e360cfd 100644
--- a/shared/js/ui/react-elements/Icon.tsx
+++ b/shared/js/ui/react-elements/Icon.tsx
@@ -1,35 +1,63 @@
import * as React from "react";
+import {LocalIcon} from "tc-shared/FileManager";
export interface IconProperties {
- icon: string | JQuery;
+ icon: string | LocalIcon;
+ title?: string;
}
export class IconRenderer extends React.Component {
- private readonly icon_ref: React.RefObject;
+ render() {
+ if(!this.props.icon)
+ return ;
+ else if(typeof this.props.icon === "string")
+ return ;
+ else if(this.props.icon instanceof LocalIcon)
+ return ;
+ else throw "JQuery icons are not longer supported";
+ }
+}
+
+export interface LoadedIconRenderer {
+ icon: LocalIcon;
+ title?: string;
+}
+
+export class LocalIconRenderer extends React.Component {
+ private readonly callback_state_update;
constructor(props) {
super(props);
- if(typeof this.props.icon === "object")
- this.icon_ref = React.createRef();
+ this.callback_state_update = () => {
+ const icon = this.props.icon;
+ if(icon.status !== "destroyed")
+ this.forceUpdate();
+ };
}
render() {
- if(!this.props.icon)
- return ;
- else if(typeof this.props.icon === "string")
- return ;
-
-
- return ;
+ const icon = this.props.icon;
+ if(!icon || icon.status === "empty" || icon.status === "destroyed")
+ return ;
+ else if(icon.status === "loaded") {
+ if(icon.icon_id >= 0 && icon.icon_id <= 1000) {
+ if(icon.icon_id === 0)
+ return ;
+ return ;
+ }
+ return 
;
+ } else if(icon.status === "loading")
+ return ;
+ else if(icon.status === "error")
+ return ;
}
componentDidMount(): void {
- if(this.icon_ref)
- $(this.icon_ref.current).replaceWith(this.props.icon);
+ this.props.icon?.status_change_callbacks.push(this.callback_state_update);
}
+
componentWillUnmount(): void {
- if(this.icon_ref)
- $(this.icon_ref.current).empty();
+ this.props.icon?.status_change_callbacks.remove(this.callback_state_update);
}
}
\ No newline at end of file
diff --git a/shared/js/ui/react-elements/ReactComponentBase.ts b/shared/js/ui/react-elements/ReactComponentBase.ts
index 48cdab1e..636b79e1 100644
--- a/shared/js/ui/react-elements/ReactComponentBase.ts
+++ b/shared/js/ui/react-elements/ReactComponentBase.ts
@@ -1,22 +1,101 @@
import * as React from "react";
+import * as ReactDOM from "react-dom";
+
+export enum BatchUpdateType {
+ UNSET = -1,
+ GENERAL = 0,
+ CHANNEL_TREE = 1
+}
+
+interface UpdateBatch {
+ enabled: boolean;
+ enable_count: number;
+
+ update: {
+ c: any;
+ s: any;
+ b: () => void;
+ }[];
+
+ force: {
+ c: any;
+ b: () => void;
+ }[];
+}
+
+const generate_batch = () => { return { enabled: false, enable_count: 0, update: [], force: [] }};
+let update_batches: {[key: number]:UpdateBatch} = {
+ 0: generate_batch(),
+ 1: generate_batch()
+};
+(window as any).update_batches = update_batches;
+
+export function BatchUpdateAssignment(type: BatchUpdateType) {
+ return function (constructor: Function) {
+ if(!ReactComponentBase.prototype.isPrototypeOf(constructor.prototype))
+ throw "Class/object isn't an instance of ReactComponentBase";
+
+ const didMount = constructor.prototype.componentDidMount;
+ constructor.prototype.componentDidMount = function() {
+ if(typeof this.update_batch === "undefined")
+ this.update_batch = update_batches[type];
+
+ if(typeof didMount === "function")
+ didMount.call(this, arguments);
+ };
+ }
+}
export abstract class ReactComponentBase extends React.Component {
+ private update_batch: UpdateBatch;
+
+ private batch_component_id: number;
+ private batch_component_force_id: number;
+
constructor(props: Properties) {
super(props);
+ this.batch_component_id = -1;
+ this.batch_component_force_id = -1;
- this.state = this.default_state();
+ this.state = this.defaultState();
this.initialize();
}
protected initialize() { }
- protected abstract default_state() : State;
+ protected defaultState() : State { return {} as State; }
- updateState(updates: {[key in keyof State]?: State[key]}, callback?: () => void) {
- if(Object.keys(updates).findIndex(e => updates[e] !== this.state[e]) === -1) {
- if(callback) setTimeout(callback, 0);
- return; /* no state has been changed */
+ setState(
+ state: ((prevState: Readonly, props: Readonly) => (Pick | State | null)) | (Pick | State | null),
+ callback?: () => void
+ ): void {
+ if(typeof this.update_batch !== "undefined" && this.update_batch.enabled) {
+ const obj = {
+ c: this,
+ s: Object.assign(this.update_batch.update[this.batch_component_id]?.s || {}, state),
+ b: callback
+ };
+ if(this.batch_component_id === -1)
+ this.batch_component_id = this.update_batch.update.push(obj) - 1;
+ else
+ this.update_batch.update[this.batch_component_id] = obj;
+ } else {
+ super.setState(state, callback);
+ }
+ }
+
+ forceUpdate(callback?: () => void): void {
+ if(typeof this.update_batch !== "undefined" && this.update_batch.enabled) {
+ const obj = {
+ c: this,
+ b: callback
+ };
+ if(this.batch_component_force_id === -1)
+ this.batch_component_force_id = this.update_batch.force.push(obj) - 1;
+ else
+ this.update_batch.force[this.batch_component_force_id] = obj;
+ } else {
+ super.forceUpdate(callback);
}
- this.setState(Object.assign(this.state, updates), callback);
}
protected classList(...classes: (string | undefined)[]) {
@@ -29,4 +108,45 @@ export abstract class ReactComponentBase extends React.Compon
return Array.isArray(this.props.children) ? this.props.children.length > 0 : true;
}
+}
+
+export function batch_updates(type: BatchUpdateType) {
+ const batch = update_batches[type];
+ if(typeof batch === "undefined") throw "unknown batch type";
+
+ batch.enabled = true;
+ batch.enable_count++;
+}
+
+export function flush_batched_updates(type: BatchUpdateType, force?: boolean) {
+ const batch = update_batches[type];
+ if(typeof batch === "undefined") throw "unknown batch type";
+ if(--batch.enable_count > 0 && !force) return;
+ if(batch.enable_count < 0) throw "flush_batched_updates called more than batch_updates!";
+
+ const updates = batch.update;
+ const forces = batch.force;
+
+ batch.update = [];
+ batch.force = [];
+ batch.enabled = batch.enable_count > 0;
+
+ ReactDOM.unstable_batchedUpdates(() => {
+ {
+ let index = updates.length;
+ while(index--) { /* fastest way to iterate */
+ const update = updates[index];
+ update.c.batch_component_id = -1;
+ update.c.setState(update.s, update.b);
+ }
+ }
+ {
+ let index = forces.length;
+ while(index--) { /* fastest way to iterate */
+ const update = forces[index];
+ update.c.batch_component_force_id = -1;
+ update.c.forceUpdate(update.b);
+ }
+ }
+ });
}
\ No newline at end of file
diff --git a/shared/js/ui/server.ts b/shared/js/ui/server.ts
index aef16da4..743feaae 100644
--- a/shared/js/ui/server.ts
+++ b/shared/js/ui/server.ts
@@ -14,6 +14,10 @@ import {server_connections} from "tc-shared/ui/frames/connection_handlers";
import {connection_log} from "tc-shared/ui/modal/ModalConnect";
import * as top_menu from "./frames/MenuBar";
import {control_bar_instance} from "tc-shared/ui/frames/control-bar";
+import { ServerEntry as ServerEntryView } from "./tree/Server";
+import * as React from "react";
+import {Registry} from "tc-shared/events";
+import {ChannelTreeEntry, ChannelTreeEntryEvents} from "tc-shared/ui/TreeEntry";
export class ServerProperties {
virtualserver_host: string = "";
@@ -122,11 +126,21 @@ export interface ServerAddress {
port: number;
}
-export class ServerEntry {
+export interface ServerEvents extends ChannelTreeEntryEvents {
+ notify_properties_updated: {
+ updated_properties: {[Key in keyof ServerProperties]: ServerProperties[Key]};
+ server_properties: ServerProperties
+ }
+}
+
+export class ServerEntry extends ChannelTreeEntry {
remote_address: ServerAddress;
channelTree: ChannelTree;
properties: ServerProperties;
+ readonly events: Registry;
+ readonly view: React.Ref;
+
private info_request_promise: Promise = undefined;
private info_request_promise_resolve: any = undefined;
private info_request_promise_reject: any = undefined;
@@ -138,56 +152,22 @@ export class ServerEntry {
lastInfoRequest: number = 0;
nextInfoRequest: number = 0;
- private _htmlTag: JQuery;
private _destroyed = false;
constructor(tree, name, address: ServerAddress) {
+ super();
+
+ this.events = new Registry();
+ this.view = React.createRef();
+
this.properties = new ServerProperties();
this.channelTree = tree;
- this.remote_address = Object.assign({}, address); /* close the address because it might get changed due to the DNS resolve */
+ this.remote_address = Object.assign({}, address); /* copy the address because it might get changed due to the DNS resolve */
this.properties.virtualserver_name = name;
}
- get htmlTag() {
- if(this._destroyed) throw "destoryed";
- if(this._htmlTag) return this._htmlTag;
-
- let tag = $.spawn("div").addClass("tree-entry server");
-
- /* unread marker */
- {
- tag.append(
- $.spawn("div")
- .addClass("marker-text-unread hidden")
- .attr("conversation", 0)
- );
- }
-
- tag.append(
- $.spawn("div")
- .addClass("server_type icon client-server_green")
- );
-
- tag.append(
- $.spawn("div")
- .addClass("name")
- .text(this.properties.virtualserver_name)
- );
-
- tag.append(
- $.spawn("div")
- .addClass("icon_property icon_empty")
- );
-
- return this._htmlTag = tag;
- }
-
destroy() {
this._destroyed = true;
- if(this._htmlTag) {
- this._htmlTag.remove();
- this._htmlTag = undefined;
- }
this.info_request_promise = undefined;
this.info_request_promise_resolve = undefined;
this.info_request_promise_reject = undefined;
@@ -196,33 +176,22 @@ export class ServerEntry {
this.remote_address = undefined;
}
- initializeListener(){
- this._htmlTag.on('click' ,() => {
- this.channelTree.onSelect(this);
- this.updateProperties(); /* just prepare to show some server info */
- });
+ protected onSelect(singleSelect: boolean) {
+ super.onSelect(singleSelect);
+ if(!singleSelect) return;
- if(!settings.static(Settings.KEY_DISABLE_CONTEXT_MENU, false)) {
- this.htmlTag.on("contextmenu", (event) => {
- event.preventDefault();
- if($.isArray(this.channelTree.currently_selected)) { //Multiselect
- (this.channelTree.currently_selected_context_callback || ((_) => null))(event);
- return;
- }
-
- this.channelTree.onSelect(this, true);
- this.spawnContextMenu(event.pageX, event.pageY, () => { this.channelTree.onSelect(undefined, true); });
- });
+ if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) {
+ this.channelTree.client.side_bar.channel_conversations().set_current_channel(0);
+ this.channelTree.client.side_bar.show_channel_conversations();
}
}
- spawnContextMenu(x: number, y: number, on_close: () => void = () => {}) {
- let trigger_close = true;
- contextmenu.spawn_context_menu(x, y, {
+ contextMenuItems() : contextmenu.MenuEntry[] {
+ return [
+ {
type: contextmenu.MenuEntryType.ENTRY,
name: tr("Show server info"),
callback: () => {
- trigger_close = false;
openServerInfo(this);
},
icon_class: "client-about"
@@ -277,7 +246,28 @@ export class ServerEntry {
visible: false, //TODO: Enable again as soon the new design is finished
callback: () => spawnAvatarList(this.channelTree.client)
},
- contextmenu.Entry.CLOSE(() => trigger_close ? on_close() : {})
+ {
+ type: contextmenu.MenuEntryType.HR,
+ name: ''
+ },
+ {
+ type: contextmenu.MenuEntryType.ENTRY,
+ icon_class: "client-channel_collapse_all",
+ name: tr("Collapse all channels"),
+ callback: () => this.channelTree.collapse_channels()
+ },
+ {
+ type: contextmenu.MenuEntryType.ENTRY,
+ icon_class: "client-channel_expand_all",
+ name: tr("Expend all channels"),
+ callback: () => this.channelTree.expand_channels()
+ },
+ ];
+ }
+
+ spawnContextMenu(x: number, y: number, on_close: () => void = () => {}) {
+ contextmenu.spawn_context_menu(x, y, ...this.contextMenuItems(),
+ contextmenu.Entry.CLOSE(on_close)
);
}
@@ -295,12 +285,11 @@ export class ServerEntry {
log.table(LogType.DEBUG, LogCategory.PERMISSIONS, "Server update properties", entries);
}
- let update_bannner = false, update_button = false;
+ let update_bannner = false, update_button = false, update_bookmarks = false;
for(let variable of variables) {
JSON.map_field_to(this.properties, variable.value, variable.key);
if(variable.key == "virtualserver_name") {
- this.htmlTag.find(".name").text(variable.value);
this.channelTree.client.tag_connection_handler.find(".server-name").text(variable.value);
server_connections.update_ui();
} else if(variable.key == "virtualserver_icon_id") {
@@ -308,30 +297,38 @@ export class ServerEntry {
* ATTENTION: This is required!
*/
this.properties.virtualserver_icon_id = variable.value as any >>> 0;
-
- 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);
- if(bmarks.length > 0) {
- bmarks.forEach(e => {
- e.last_icon_id = this.properties.virtualserver_icon_id;
- });
- bookmarks.save_bookmark();
- top_menu.rebuild_bookmarks();
-
- control_bar_instance()?.events().fire("update_state", { state: "bookmarks" });
- }
-
- if(this.channelTree.client.fileManager && this.channelTree.client.fileManager.icons)
- this.htmlTag.find(".icon_property").replaceWith(this.channelTree.client.fileManager.icons.generateTag(this.properties.virtualserver_icon_id).addClass("icon_property"));
+ update_bookmarks = true;
} else if(variable.key.indexOf('hostbanner') != -1) {
update_bannner = true;
} else if(variable.key.indexOf('hostbutton') != -1) {
update_button = true;
}
}
+ {
+ let properties = {};
+ for(const property of variables)
+ properties[property.key] = this.properties[property.key];
+ this.events.fire("notify_properties_updated", { updated_properties: properties as any, 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();
+ top_menu.rebuild_bookmarks();
+
+ control_bar_instance()?.events().fire("update_state", { state: "bookmarks" });
+ }
+ }
+
if(update_bannner)
this.channelTree.client.hostbanner.update();
+
if(update_button)
control_bar_instance()?.events().fire("server_updated", { handler: this.channelTree.client, category: "hostbanner" });
@@ -353,6 +350,7 @@ export class ServerEntry {
flag_password: this.properties.virtualserver_flag_password,
name: this.properties.virtualserver_name,
icon_id: this.properties.virtualserver_icon_id,
+ server_unique_id: this.properties.virtualserver_unique_identifier,
password_hash: undefined /* we've here no clue */
});
@@ -413,7 +411,11 @@ export class ServerEntry {
return this.properties.virtualserver_uptime + (new Date().getTime() - this.lastInfoRequest) / 1000;
}
- set flag_text_unread(flag: boolean) {
- this._htmlTag.find(".marker-text-unread").toggleClass("hidden", !flag);
+ reset() {
+ this.properties = new ServerProperties();
+ this._info_connection_promise = undefined;
+ this._info_connection_promise_reject = undefined;
+ this._info_connection_promise_resolve = undefined;
+ this._info_connection_promise_timestamp = undefined;
}
}
\ No newline at end of file
diff --git a/shared/js/ui/tree/Channel.scss b/shared/js/ui/tree/Channel.scss
new file mode 100644
index 00000000..26b8e890
--- /dev/null
+++ b/shared/js/ui/tree/Channel.scss
@@ -0,0 +1,113 @@
+.channelEntry {
+ position: relative;
+
+ display: flex;
+ flex-direction: row;
+ justify-content: stretch;
+
+ width: 100%;
+ min-height: 16px;
+
+ align-items: center;
+ cursor: pointer;
+
+ .containerArrow {
+ width: 16px;
+ margin-left: -16px;
+ text-align: center;
+
+ &.down {
+ align-self: normal;
+ }
+
+ :global .arrow {
+ border-color: hsla(220, 5%, 30%, 1);
+ }
+ }
+
+ .channelType {
+ flex-grow: 0;
+ flex-shrink: 0;
+
+ margin-right: 2px;
+ }
+
+ .containerChannelName {
+ display: flex;
+ flex-direction: row;
+
+ flex-grow: 1;
+ flex-shrink: 1;
+
+ justify-content: left;
+
+ max-width: 100%; /* important for the repetitive channel name! */
+ overflow-x: hidden;
+ height: 16px;
+
+ &.align-right {
+ justify-content: right;
+ }
+
+ &.align-center, &.align-repetitive {
+ justify-content: center;
+ }
+
+ .channelName {
+ align-self: center;
+ color: var(--channel-tree-entry-color);
+
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &.align-repetitive {
+ .channelName {
+ text-overflow: clip;
+ }
+ }
+ }
+
+ .icons {
+ display: flex;
+ flex-direction: row;
+
+ padding-right: 5px;
+
+ flex-grow: 0;
+ flex-shrink: 0;
+ }
+
+ &.moveSelected {
+ border-bottom: 1px solid black;
+ }
+
+ .showChannelNormalOnly {
+ display: none;
+
+ &.channelNormal {
+ display: block;
+ }
+ }
+
+ .icon_no_sound {
+ z-index: 0;
+
+ display: flex;
+ position: relative;
+
+ .background {
+ height: 14px;
+ width: 10px;
+
+ background: red;
+ position: absolute;
+
+ top: 1px;
+ left: 3px;
+ z-index: -1;
+ }
+ }
+}
\ No newline at end of file
diff --git a/shared/js/ui/tree/Channel.tsx b/shared/js/ui/tree/Channel.tsx
new file mode 100644
index 00000000..daa7fa61
--- /dev/null
+++ b/shared/js/ui/tree/Channel.tsx
@@ -0,0 +1,302 @@
+import {
+ BatchUpdateAssignment,
+ BatchUpdateType,
+ ReactComponentBase
+} from "tc-shared/ui/react-elements/ReactComponentBase";
+import * as React from "react";
+import {ChannelEntry as ChannelEntryController, ChannelEvents, ChannelProperties} from "../channel";
+import {LocalIconRenderer} from "tc-shared/ui/react-elements/Icon";
+import {EventHandler, ReactEventHandler} from "tc-shared/events";
+import {Settings, settings} from "tc-shared/settings";
+import {TreeEntry, UnreadMarker} from "tc-shared/ui/tree/TreeEntry";
+
+const channelStyle = require("./Channel.scss");
+const viewStyle = require("./View.scss");
+
+interface ChannelEntryIconsProperties {
+ channel: ChannelEntryController;
+}
+
+interface ChannelEntryIconsState {
+ icons_shown: boolean;
+
+ is_default: boolean;
+ is_password_protected: boolean;
+ is_music_quality: boolean;
+ is_moderated: boolean;
+ is_codec_supported: boolean;
+
+ custom_icon_id: number;
+}
+
+@ReactEventHandler(e => e.props.channel.events)
+@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
+class ChannelEntryIcons extends ReactComponentBase {
+ private static readonly SimpleIcon = (props: { iconClass: string, title: string }) => {
+ return
+ };
+
+ protected defaultState(): ChannelEntryIconsState {
+ const properties = this.props.channel.properties;
+ const server_connection = this.props.channel.channelTree.client.serverConnection;
+
+ return {
+ icons_shown: this.props.channel.parsed_channel_name.alignment === "normal",
+ custom_icon_id: properties.channel_icon_id,
+ is_music_quality: properties.channel_codec === 3 || properties.channel_codec === 5,
+ is_codec_supported: server_connection.support_voice() && server_connection.voice_connection().decoding_supported(properties.channel_codec),
+ is_default: properties.channel_flag_default,
+ is_password_protected: properties.channel_flag_password,
+ is_moderated: properties.channel_needed_talk_power !== 0
+ }
+ }
+
+ render() {
+ let icons = [];
+
+ if(!this.state.icons_shown)
+ return null;
+
+ if(this.state.is_default)
+ icons.push();
+
+ if(this.state.is_password_protected)
+ icons.push(); //TODO: "client-register" is really the right icon?
+
+ if(this.state.is_music_quality)
+ icons.push();
+
+ if(this.state.is_moderated)
+ icons.push();
+
+ if(this.state.custom_icon_id)
+ icons.push();
+
+ if(!this.state.is_codec_supported) {
+ icons.push();
+ }
+
+ return
+ {icons}
+
+ }
+
+ @EventHandler("notify_properties_updated")
+ private handlePropertiesUpdate(event: ChannelEvents["notify_properties_updated"]) {
+ if(typeof event.updated_properties.channel_icon_id !== "undefined")
+ this.setState({ custom_icon_id: event.updated_properties.channel_icon_id });
+
+ if(typeof event.updated_properties.channel_codec !== "undefined" || typeof event.updated_properties.channel_codec_quality !== "undefined") {
+ const codec = event.channel_properties.channel_codec;
+ this.setState({ is_music_quality: codec === 3 || codec === 5 });
+ }
+
+ if(typeof event.updated_properties.channel_codec !== "undefined") {
+ const server_connection = this.props.channel.channelTree.client.serverConnection;
+ this.setState({ is_codec_supported: server_connection.support_voice() && server_connection.voice_connection().decoding_supported(event.channel_properties.channel_codec) });
+ }
+
+ if(typeof event.updated_properties.channel_flag_default !== "undefined")
+ this.setState({ is_default: event.updated_properties.channel_flag_default });
+
+ if(typeof event.updated_properties.channel_flag_password !== "undefined")
+ this.setState({ is_password_protected: event.updated_properties.channel_flag_password });
+
+ if(typeof event.updated_properties.channel_needed_talk_power !== "undefined")
+ this.setState({ is_moderated: event.channel_properties.channel_needed_talk_power !== 0 });
+
+ if(typeof event.updated_properties.channel_name !== "undefined")
+ this.setState({ icons_shown: this.props.channel.parsed_channel_name.alignment === "normal" });
+ }
+}
+
+interface ChannelEntryIconProperties {
+ channel: ChannelEntryController;
+}
+
+@ReactEventHandler(e => e.props.channel.events)
+@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
+class ChannelEntryIcon extends ReactComponentBase {
+ private static readonly IconUpdateKeys: (keyof ChannelProperties)[] = [
+ "channel_name",
+ "channel_flag_password",
+
+ "channel_maxclients",
+ "channel_flag_maxclients_unlimited",
+
+ "channel_maxfamilyclients",
+ "channel_flag_maxfamilyclients_inherited",
+ "channel_flag_maxfamilyclients_unlimited",
+ ];
+
+ render() {
+ if(this.props.channel.formattedChannelName() !== this.props.channel.channelName())
+ return null;
+
+ const channel_properties = this.props.channel.properties;
+
+ let type;
+ if(channel_properties.channel_flag_password === true && !this.props.channel.cached_password())
+ type = "yellow";
+ else if(!channel_properties.channel_flag_maxclients_unlimited && this.props.channel.clients().length >= channel_properties.channel_maxclients)
+ type = "red";
+ else if(!channel_properties.channel_flag_maxfamilyclients_unlimited && channel_properties.channel_maxfamilyclients >= 0 && this.props.channel.clients(true).length >= channel_properties.channel_maxfamilyclients)
+ type = "red";
+ else
+ type = "green";
+
+ return ;
+ }
+
+ @EventHandler("notify_properties_updated")
+ private handlePropertiesUpdate(event: ChannelEvents["notify_properties_updated"]) {
+ for(const key of ChannelEntryIcon.IconUpdateKeys) {
+ if(key in event.updated_properties) {
+ this.forceUpdate();
+ return;
+ }
+ }
+ }
+
+ /* A client change may cause the channel to show another flag */
+ @EventHandler("notify_clients_changed")
+ private handleClientsUpdated() {
+ this.forceUpdate();
+ }
+
+ @EventHandler("notify_cached_password_updated")
+ private handleCachedPasswordUpdate() {
+ this.forceUpdate();
+ }
+
+ @EventHandler("notify_subscribe_state_changed")
+ private handleSubscribeModeChanges() {
+ this.forceUpdate();
+ }
+}
+
+interface ChannelEntryNameProperties {
+ channel: ChannelEntryController;
+}
+
+@ReactEventHandler(e => e.props.channel.events)
+@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
+class ChannelEntryName extends ReactComponentBase {
+
+ render() {
+ const name = this.props.channel.parsed_channel_name;
+ let class_name: string;
+ let text: string;
+ if(name.repetitive) {
+ class_name = "align-repetitive";
+ text = name.text;
+ if(text.length) {
+ while(text.length < 8000)
+ text += text;
+ }
+ } else {
+ text = name.text;
+ class_name = "align-" + name.alignment;
+ }
+
+ return ;
+ }
+
+ @EventHandler("notify_properties_updated")
+ private handlePropertiesUpdate(event: ChannelEvents["notify_properties_updated"]) {
+ if(typeof event.updated_properties.channel_name !== "undefined")
+ this.forceUpdate();
+ }
+}
+
+interface ChannelEntryViewProperties {
+ channel: ChannelEntryController;
+ depth: number;
+ offset: number;
+}
+
+
+const ChannelCollapsedIndicator = (props: { collapsed: boolean, onToggle: () => void }) => {
+ return {
+ event.preventDefault();
+ props.onToggle();
+ }} />
+};
+
+@ReactEventHandler
(e => e.props.channel.events)
+@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
+export class ChannelEntryView extends TreeEntry {
+ shouldComponentUpdate(nextProps: Readonly, nextState: Readonly<{}>, nextContext: any): boolean {
+ if(nextProps.offset !== this.props.offset)
+ return true;
+ if(nextProps.depth !== this.props.depth)
+ return true;
+
+ return nextProps.channel !== this.props.channel;
+ }
+
+ render() {
+ const collapsed_indicator = this.props.channel.child_channel_head || this.props.channel.clients(false).length > 0;
+ return this.onMouseUp(e)}
+ onDoubleClick={() => this.onDoubleClick()}
+ onContextMenu={e => this.onContextMenu(e)}
+ >
+
+ {collapsed_indicator && this.onCollapsedToggle()} collapsed={this.props.channel.collapsed} />}
+
+
+
+
;
+ }
+
+ private onCollapsedToggle() {
+ this.props.channel.collapsed = !this.props.channel.collapsed;
+ }
+
+ private onMouseUp(event: React.MouseEvent) {
+ if(event.button !== 0) return; /* only left mouse clicks */
+
+ const channel = this.props.channel;
+ if(channel.channelTree.isClientMoveActive()) return;
+
+ channel.channelTree.events.fire("action_select_entries", {
+ entries: [ channel ],
+ mode: "auto"
+ });
+ }
+
+ private onDoubleClick() {
+ const channel = this.props.channel;
+ if(channel.channelTree.selection.is_multi_select()) return;
+
+ channel.joinChannel();
+ }
+
+ private onContextMenu(event: React.MouseEvent) {
+ if(settings.static(Settings.KEY_DISABLE_CONTEXT_MENU))
+ return;
+
+ event.preventDefault();
+ const channel = this.props.channel;
+ if(channel.channelTree.selection.is_multi_select() && channel.isSelected())
+ return;
+
+ channel.channelTree.events.fire("action_select_entries", {
+ entries: [ channel ],
+ mode: "exclusive"
+ });
+ channel.showContextMenu(event.pageX, event.pageY);
+ }
+
+ @EventHandler("notify_select_state_change")
+ private handleSelectStateChange() {
+ this.forceUpdate();
+ }
+}
\ No newline at end of file
diff --git a/shared/js/ui/tree/Client.scss b/shared/js/ui/tree/Client.scss
new file mode 100644
index 00000000..e06f9186
--- /dev/null
+++ b/shared/js/ui/tree/Client.scss
@@ -0,0 +1,104 @@
+@import "../../../css/static/mixin";
+
+.clientEntry {
+ cursor: pointer;
+
+ display: flex;
+ flex-direction: row;
+
+ align-items: center;
+
+ > div {
+ margin-right: 2px;
+ }
+
+ .clientName {
+ line-height: 16px;
+ min-width: 2em;
+
+ flex-grow: 0;
+ flex-shrink: 1;
+
+ padding-right: .25em;
+ color: var(--channel-tree-entry-color);
+
+ &:not(.edit) {
+ @include text-dotdotdot();
+ }
+
+ &.clientNameOwn {
+ font-weight: bold;
+ }
+
+ &.edit {
+ width: 100%;
+ font-weight: normal;
+
+ color: black;
+ background-color: white;
+
+ overflow-y: hidden;
+ overflow-x: hidden;
+ }
+ }
+
+ .clientAwayMessage {
+ color: var(--channel-tree-entry-color);
+ }
+
+ .containerIcons {
+ margin-right: 0; /* override from previous thing */
+ height: 100%;
+
+ position: absolute;
+ right: 0;
+ padding-right: 5px;
+ padding-left: 4px;
+
+ display: flex;
+ flex-direction: row;
+
+ align-items: center;
+
+ .containerIconsGroup {
+ display: flex;
+ flex-direction: row;
+
+ .containerHroupIcon {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ }
+ }
+ }
+
+ &.selected {
+ &:focus-within {
+ .containerIcons {
+ background-color: var(--channel-tree-entry-selected); /* overpaint the name change box */
+
+ padding-left: 5px;
+ z-index: 1001; /* show before client name */
+
+ height: 18px;
+ }
+ }
+
+ .clientName {
+ &:focus {
+ position: absolute;
+ color: black;
+
+ padding-top: 1px;
+ padding-bottom: 1px;
+
+ z-index: 1000;
+
+ margin-right: -10px;
+ margin-left: 18px;
+
+ width: 100%;
+ }
+ }
+ }
+ }
\ No newline at end of file
diff --git a/shared/js/ui/tree/Client.tsx b/shared/js/ui/tree/Client.tsx
new file mode 100644
index 00000000..b3f12183
--- /dev/null
+++ b/shared/js/ui/tree/Client.tsx
@@ -0,0 +1,433 @@
+import {
+ BatchUpdateAssignment,
+ BatchUpdateType,
+ ReactComponentBase
+} from "tc-shared/ui/react-elements/ReactComponentBase";
+import * as React from "react";
+import {
+ ClientEntry as ClientEntryController,
+ ClientEvents,
+ ClientProperties,
+ ClientType,
+ LocalClientEntry, MusicClientEntry
+} from "../client";
+import {EventHandler, ReactEventHandler} from "tc-shared/events";
+import {Group, GroupEvents} from "tc-shared/permission/GroupManager";
+import {Settings, settings} from "tc-shared/settings";
+import {TreeEntry, UnreadMarker} from "tc-shared/ui/tree/TreeEntry";
+import {LocalIconRenderer} from "tc-shared/ui/react-elements/Icon";
+import * as DOMPurify from "dompurify";
+
+const clientStyle = require("./Client.scss");
+const viewStyle = require("./View.scss");
+
+interface ClientIconProperties {
+ client: ClientEntryController;
+}
+
+@ReactEventHandler(e => e.props.client.events)
+@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
+class ClientSpeakIcon extends ReactComponentBase {
+ private static readonly IconUpdateKeys: (keyof ClientProperties)[] = [
+ "client_away",
+ "client_input_hardware",
+ "client_output_hardware",
+ "client_output_muted",
+ "client_input_muted",
+ "client_is_channel_commander",
+ "client_talk_power"
+ ];
+
+ render() {
+ let icon: string = "";
+ let clicon: string = "";
+
+ const client = this.props.client;
+ const properties = client.properties;
+
+ if(properties.client_type_exact == ClientType.CLIENT_QUERY) {
+ icon = "client-server_query";
+ } else {
+ if (properties.client_away) {
+ icon = "client-away";
+ } else if (!client.get_audio_handle() && !(this instanceof LocalClientEntry)) {
+ icon = "client-input_muted_local";
+ } else if(!properties.client_output_hardware) {
+ icon = "client-hardware_output_muted";
+ } else if(properties.client_output_muted) {
+ icon = "client-output_muted";
+ } else if(!properties.client_input_hardware) {
+ icon = "client-hardware_input_muted";
+ } else if(properties.client_input_muted) {
+ icon = "client-input_muted";
+ } else {
+ if(client.isSpeaking()) {
+ if(properties.client_is_channel_commander)
+ clicon = "client_cc_talk";
+ else
+ clicon = "client_talk";
+ } else {
+ if(properties.client_is_channel_commander)
+ clicon = "client_cc_idle";
+ else
+ clicon = "client_idle";
+ }
+ }
+ }
+
+ if(clicon.length > 0)
+ return ;
+ else if(icon.length > 0)
+ return ;
+ else
+ return null;
+ }
+
+ @EventHandler("notify_properties_updated")
+ private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) {
+ for(const key of ClientSpeakIcon.IconUpdateKeys)
+ if(key in event.updated_properties) {
+ this.forceUpdate();
+ return;
+ }
+ }
+
+ @EventHandler("notify_mute_state_change")
+ private handleMuteStateChange() {
+ this.forceUpdate();
+ }
+
+ @EventHandler("notify_speak_state_change")
+ private handleSpeakStateChange() {
+ this.forceUpdate();
+ }
+}
+
+interface ClientServerGroupIconsProperties {
+ client: ClientEntryController;
+}
+
+@ReactEventHandler(e => e.props.client.events)
+@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
+class ClientServerGroupIcons extends ReactComponentBase {
+ private subscribed_groups: Group[] = [];
+ private group_updated_callback;
+
+ protected initialize() {
+ this.group_updated_callback = (event: GroupEvents["notify_properties_updated"]) => {
+ if(typeof event.updated_properties.iconid !== "undefined" || typeof event.updated_properties.sortid !== "undefined")
+ this.forceUpdate();
+ };
+ }
+
+ private unsubscribeGroupEvents() {
+ this.subscribed_groups.forEach(e => e.events.off("notify_properties_updated", this.group_updated_callback));
+ this.subscribed_groups = [];
+ }
+
+ componentWillUnmount(): void {
+ this.unsubscribeGroupEvents();
+ }
+
+ render() {
+ this.unsubscribeGroupEvents();
+
+ const groups = this.props.client.assignedServerGroupIds()
+ .map(e => this.props.client.channelTree.client.groups.serverGroup(e)).filter(e => !!e);
+ if(groups.length === 0) return null;
+
+ groups.forEach(e => {
+ e.events.on("notify_properties_updated", this.group_updated_callback);
+ this.subscribed_groups.push(e);
+ });
+
+ const group_icons = groups.filter(e => e?.properties.iconid)
+ .sort((a, b) => a.properties.sortid - b.properties.sortid);
+ if(group_icons.length === 0) return null;
+ return [
+ group_icons.map(e => {
+ return ;
+ })
+ ];
+ }
+
+ @EventHandler("notify_properties_updated")
+ private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) {
+ if(typeof event.updated_properties.client_servergroups)
+ this.forceUpdate();
+ }
+}
+
+interface ClientChannelGroupIconProperties {
+ client: ClientEntryController;
+}
+
+@ReactEventHandler(e => e.props.client.events)
+@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
+class ClientChannelGroupIcon extends ReactComponentBase {
+ private subscribed_group: Group | undefined;
+ private group_updated_callback;
+
+ protected initialize() {
+ this.group_updated_callback = (event: GroupEvents["notify_properties_updated"]) => {
+ if(typeof event.updated_properties.iconid !== "undefined" || typeof event.updated_properties.sortid !== "undefined")
+ this.forceUpdate();
+ };
+ }
+
+ private unsubscribeGroupEvent() {
+ this.subscribed_group?.events.off("notify_properties_updated", this.group_updated_callback);
+ }
+
+ componentWillUnmount(): void {
+ this.unsubscribeGroupEvent();
+ }
+
+ render() {
+ this.unsubscribeGroupEvent();
+
+ const cgid = this.props.client.assignedChannelGroup();
+ if(cgid === 0) return null;
+
+ const channel_group = this.props.client.channelTree.client.groups.channelGroup(cgid);
+ if(!channel_group) return null;
+
+ channel_group.events.on("notify_properties_updated", this.group_updated_callback);
+ this.subscribed_group = channel_group;
+
+ if(channel_group.properties.iconid === 0) return null;
+ return ;
+ }
+
+ @EventHandler("notify_properties_updated")
+ private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) {
+ if(typeof event.updated_properties.client_servergroups)
+ this.forceUpdate();
+ }
+}
+
+interface ClientIconsProperties {
+ client: ClientEntryController;
+}
+
+@ReactEventHandler(e => e.props.client.events)
+@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
+class ClientIcons extends ReactComponentBase {
+ render() {
+ const icons = [];
+ const talk_power = this.props.client.properties.client_talk_power;
+ const needed_talk_power = this.props.client.currentChannel()?.properties.channel_needed_talk_power || 0;
+ if(talk_power !== -1 && needed_talk_power !== 0 && needed_talk_power > talk_power)
+ icons.push();
+
+ icons.push();
+ icons.push();
+ if(this.props.client.properties.client_icon_id !== 0)
+ icons.push();
+
+ return (
+
+ {icons}
+
+ )
+ }
+
+ @EventHandler("notify_properties_updated")
+ private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) {
+ if(typeof event.updated_properties.client_channel_group_id !== "undefined" || typeof event.updated_properties.client_talk_power !== "undefined" || typeof event.updated_properties.client_icon_id !== "undefined")
+ this.forceUpdate();
+ }
+}
+
+interface ClientNameProperties {
+ client: ClientEntryController;
+}
+
+interface ClientNameState {
+ group_prefix: string;
+ group_suffix: string;
+
+ away_message: string;
+}
+
+/* group prefix & suffix, away message */
+@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
+class ClientName extends ReactComponentBase {
+ protected defaultState(): ClientNameState {
+ return {
+ group_prefix: "",
+ away_message: "",
+ group_suffix: ""
+ }
+ }
+
+ render() {
+ return
+ {this.state.group_prefix + this.props.client.clientNickName() + this.state.group_suffix + this.state.away_message}
+
+ }
+
+ @EventHandler("notify_properties_updated")
+ private handlePropertiesChanged(event: ClientEvents["notify_properties_updated"]) {
+ if(typeof event.updated_properties.client_away !== "undefined" || typeof event.updated_properties.client_away_message !== "undefined") {
+ this.setState({
+ away_message: event.client_properties.client_away_message && " [" + event.client_properties.client_away_message + "]"
+ });
+ }
+ if(typeof event.updated_properties.client_servergroups !== "undefined" || typeof event.updated_properties.client_channel_group_id !== "undefined") {
+ let prefix_groups: string[] = [];
+ let suffix_groups: string[] = [];
+ for(const group_id of this.props.client.assignedServerGroupIds()) {
+ const group = this.props.client.channelTree.client.groups.serverGroup(group_id);
+ if(!group) continue;
+
+ if(group.properties.namemode == 1)
+ prefix_groups.push(group.name);
+ else if(group.properties.namemode == 2)
+ suffix_groups.push(group.name);
+ }
+
+ const channel_group = this.props.client.channelTree.client.groups.channelGroup(this.props.client.assignedChannelGroup());
+ if(channel_group) {
+ if(channel_group.properties.namemode == 1)
+ prefix_groups.push(channel_group.name);
+ else if(channel_group.properties.namemode == 2)
+ suffix_groups.splice(0, 0, channel_group.name);
+ }
+
+ this.setState({
+ group_suffix: suffix_groups.map(e => "[" + e + "]").join(""),
+ group_prefix: prefix_groups.map(e => "[" + e + "]").join("")
+ })
+ }
+ }
+}
+
+interface ClientNameEditProps {
+ editFinished: (new_name?: string) => void;
+ initialName: string;
+}
+
+class ClientNameEdit extends ReactComponentBase {
+ private readonly ref_div: React.RefObject = React.createRef();
+
+ componentDidMount(): void {
+ this.ref_div.current.focus();
+ }
+
+ render() {
+ return this.onBlur()}
+ onKeyPress={e => this.onKeyPress(e)}
+ />
+ }
+
+ private onBlur() {
+ this.props.editFinished(this.ref_div.current.textContent);
+ }
+
+ private onKeyPress(event: React.KeyboardEvent) {
+ if(event.key === "Enter") {
+ event.preventDefault();
+ this.onBlur();
+ }
+ }
+}
+
+export interface ClientEntryProperties {
+ client: ClientEntryController;
+ depth: number;
+ offset: number;
+}
+
+export interface ClientEntryState {
+ rename: boolean;
+ renameInitialName?: string;
+}
+
+@ReactEventHandler
(e => e.props.client.events)
+@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
+export class ClientEntry extends TreeEntry {
+ shouldComponentUpdate(nextProps: Readonly, nextState: Readonly, nextContext: any): boolean {
+ return nextState.rename !== this.state.rename ||
+ nextProps.offset !== this.props.offset ||
+ nextProps.client !== this.props.client ||
+ nextProps.depth !== this.props.depth;
+ }
+
+ render() {
+ return (
+ this.onDoubleClick()}
+ onMouseUp={e => this.onMouseUp(e)}
+ onContextMenu={e => this.onContextMenu(e)}
+ >
+
+
+ {this.state.rename ?
+ this.onEditFinished(name)} initialName={this.state.renameInitialName || this.props.client.properties.client_nickname} /> :
+ [, ] }
+
+ )
+ }
+
+ private onDoubleClick() {
+ const client = this.props.client;
+ if(client.channelTree.selection.is_multi_select()) return;
+
+ if(this.props.client instanceof LocalClientEntry) {
+ this.props.client.openRename();
+ } else if(this.props.client instanceof MusicClientEntry) {
+ /* no action defined yet */
+ } else {
+ this.props.client.open_text_chat();
+ }
+ }
+
+ private onEditFinished(new_name?: string) {
+ if(!(this.props.client instanceof LocalClientEntry))
+ throw "Only local clients could be renamed";
+
+ if(new_name && new_name !== this.state.renameInitialName) {
+ const client = this.props.client;
+ client.renameSelf(new_name).then(result => {
+ if(!result)
+ this.setState({ rename: true, renameInitialName: new_name }); //TODO: Keep last name?
+ });
+ }
+ this.setState({ rename: false });
+ }
+
+ private onMouseUp(event: React.MouseEvent) {
+ if(event.button !== 0) return; /* only left mouse clicks */
+ const tree = this.props.client.channelTree;
+ if(tree.isClientMoveActive()) return;
+
+ tree.events.fire("action_select_entries", { entries: [this.props.client], mode: "auto" });
+ }
+
+ private onContextMenu(event: React.MouseEvent) {
+ if(settings.static(Settings.KEY_DISABLE_CONTEXT_MENU))
+ return;
+
+ event.preventDefault();
+ const client = this.props.client;
+ if(client.channelTree.selection.is_multi_select() && client.isSelected()) return;
+
+ client.channelTree.events.fire("action_select_entries", {
+ entries: [ client ],
+ mode: "exclusive"
+ });
+ client.showContextMenu(event.pageX, event.pageY);
+ }
+
+ @EventHandler("notify_select_state_change")
+ private handleSelectChangeState() {
+ this.forceUpdate();
+ }
+}
\ No newline at end of file
diff --git a/shared/js/ui/tree/Server.scss b/shared/js/ui/tree/Server.scss
new file mode 100644
index 00000000..4509457c
--- /dev/null
+++ b/shared/js/ui/tree/Server.scss
@@ -0,0 +1,33 @@
+.serverEntry {
+ display: flex;
+ flex-direction: row;
+ justify-content: stretch;
+
+ position: relative;
+
+ cursor: pointer;
+ padding-left: 2px;
+ padding-right: 5px;
+
+ .server_type {
+ flex-grow: 0;
+ flex-shrink: 0;
+
+ margin-right: 2px;
+
+ z-index: 1;
+ }
+
+ .name {
+ flex-grow: 1;
+ flex-shrink: 1;
+
+ align-self: center;
+ color: var(--channel-tree-entry-color);
+
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
\ No newline at end of file
diff --git a/shared/js/ui/tree/Server.tsx b/shared/js/ui/tree/Server.tsx
new file mode 100644
index 00000000..9cc6f8e7
--- /dev/null
+++ b/shared/js/ui/tree/Server.tsx
@@ -0,0 +1,127 @@
+import {
+ BatchUpdateAssignment,
+ BatchUpdateType,
+ ReactComponentBase
+} from "tc-shared/ui/react-elements/ReactComponentBase";
+import {ServerEntry as ServerEntryController, ServerEvents} from "../server";
+import * as React from "react";
+import {LocalIconRenderer} from "tc-shared/ui/react-elements/Icon";
+import {EventHandler, ReactEventHandler} from "tc-shared/events";
+import {Settings, settings} from "tc-shared/settings";
+import {TreeEntry, UnreadMarker} from "tc-shared/ui/tree/TreeEntry";
+import {ConnectionEvents, ConnectionState} from "tc-shared/ConnectionHandler";
+
+const serverStyle = require("./Server.scss");
+const viewStyle = require("./View.scss");
+
+
+export interface ServerEntryProperties {
+ server: ServerEntryController;
+ offset: number;
+}
+
+export interface ServerEntryState {
+ connection_state: "connected" | "connecting" | "disconnected";
+}
+
+@ReactEventHandler(e => e.props.server.events)
+@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
+export class ServerEntry extends TreeEntry {
+ private handle_connection_state_change;
+
+ protected defaultState(): ServerEntryState {
+ return { connection_state: "disconnected" };
+ }
+
+ protected initialize() {
+ this.handle_connection_state_change = (event: ConnectionEvents["notify_connection_state_changed"]) => {
+ switch (event.new_state) {
+ case ConnectionState.AUTHENTICATING:
+ case ConnectionState.CONNECTING:
+ case ConnectionState.INITIALISING:
+ this.setState({ connection_state: "connecting" });
+ break;
+
+ case ConnectionState.CONNECTED:
+ this.setState({ connection_state: "connected" });
+ break;
+
+ case ConnectionState.DISCONNECTING:
+ case ConnectionState.UNCONNECTED:
+ this.setState({ connection_state: "disconnected" });
+ break;
+ }
+ }
+ }
+
+ shouldComponentUpdate(nextProps: Readonly, nextState: Readonly, nextContext: any): boolean {
+ return this.state.connection_state !== nextState.connection_state ||
+ this.props.offset !== nextProps.offset ||
+ this.props.server !== nextProps.server;
+ }
+
+ componentDidMount(): void {
+ this.props.server.channelTree.client.events().on("notify_connection_state_changed", this.handle_connection_state_change);
+ }
+
+ componentWillUnmount(): void {
+ this.props.server.channelTree.client.events().off("notify_connection_state_changed", this.handle_connection_state_change);
+ }
+
+ render() {
+ let name = this.props.server.properties.virtualserver_name;
+ if(this.state.connection_state === "disconnected")
+ name = tr("Not connected to any server");
+ else if(this.state.connection_state === "connecting")
+ name = tr("Connecting to ") + this.props.server.remote_address.host + (this.props.server.remote_address.port !== 9987 ? ":" + this.props.server.remote_address.host : "");
+
+ return this.onMouseUp(e)}
+ onContextMenu={e => this.onContextMenu(e)}
+ >
+
+
+
{name}
+
+
+ }
+
+ private onMouseUp(event: React.MouseEvent) {
+ if(event.button !== 0) return; /* only left mouse clicks */
+ if(this.props.server.channelTree.isClientMoveActive()) return;
+
+ this.props.server.channelTree.events.fire("action_select_entries", {
+ entries: [ this.props.server ],
+ mode: "auto"
+ });
+ }
+
+ private onContextMenu(event: React.MouseEvent) {
+ if(settings.static(Settings.KEY_DISABLE_CONTEXT_MENU))
+ return;
+
+ event.preventDefault();
+ const server = this.props.server;
+ if(server.channelTree.selection.is_multi_select() && server.isSelected())
+ return;
+
+ server.channelTree.events.fire("action_select_entries", {
+ entries: [ server ],
+ mode: "exclusive"
+ });
+ server.spawnContextMenu(event.pageX, event.pageY);
+ }
+
+ @EventHandler("notify_properties_updated")
+ private handlePropertiesUpdated(event: ServerEvents["notify_properties_updated"]) {
+ if(typeof event.updated_properties.virtualserver_name !== "undefined" || typeof event.updated_properties.virtualserver_icon_id !== "undefined") {
+ this.forceUpdate();
+ }
+ }
+
+ @EventHandler("notify_select_state_change")
+ private handleServerSelectStateChange() {
+ this.forceUpdate();
+ }
+}
\ No newline at end of file
diff --git a/shared/js/ui/tree/TreeEntry.tsx b/shared/js/ui/tree/TreeEntry.tsx
new file mode 100644
index 00000000..efa596e1
--- /dev/null
+++ b/shared/js/ui/tree/TreeEntry.tsx
@@ -0,0 +1,26 @@
+import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
+import {ChannelTreeEntry, ChannelTreeEntryEvents} from "tc-shared/ui/TreeEntry";
+import * as React from "react";
+import {EventHandler, ReactEventHandler} from "tc-shared/events";
+
+const viewStyle = require("./View.scss");
+
+export interface UnreadMarkerProperties {
+ entry: ChannelTreeEntry;
+}
+
+@ReactEventHandler(e => e.props.entry.events)
+export class UnreadMarker extends ReactComponentBase {
+ render() {
+ if(!this.props.entry.isUnread())
+ return null;
+ return ;
+ }
+
+ @EventHandler("notify_unread_state_change")
+ private handleUnreadStateChange() {
+ this.forceUpdate();
+ }
+}
+
+export class TreeEntry extends ReactComponentBase { }
\ No newline at end of file
diff --git a/shared/js/ui/tree/TreeEntryMove.scss b/shared/js/ui/tree/TreeEntryMove.scss
new file mode 100644
index 00000000..5f2bdb3a
--- /dev/null
+++ b/shared/js/ui/tree/TreeEntryMove.scss
@@ -0,0 +1,22 @@
+html:root {
+ --channel-tree-move-color: hsla(220, 5%, 2%, 1);
+ --channel-tree-move-background: hsla(0, 0%, 25%, 1);
+ --channel-tree-move-border: hsla(220, 4%, 40%, 1);
+}
+
+.moveContainer {
+ position: absolute;
+ display: block;
+
+ border: 2px solid var(--channel-tree-move-border);
+ background-color: var(--channel-tree-move-background);
+
+ z-index: 10000;
+ margin-left: 5px;
+
+ padding-left: .25em;
+ padding-right: .25em;
+
+ border-radius: 2px;
+ color: var(--channel-tree-move-color);
+}
\ No newline at end of file
diff --git a/shared/js/ui/tree/TreeEntryMove.tsx b/shared/js/ui/tree/TreeEntryMove.tsx
new file mode 100644
index 00000000..5035a8c3
--- /dev/null
+++ b/shared/js/ui/tree/TreeEntryMove.tsx
@@ -0,0 +1,95 @@
+import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
+import * as React from "react";
+import * as ReactDOM from "react-dom";
+import {ChannelTreeView} from "tc-shared/ui/tree/View";
+const moveStyle = require("./TreeEntryMove.scss");
+
+export interface TreeEntryMoveProps {
+ onMoveEnd: (point: { x: number, y: number }) => void;
+}
+
+export interface TreeEntryMoveState {
+ tree_view: ChannelTreeView;
+
+ begin: { x: number, y: number };
+ description: string;
+}
+
+export class TreeEntryMove extends ReactComponentBase {
+ private readonly domContainer;
+ private readonly document_mouse_out_listener;
+ private readonly document_mouse_listener;
+ private readonly ref_container: React.RefObject;
+
+ private current: { x: number, y: number };
+
+ constructor(props) {
+ super(props);
+
+ this.ref_container = React.createRef();
+ this.domContainer = document.getElementById("mouse-move");
+ this.document_mouse_out_listener = (e: MouseEvent) => {
+ if(e.type === "mouseup") {
+ if(e.button !== 0) return;
+
+ this.props.onMoveEnd({ x: e.pageX, y: e.pageY });
+ }
+
+ this.disableEntryMove();
+ };
+
+ this.document_mouse_listener = (e: MouseEvent) => {
+ this.current = { x: e.pageX, y: e.pageY };
+ const container = this.ref_container.current;
+ if(!container) return;
+
+ container.style.top = e.pageY + "px";
+ container.style.left = e.pageX + "px";
+ };
+ }
+
+ enableEntryMove(view: ChannelTreeView, description: string, begin: { x: number, y: null }, current: { x: number, y: null }, callback_enabled?: () => void) {
+ this.setState({
+ tree_view: view,
+ begin: begin,
+ description: description
+ }, callback_enabled);
+
+ this.current = current;
+ document.addEventListener("mousemove", this.document_mouse_listener);
+ document.addEventListener("mouseleave", this.document_mouse_out_listener);
+ document.addEventListener("mouseup", this.document_mouse_out_listener);
+ }
+
+ private disableEntryMove() {
+ this.setState({
+ tree_view: null
+ });
+ document.removeEventListener("mousemove", this.document_mouse_listener);
+ document.removeEventListener("mouseleave", this.document_mouse_out_listener);
+ document.removeEventListener("mouseup", this.document_mouse_out_listener);
+ }
+
+ protected defaultState(): TreeEntryMoveState {
+ return {
+ tree_view: null,
+ begin: { x: 0, y: 0},
+ description: ""
+ }
+ }
+
+ isActive() { return !!this.state.tree_view; }
+
+ render() {
+ if(!this.state.tree_view)
+ return null;
+
+ return ReactDOM.createPortal(this.renderPortal(), this.domContainer);
+ }
+
+ private renderPortal() {
+ return
+ {this.state.description}
+
;
+ }
+}
\ No newline at end of file
diff --git a/shared/js/ui/tree/View.scss b/shared/js/ui/tree/View.scss
new file mode 100644
index 00000000..c0139b05
--- /dev/null
+++ b/shared/js/ui/tree/View.scss
@@ -0,0 +1,122 @@
+@import "../../../css/static/properties";
+@import "../../../css/static/mixin";
+
+html:root {
+ --channel-tree-entry-move: #313235;
+ --channel-tree-entry-selected: #2d2d2d;
+ --channel-tree-entry-hovered: #393939;
+ --channel-tree-entry-color: #828282;
+
+ --channel-tree-entry-marker-unread: rgba(168, 20, 20, 0.5);
+}
+
+@if 0 {
+ /* the channel tree */
+ .channel-tree {
+
+ .tree-entry {
+ &.client {
+
+ }
+ }
+ }
+}
+
+.channelTree {
+ @include user-select(none);
+ width: 100%;
+
+ min-width: 10em;
+ min-height: 5em;
+
+ display: flex;
+ flex-direction: column;
+
+ flex-shrink: 0;
+ flex-grow: 1;
+
+ * {
+ font-family: sans-serif;
+ font-size: 12px;
+ white-space: pre;
+ line-height: 1;
+ }
+
+ .treeEntry {
+ position: absolute;
+ left: 0;
+ right: 0;
+
+ display: flex;
+ flex-direction: row;
+ justify-content: stretch;
+
+ height: 18px;
+ padding-top: 1px;
+ padding-bottom: 1px;
+
+ flex-grow: 0;
+ flex-shrink: 0;
+
+ &:hover {
+ background-color: var(--channel-tree-entry-hovered);
+ }
+
+ &.selected {
+ background-color: var(--channel-tree-entry-selected);
+ }
+
+
+ .markerUnread {
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+
+ width: 1px;
+ background-color: var(--channel-tree-entry-marker-unread);
+
+ opacity: 1;
+
+ &:before {
+ content: '';
+ position: absolute;
+
+ left: 0;
+ top: 0;
+ bottom: 0;
+
+ width: 24px;
+
+ background: linear-gradient(to right, var(--channel-tree-entry-marker-unread) 0%, rgba(0, 0, 0, 0) 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
+ }
+
+ &.hidden {
+ opacity: 0;
+ }
+
+ @include transition(opacity $button_hover_animation_time);
+ }
+ }
+
+ &.move {
+ .treeEntry.selected {
+ background-color: var(--channel-tree-entry-move);
+ }
+ }
+}
+
+.channelTreeContainer {
+ @include chat-scrollbar-vertical();
+
+ scroll-behavior: smooth;
+
+ position: relative;
+ height: 100%;
+
+ flex-grow: 1;
+ flex-shrink: 1;
+
+ overflow: hidden;
+ overflow-y: auto;
+}
\ No newline at end of file
diff --git a/shared/js/ui/tree/View.tsx b/shared/js/ui/tree/View.tsx
new file mode 100644
index 00000000..20201f83
--- /dev/null
+++ b/shared/js/ui/tree/View.tsx
@@ -0,0 +1,331 @@
+import {
+ BatchUpdateAssignment,
+ BatchUpdateType,
+ ReactComponentBase
+} from "tc-shared/ui/react-elements/ReactComponentBase";
+import {ChannelTree, ChannelTreeEvents} from "tc-shared/ui/view";
+import ResizeObserver from 'resize-observer-polyfill';
+
+import * as React from "react";
+
+import {EventHandler, ReactEventHandler} from "tc-shared/events";
+
+import {ChannelEntryView as ChannelEntryView} from "./Channel";
+import {ServerEntry as ServerEntryView} from "./Server";
+import {ClientEntry as ClientEntryView} from "./Client";
+
+import {ChannelEntry} from "tc-shared/ui/channel";
+import {ServerEntry} from "tc-shared/ui/server";
+import {ClientEntry, ClientType} from "tc-shared/ui/client";
+
+const viewStyle = require("./View.scss");
+
+
+export interface ChannelTreeViewProperties {
+ tree: ChannelTree;
+ onMoveStart: (start: { x: number, y: number }, current: { x: number, y: number }) => void;
+ moveThreshold?: number;
+}
+
+export interface ChannelTreeViewState {
+ element_scroll_offset?: number; /* in px */
+ scroll_offset: number; /* in px */
+ view_height: number; /* in px */
+
+ tree_version: number;
+}
+
+type TreeEntry = ChannelEntry | ServerEntry | ClientEntry;
+type FlatTreeEntry = {
+ rendered: any;
+ entry: TreeEntry;
+}
+
+//TODO: Only register listeners when channel is in view ;)
+@ReactEventHandler(e => e.props.tree.events)
+@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
+export class ChannelTreeView extends ReactComponentBase {
+ private static readonly EntryHeight = 18;
+
+ private readonly ref_container = React.createRef();
+ private resize_observer: ResizeObserver;
+
+ private flat_tree: FlatTreeEntry[] = [];
+ private listener_client_change;
+ private listener_channel_change;
+ private listener_state_collapsed;
+ private update_timeout;
+
+ private mouse_move: { x: number, y: number, down: boolean, fired: boolean } = { x: 0, y: 0, down: false, fired: false };
+ private document_mouse_listener;
+
+ private in_view_callbacks: {
+ index: number,
+ callback: () => void,
+ timeout
+ }[] = [];
+
+ protected defaultState(): ChannelTreeViewState {
+ return {
+ scroll_offset: 0,
+ view_height: 0,
+ tree_version: 0
+ };
+ }
+
+ componentDidMount(): void {
+ this.resize_observer = new ResizeObserver(entries => {
+ if(entries.length !== 1) {
+ if(entries.length === 0)
+ console.warn("Channel resize observer fired resize event with no entries!");
+ else
+ console.warn("Channel resize observer fired resize event with more than one entry which should not be possible (%d)!", entries.length);
+ return;
+ }
+ const bounds = entries[0].contentRect;
+ if(this.state.view_height !== bounds.height) {
+ console.log("Handling height update and change tree height to %d from %d", bounds.height, this.state.view_height);
+ this.setState({
+ view_height: bounds.height
+ });
+ }
+ });
+ this.resize_observer.observe(this.ref_container.current);
+ }
+
+ componentWillUnmount(): void {
+ this.resize_observer.disconnect();
+ this.resize_observer = undefined;
+ }
+
+ protected initialize() {
+ (window as any).do_tree_update = () => this.handleTreeUpdate();
+ this.listener_client_change = () => this.handleTreeUpdate();
+ this.listener_channel_change = () => this.handleTreeUpdate();
+ this.listener_state_collapsed = () => this.handleTreeUpdate();
+
+ this.document_mouse_listener = (e: MouseEvent) => {
+ if(e.type !== "mouseleave" && e.button !== 0)
+ return;
+
+ this.mouse_move.down = false;
+ this.mouse_move.fired = false;
+
+ this.removeDocumentMouseListener();
+ }
+ }
+
+ private registerDocumentMouseListener() {
+ document.addEventListener("mouseleave", this.document_mouse_listener);
+ document.addEventListener("mouseup", this.document_mouse_listener);
+ }
+
+ private removeDocumentMouseListener() {
+ document.removeEventListener("mouseleave", this.document_mouse_listener);
+ document.removeEventListener("mouseup", this.document_mouse_listener);
+ }
+
+ private handleTreeUpdate() {
+ clearTimeout(this.update_timeout);
+ this.update_timeout = setTimeout(() => {
+ this.rebuild_tree();
+ this.forceUpdate();
+ }, 50);
+ }
+
+ private visibleEntries() {
+ let view_entry_count = Math.ceil(this.state.view_height / ChannelTreeView.EntryHeight);
+ const view_entry_begin = Math.floor(this.state.scroll_offset / ChannelTreeView.EntryHeight);
+ const view_entry_end = Math.min(this.flat_tree.length, view_entry_begin + view_entry_count);
+
+ return {
+ begin: view_entry_begin,
+ end: view_entry_end
+ }
+ }
+
+ render() {
+ const entry_prerender_count = 5;
+ const entry_postrender_count = 5;
+
+ const elements = [];
+ const renderedRange = this.visibleEntries();
+ const view_entry_begin = Math.max(0, renderedRange.begin - entry_prerender_count);
+ const view_entry_end = Math.min(this.flat_tree.length, renderedRange.end + entry_postrender_count);
+
+ for (let index = view_entry_begin; index < view_entry_end; index++)
+ elements.push(this.flat_tree[index].rendered);
+
+ for(const callback of this.in_view_callbacks.slice(0)) {
+ if(callback.index >= renderedRange.begin && callback.index <= renderedRange.end) {
+ clearTimeout(callback.timeout);
+ callback.callback();
+ this.in_view_callbacks.remove(callback);
+ }
+ }
+
+ return (
+ this.onScroll()}
+ ref={this.ref_container}
+ onMouseDown={e => this.onMouseDown(e)}
+ onMouseMove={e => this.onMouseMove(e)} >
+
+ {elements}
+
+
+ )
+ }
+
+ private build_top_offset: number;
+ private build_sub_tree(entry: ChannelEntry, depth: number) {
+ entry.events.on("notify_clients_changed", this.listener_client_change);
+ entry.events.on("notify_children_changed", this.listener_channel_change);
+ entry.events.on("notify_collapsed_state_changed", this.listener_state_collapsed);
+
+ this.flat_tree.push({
+ entry: entry,
+ rendered:
+ });
+
+ if(entry.collapsed) return;
+ let clients = entry.clients(false);
+ if(!this.props.tree.areServerQueriesShown())
+ clients = clients.filter(e => e.properties.client_type_exact !== ClientType.CLIENT_QUERY);
+ this.flat_tree.push(...clients.map(e => {
+ return {
+ entry: e,
+ rendered:
+ };
+ }));
+ for (const channel of entry.children(false))
+ this.build_sub_tree(channel, depth + 1);
+ }
+
+ private rebuild_tree() {
+ const tree = this.props.tree;
+ {
+ let index = this.flat_tree.length;
+ while(index--) {
+ const entry = this.flat_tree[index].entry;
+ if(entry instanceof ChannelEntry) {
+ entry.events.off("notify_clients_changed", this.listener_client_change);
+ entry.events.off("notify_children_changed", this.listener_channel_change);
+ entry.events.off("notify_collapsed_state_changed", this.listener_state_collapsed);
+ }
+ }
+ }
+ this.build_top_offset = -ChannelTreeView.EntryHeight; /* because of the += */
+ this.flat_tree = [{
+ entry: tree.server,
+ rendered:
+ }];
+
+ for (const channel of tree.rootChannel())
+ this.build_sub_tree(channel, 1);
+ }
+
+ @EventHandler("notify_root_channel_changed")
+ private handleRootChannelChanged() {
+ this.handleTreeUpdate();
+ }
+
+ @EventHandler("notify_query_view_state_changed")
+ private handleQueryViewStateChange() {
+ this.handleTreeUpdate();
+ }
+
+ @EventHandler("notify_entry_move_begin")
+ private handleEntryMoveBegin() {
+ this.handleTreeUpdate();
+ }
+
+ @EventHandler("notify_entry_move_end")
+ private handleEntryMoveEnd() {
+ this.handleTreeUpdate();
+ }
+
+ @EventHandler("notify_tree_reset")
+ private handleTreeReset() {
+ this.rebuild_tree();
+ this.setState({
+ tree_version: this.state.tree_version + 1
+ });
+ }
+
+ private onScroll() {
+ this.setState({
+ scroll_offset: this.ref_container.current.scrollTop
+ });
+ }
+
+ private onMouseDown(e: React.MouseEvent) {
+ if(e.button !== 0) return; /* left button only */
+
+ this.mouse_move.down = true;
+ this.mouse_move.x = e.pageX;
+ this.mouse_move.y = e.pageY;
+ this.registerDocumentMouseListener();
+ }
+
+ private onMouseMove(e: React.MouseEvent) {
+ if(!this.mouse_move.down || this.mouse_move.fired) return;
+ if(Math.abs((this.mouse_move.x - e.pageX) * (this.mouse_move.y - e.pageY)) > (this.props.moveThreshold || 9)) {
+ this.mouse_move.fired = true;
+ this.props.onMoveStart({x: this.mouse_move.x, y: this.mouse_move.y}, {x: e.pageX, y: e.pageY});
+ }
+ }
+
+ scrollEntryInView(entry: TreeEntry, callback?: () => void) {
+ const index = this.flat_tree.findIndex(e => e.entry === entry);
+ if(index === -1) {
+ if(callback) callback();
+ console.warn("Failed to scroll tree entry in view because its not registered within the view. Entry: %o", entry);
+ return;
+ }
+
+ let new_index;
+ const currentRange = this.visibleEntries();
+ if(index >= currentRange.end - 1) {
+ new_index = index - (currentRange.end - currentRange.begin) + 2;
+ } else if(index < currentRange.begin) {
+ new_index = index;
+ } else {
+ if(callback) callback();
+ return;
+ }
+
+ this.ref_container.current.scrollTop = new_index * ChannelTreeView.EntryHeight;
+
+ if(callback) {
+ let cb = {
+ index: index,
+ callback: callback,
+ timeout: setTimeout(() => {
+ this.in_view_callbacks.remove(cb);
+ callback();
+ }, (Math.abs(new_index - currentRange.begin) / (currentRange.end - currentRange.begin)) * 1500)
+ };
+ this.in_view_callbacks.push(cb);
+ }
+ }
+
+ getEntryFromPoint(pageX: number, pageY: number) {
+ const container = this.ref_container.current;
+ if(!container) return;
+
+ const bounds = container.getBoundingClientRect();
+ pageY -= bounds.y;
+ pageX -= bounds.x;
+
+ if(pageX < 0 || pageY < 0)
+ return undefined;
+
+ if(pageX > container.clientWidth)
+ return undefined;
+
+ const total_offset = container.scrollTop + pageY;
+ return this.flat_tree[Math.floor(total_offset / ChannelTreeView.EntryHeight)]?.entry;
+ }
+}
\ No newline at end of file
diff --git a/shared/js/ui/view.ts b/shared/js/ui/view.ts
deleted file mode 100644
index e237ba3f..00000000
--- a/shared/js/ui/view.ts
+++ /dev/null
@@ -1,940 +0,0 @@
-import * as contextmenu from "tc-shared/ui/elements/ContextMenu";
-import * as log from "tc-shared/log";
-import {Settings, settings} from "tc-shared/settings";
-import {PermissionType} from "tc-shared/permission/PermissionType";
-import {LogCategory} from "tc-shared/log";
-import {KeyCode, SpecialKey} from "tc-shared/PPTListener";
-import {createInputModal} from "tc-shared/ui/elements/Modal";
-import {Sound} from "tc-shared/sound/Sounds";
-import {Group} from "tc-shared/permission/GroupManager";
-import * as server_log from "tc-shared/ui/frames/server_log";
-import {ServerAddress, ServerEntry} from "tc-shared/ui/server";
-import {ClientMover} from "tc-shared/ui/client_move";
-import {ChannelEntry, ChannelSubscribeMode} from "tc-shared/ui/channel";
-import {ClientEntry, ClientType, LocalClientEntry, MusicClientEntry} from "tc-shared/ui/client";
-import {ConnectionHandler, ViewReasonId} from "tc-shared/ConnectionHandler";
-import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
-import {formatMessage} from "tc-shared/ui/frames/chat";
-import {spawnBanClient} from "tc-shared/ui/modal/ModalBanClient";
-import {createChannelModal} from "tc-shared/ui/modal/ModalCreateChannel";
-import * as ppt from "tc-backend/ppt";
-
-export class ChannelTree {
- client: ConnectionHandler;
- server: ServerEntry;
-
- channels: ChannelEntry[] = [];
- clients: ClientEntry[] = [];
-
- currently_selected: ClientEntry | ServerEntry | ChannelEntry | (ClientEntry | ServerEntry)[] = undefined;
- currently_selected_context_callback: (event) => any = undefined;
- readonly client_mover: ClientMover;
-
- private _tag_container: JQuery;
- private _tag_entries: JQuery;
-
- private _tree_detached: boolean = false;
- private _show_queries: boolean;
- private channel_last?: ChannelEntry;
- private channel_first?: ChannelEntry;
-
- private _focused = false;
- private _listener_document_click;
- private _listener_document_key;
-
- private _scroll_bar/*: SimpleBar*/;
-
- constructor(client) {
- this.client = client;
-
- this._tag_container = $.spawn("div").addClass("channel-tree-container");
- this._tag_entries = $.spawn("div").addClass("channel-tree");
- //if('SimpleBar' in window) /* for MSEdge, and may consider Firefox? */
- // this._scroll_bar = new SimpleBar(this._tag_container[0]);
-
- this.client_mover = new ClientMover(this);
- this.reset();
-
- if(!settings.static(Settings.KEY_DISABLE_CONTEXT_MENU, false)) {
- this._tag_container.on("contextmenu", (event) => {
- if(event.isDefaultPrevented()) return;
-
- for(const element of document.elementsFromPoint(event.pageX, event.pageY))
- if(element.classList.contains("channelLine") || element.classList.contains("client"))
- return;
-
- event.preventDefault();
- if($.isArray(this.currently_selected)) { //Multiselect
- (this.currently_selected_context_callback || ((_) => null))(event);
- } else {
- this.onSelect(undefined);
- this.showContextMenu(event.pageX, event.pageY);
- }
- });
- }
-
- this._tag_container.on('resize', this.handle_resized.bind(this));
- this._listener_document_key = event => this.handle_key_press(event);
- this._listener_document_click = event => {
- this._focused = false;
- let element = event.target as HTMLElement;
- while(element) {
- if(element === this._tag_container[0]) {
- this._focused = true;
- break;
- }
- element = element.parentNode as HTMLElement;
- }
- };
- document.addEventListener('click', this._listener_document_click);
- document.addEventListener('keydown', this._listener_document_key);
- }
-
- tag_tree() : JQuery {
- return this._tag_container;
- }
-
- destroy() {
- this._listener_document_click && document.removeEventListener('click', this._listener_document_click);
- this._listener_document_click = undefined;
-
- this._listener_document_key && document.removeEventListener('keydown', this._listener_document_key);
- this._listener_document_key = undefined;
-
- if(this.server) {
- this.server.destroy();
- this.server = undefined;
- }
- this.reset(); /* cleanup channel and clients */
-
- this.channel_first = undefined;
- this.channel_last = undefined;
-
- this._tag_container.remove();
- this.currently_selected = undefined;
- this.currently_selected_context_callback = undefined;
- }
-
- hide_channel_tree() {
- this._tag_entries.detach();
- this._tree_detached = true;
- }
-
- show_channel_tree() {
- this._tree_detached = false;
- if(this._scroll_bar)
- this._tag_entries.appendTo(this._scroll_bar.getContentElement());
- else
- this._tag_entries.appendTo(this._tag_container);
-
- this.channels.forEach(e => {
- e.recalculate_repetitive_name();
- e.reorderClients();
- });
- this._scroll_bar?.recalculate();
- }
-
- showContextMenu(x: number, y: number, on_close: () => void = undefined) {
- let channelCreate =
- this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_TEMPORARY).granted(1) ||
- this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_SEMI_PERMANENT).granted(1) ||
- this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_PERMANENT).granted(1);
-
- contextmenu.spawn_context_menu(x, y,
- {
- type: contextmenu.MenuEntryType.ENTRY,
- icon_class: "client-channel_create",
- name: tr("Create channel"),
- invalidPermission: !channelCreate,
- callback: () => this.spawnCreateChannel()
- },
- contextmenu.Entry.CLOSE(on_close)
- );
- }
-
- initialiseHead(serverName: string, address: ServerAddress) {
- if(this.server) {
- this.server.destroy();
- this.server = undefined;
- }
- this.server = new ServerEntry(this, serverName, address);
- this.server.htmlTag.appendTo(this._tag_entries);
- this.server.initializeListener();
- }
-
- private __deleteAnimation(element: ChannelEntry | ClientEntry) {
- let tag = element instanceof ChannelEntry ? element.rootTag() : element.tag;
- tag.fadeOut("slow", () => {
- tag.detach();
- element.destroy();
- });
- }
-
- rootChannel() : ChannelEntry[] {
- return this.channels.filter(e => e.parent == undefined);
- }
-
- deleteChannel(channel: ChannelEntry) {
- const _this = this;
- for(let index = 0; index < this.channels.length; index++) {
- let entry = this.channels[index];
- let currentEntry = this.channels[index];
- while(currentEntry != undefined && currentEntry != null) {
- if(currentEntry == channel) {
- _this.channels.remove(entry);
- _this.__deleteAnimation(entry);
- entry.channelTree = null;
- index--;
- break;
- } else currentEntry = currentEntry.parent_channel();
- }
- }
-
- this.channels.remove(channel);
- this.__deleteAnimation(channel);
- channel.channelTree = null;
-
- if(channel.channel_previous)
- channel.channel_previous.channel_next = channel.channel_next;
-
- if(channel.channel_next)
- channel.channel_next.channel_previous = channel.channel_previous;
-
- if(channel == this.channel_first)
- this.channel_first = channel.channel_next;
-
- if(channel == this.channel_last)
- this.channel_last = channel.channel_previous;
-
- if(this._scroll_bar) this._scroll_bar.recalculate();
- }
-
- insertChannel(channel: ChannelEntry) {
- channel.channelTree = this;
- this.channels.push(channel);
-
- let elm = undefined;
- let tag = this._tag_entries;
-
- let previous_channel = null;
- if(channel.hasParent()) {
- let parent = channel.parent_channel();
- let siblings = parent.children();
- if(siblings.length == 0) {
- elm = parent.rootTag();
- previous_channel = null;
- } else {
- previous_channel = siblings.last();
- elm = previous_channel.tag;
- }
- tag = parent.siblingTag();
- } else {
- previous_channel = this.channel_last;
-
- if(!this.channel_last)
- this.channel_last = channel;
-
- if(!this.channel_first)
- this.channel_first = channel;
- }
-
- channel.channel_previous = previous_channel;
- channel.channel_next = undefined;
-
- if(previous_channel) {
- channel.channel_next = previous_channel.channel_next;
- previous_channel.channel_next = channel;
-
- if(channel.channel_next)
- channel.channel_next.channel_previous = channel;
- }
-
- let entry = channel.rootTag();
- if(!this._tree_detached)
- entry.css({display: "none"}).fadeIn("slow");
- entry.appendTo(tag);
-
- if(elm != undefined)
- elm.after(entry);
-
- if(channel.channel_previous == channel) /* shall never happen */
- channel.channel_previous = undefined;
- if(channel.channel_next == channel) /* shall never happen */
- channel.channel_next = undefined;
-
- channel.initializeListener();
- channel.update_family_index();
- if(this._scroll_bar) this._scroll_bar.recalculate();
- }
-
- findChannel(channelId: number) : ChannelEntry | undefined {
- for(let index = 0; index < this.channels.length; index++)
- if(this.channels[index].getChannelId() == channelId) return this.channels[index];
- return undefined;
- }
-
- find_channel_by_name(name: string, parent?: ChannelEntry, force_parent: boolean = true) : ChannelEntry | undefined {
- for(let index = 0; index < this.channels.length; index++)
- if(this.channels[index].channelName() == name && (!force_parent || parent == this.channels[index].parent))
- return this.channels[index];
- return undefined;
- }
-
- moveChannel(channel: ChannelEntry, channel_previous: ChannelEntry, parent: ChannelEntry) {
- if(channel_previous != null && channel_previous.parent != parent) {
- console.error(tr("Invalid channel move (different parents! (%o|%o)"), channel_previous.parent, parent);
- return;
- }
-
- if(channel.channel_next)
- channel.channel_next.channel_previous = channel.channel_previous;
-
- if(channel.channel_previous)
- channel.channel_previous.channel_next = channel.channel_next;
-
- if(channel == this.channel_last)
- this.channel_last = channel.channel_previous;
-
- if(channel == this.channel_first)
- this.channel_first = channel.channel_next;
-
-
- channel.channel_next = undefined;
- channel.channel_previous = channel_previous;
- channel.parent = parent;
-
- if(channel_previous) {
- if(channel_previous == this.channel_last)
- this.channel_last = channel;
-
- channel.channel_next = channel_previous.channel_next;
- channel_previous.channel_next = channel;
- channel_previous.rootTag().after(channel.rootTag());
-
- if(channel.channel_next)
- channel.channel_next.channel_previous = channel;
- } else {
- if(parent) {
- let children = parent.children();
- if(children.length <= 1) { //Self should be already in there
- let left = channel.rootTag();
- left.appendTo(parent.siblingTag());
-
- channel.channel_next = undefined;
- } else {
- channel.channel_previous = undefined;
- channel.rootTag().prependTo(parent.siblingTag());
-
- channel.channel_next = children[1]; /* children 0 shall be the channel itself */
- channel.channel_next.channel_previous = channel;
- }
- } else {
- this._tag_entries.find(".server").after(channel.rootTag());
-
- channel.channel_next = this.channel_first;
- if(this.channel_first)
- this.channel_first.channel_previous = channel;
-
- this.channel_first = channel;
- }
- }
-
- channel.update_family_index();
- channel.children(true).forEach(e => e.update_family_index());
- channel.clients(true).forEach(e => e.update_family_index());
-
- if(channel.channel_previous == channel) { /* shall never happen */
- channel.channel_previous = undefined;
- debugger;
- }
- if(channel.channel_next == channel) { /* shall never happen */
- channel.channel_next = undefined;
- debugger;
- }
- }
-
- deleteClient(client: ClientEntry, animate_tag?: boolean) {
- const old_channel = client.currentChannel();
- this.clients.remove(client);
- if(typeof(animate_tag) !== "boolean" || animate_tag)
- this.__deleteAnimation(client);
- else
- client.tag.detach();
- client.onDelete();
-
- if(old_channel) {
- this.client.side_bar.info_frame().update_channel_client_count(old_channel);
- }
-
- const voice_connection = this.client.serverConnection.voice_connection();
- if(client.get_audio_handle()) {
- if(!voice_connection) {
- log.warn(LogCategory.VOICE, tr("Deleting client with a voice handle, but we haven't a voice connection!"));
- } else {
- voice_connection.unregister_client(client.get_audio_handle());
- }
- }
- client.set_audio_handle(undefined);
- if(this._scroll_bar) this._scroll_bar.recalculate();
- }
-
- registerClient(client: ClientEntry) {
- this.clients.push(client);
- client.channelTree = this;
-
- const voice_connection = this.client.serverConnection.voice_connection();
- if(voice_connection)
- client.set_audio_handle(voice_connection.register_client(client.clientId()));
- }
-
- unregisterClient(client: ClientEntry) {
- if(!this.clients.remove(client))
- return;
- }
-
- private _update_timer: number;
- private _reorder_channels = new Set();
-
- insertClient(client: ClientEntry, channel: ChannelEntry) : ClientEntry {
- let newClient = this.findClient(client.clientId());
- if(newClient)
- client = newClient; //Got new client :)
- else {
- this.registerClient(client);
- }
-
- client["_channel"] = channel;
- let tag = client.tag;
-
- if(!this._show_queries && client.properties.client_type == ClientType.CLIENT_QUERY)
- client.tag.hide();
- else if(!this._tree_detached)
- tag.css("display", "none").fadeIn("slow");
-
- tag.appendTo(channel.clientTag());
- channel.reorderClients();
-
- /* schedule a reorder for this channel. */
- this._reorder_channels.add(client.currentChannel());
- if(!this._update_timer) {
- this._update_timer = setTimeout(() => {
- this._update_timer = undefined;
- for(const channel of this._reorder_channels) {
- channel.updateChannelTypeIcon();
- this.client.side_bar.info_frame().update_channel_client_count(channel);
- }
- this._reorder_channels.clear();
- }, 5) as any;
- }
-
- client.update_family_index(); /* why the hell is this here?! */
- if(this._scroll_bar) this._scroll_bar.recalculate();
- return client;
- }
-
- moveClient(client: ClientEntry, channel: ChannelEntry) {
- let oldChannel = client.currentChannel();
- client["_channel"] = channel;
-
- let tag = client.tag;
- tag.detach();
- tag.appendTo(client.currentChannel().clientTag());
- if(oldChannel) {
- oldChannel.updateChannelTypeIcon();
- this.client.side_bar.info_frame().update_channel_client_count(oldChannel);
- }
- if(channel) {
- channel.reorderClients();
- channel.updateChannelTypeIcon();
- this.client.side_bar.info_frame().update_channel_client_count(channel);
- }
- client.updateClientStatusIcons();
- client.update_family_index();
- }
-
- findClient?(clientId: number) : ClientEntry {
- for(let index = 0; index < this.clients.length; index++) {
- if(this.clients[index].clientId() == clientId)
- return this.clients[index];
- }
- return undefined;
- }
-
- find_client_by_dbid?(client_dbid: number) : ClientEntry {
- for(let index = 0; index < this.clients.length; index++) {
- if(this.clients[index].properties.client_database_id == client_dbid)
- return this.clients[index];
- }
- return undefined;
- }
-
- find_client_by_unique_id?(unique_id: string) : ClientEntry {
- for(let index = 0; index < this.clients.length; index++) {
- if(this.clients[index].properties.client_unique_identifier == unique_id)
- return this.clients[index];
- }
- return undefined;
- }
-
- private static same_selected_type(a, b) {
- if(a instanceof ChannelEntry)
- return b instanceof ChannelEntry;
- if(a instanceof ClientEntry)
- return b instanceof ClientEntry;
- if(a instanceof ServerEntry)
- return b instanceof ServerEntry;
- return a == b;
- }
-
- onSelect(entry?: ChannelEntry | ClientEntry | ServerEntry, enforce_single?: boolean, flag_shift?: boolean) {
- if(this.currently_selected && (ppt.key_pressed(SpecialKey.SHIFT) || flag_shift) && entry instanceof ClientEntry) { //Currently we're only supporting client multiselects :D
- if(!entry) return; //Nowhere
-
- if($.isArray(this.currently_selected)) {
- if(!ChannelTree.same_selected_type(this.currently_selected[0], entry)) return; //Not the same type
- } else if(ChannelTree.same_selected_type(this.currently_selected, entry)) {
- this.currently_selected = [this.currently_selected] as any;
- }
- if(entry instanceof ChannelEntry)
- this.currently_selected_context_callback = this.callback_multiselect_channel.bind(this);
- if(entry instanceof ClientEntry)
- this.currently_selected_context_callback = this.callback_multiselect_client.bind(this);
- } else
- this.currently_selected = undefined;
-
- if(!$.isArray(this.currently_selected) || enforce_single) {
- this.currently_selected = entry;
- this._tag_entries.find(".selected").each(function (idx, e) {
- $(e).removeClass("selected");
- });
- } else {
- for(const e of this.currently_selected)
- if(e == entry) {
- this.currently_selected.remove(e);
- if(entry instanceof ChannelEntry)
- (entry as ChannelEntry).channelTag().removeClass("selected");
- else if(entry instanceof ClientEntry)
- (entry as ClientEntry).tag.removeClass("selected");
- else if(entry instanceof ServerEntry)
- (entry as ServerEntry).htmlTag.removeClass("selected");
- if(this.currently_selected.length == 1)
- this.currently_selected = this.currently_selected[0];
- else if(this.currently_selected.length == 0)
- this.currently_selected = undefined;
- //Already selected
- return;
- }
- this.currently_selected.push(entry as any);
- }
-
- if(entry instanceof ChannelEntry)
- (entry as ChannelEntry).channelTag().addClass("selected");
- else if(entry instanceof ClientEntry)
- (entry as ClientEntry).tag.addClass("selected");
- else if(entry instanceof ServerEntry)
- (entry as ServerEntry).htmlTag.addClass("selected");
-
- if(!$.isArray(this.currently_selected)) {
- if(this.currently_selected instanceof ClientEntry && settings.static_global(Settings.KEY_SWITCH_INSTANT_CLIENT)) {
- if(this.currently_selected instanceof MusicClientEntry)
- this.client.side_bar.show_music_player(this.currently_selected as MusicClientEntry);
- else
- this.client.side_bar.show_client_info(this.currently_selected);
- } else if(this.currently_selected instanceof ChannelEntry && settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) {
- this.client.side_bar.channel_conversations().set_current_channel(this.currently_selected.channelId);
- this.client.side_bar.show_channel_conversations();
- } else if(this.currently_selected instanceof ServerEntry && settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) {
- this.client.side_bar.channel_conversations().set_current_channel(0);
- this.client.side_bar.show_channel_conversations();
- }
- }
- }
-
- private callback_multiselect_channel(event) {
- console.log(tr("Multiselect channel"));
- }
- private callback_multiselect_client(event) {
- console.log(tr("Multiselect client"));
- const clients = this.currently_selected as ClientEntry[];
- const music_only = clients.map(e => e instanceof MusicClientEntry ? 0 : 1).reduce((a, b) => a + b, 0) == 0;
- const music_entry = clients.map(e => e instanceof MusicClientEntry ? 1 : 0).reduce((a, b) => a + b, 0) > 0;
- const local_client = clients.map(e => e instanceof LocalClientEntry ? 1 : 0).reduce((a, b) => a + b, 0) > 0;
- let entries: contextmenu.MenuEntry[] = [];
- if (!music_entry && !local_client) { //Music bots or local client cant be poked
- entries.push({
- type: contextmenu.MenuEntryType.ENTRY,
- icon_class: "client-poke",
- name: tr("Poke clients"),
- callback: () => {
- createInputModal(tr("Poke clients"), tr("Poke message:
"), text => true, result => {
- if (typeof(result) === "string") {
- for (const client of this.currently_selected as ClientEntry[])
- this.client.serverConnection.send_command("clientpoke", {
- clid: client.clientId(),
- msg: result
- });
-
- }
- }, {width: 400, maxLength: 512}).open();
- }
- });
- }
- entries.push({
- type: contextmenu.MenuEntryType.ENTRY,
- icon_class: "client-move_client_to_own_channel",
- name: tr("Move clients to your channel"),
- callback: () => {
- const target = this.client.getClient().currentChannel().getChannelId();
- for(const client of clients)
- this.client.serverConnection.send_command("clientmove", {
- clid: client.clientId(),
- cid: target
- });
- }
- });
- if (!local_client) {//local client cant be kicked and/or banned or kicked
- entries.push(contextmenu.Entry.HR());
- entries.push({
- type: contextmenu.MenuEntryType.ENTRY,
- icon_class: "client-kick_channel",
- name: tr("Kick clients from channel"),
- callback: () => {
- createInputModal(tr("Kick clients from channel"), tr("Kick reason:
"), text => true, result => {
- if (result) {
- for (const client of clients)
- this.client.serverConnection.send_command("clientkick", {
- clid: client.clientId(),
- reasonid: ViewReasonId.VREASON_CHANNEL_KICK,
- reasonmsg: result
- });
-
- }
- }, {width: 400, maxLength: 255}).open();
- }
- });
-
- if (!music_entry) { //Music bots cant be banned or kicked
- entries.push({
- type: contextmenu.MenuEntryType.ENTRY,
- icon_class: "client-kick_server",
- name: tr("Kick clients fom server"),
- callback: () => {
- createInputModal(tr("Kick clients from server"), tr("Kick reason:
"), text => true, result => {
- if (result) {
- for (const client of clients)
- this.client.serverConnection.send_command("clientkick", {
- clid: client.clientId(),
- reasonid: ViewReasonId.VREASON_SERVER_KICK,
- reasonmsg: result
- });
-
- }
- }, {width: 400, maxLength: 255}).open();
- }
- }, {
- type: contextmenu.MenuEntryType.ENTRY,
- icon_class: "client-ban_client",
- name: tr("Ban clients"),
- invalidPermission: !this.client.permissions.neededPermission(PermissionType.I_CLIENT_BAN_MAX_BANTIME).granted(1),
- callback: () => {
- spawnBanClient(this.client, (clients).map(entry => {
- return {
- name: entry.clientNickName(),
- unique_id: entry.properties.client_unique_identifier
- }
- }), (data) => {
- for (const client of clients)
- this.client.serverConnection.send_command("banclient", {
- uid: client.properties.client_unique_identifier,
- banreason: data.reason,
- time: data.length
- }, {
- flagset: [data.no_ip ? "no-ip" : "", data.no_hwid ? "no-hardware-id" : "", data.no_name ? "no-nickname" : ""]
- }).then(() => {
- this.client.sound.play(Sound.USER_BANNED);
- });
- });
- }
- });
- }
- if(music_only) {
- entries.push(contextmenu.Entry.HR());
- entries.push({
- name: tr("Delete bots"),
- icon_class: "client-delete",
- disabled: false,
- callback: () => {
- const param_string = clients.map((_, index) => "{" + index + "}").join(', ');
- const param_values = clients.map(client => client.createChatTag(true));
- const tag = $.spawn("div").append(...formatMessage(tr("Do you really want to delete ") + param_string, ...param_values));
- const tag_container = $.spawn("div").append(tag);
- spawnYesNo(tr("Are you sure?"), tag_container, result => {
- if(result) {
- for(const client of clients)
- this.client.serverConnection.send_command("musicbotdelete", {
- botid: client.properties.client_database_id
- });
- }
- });
- },
- type: contextmenu.MenuEntryType.ENTRY
- });
- }
- }
- contextmenu.spawn_context_menu(event.pageX, event.pageY, ...entries);
- }
-
- clientsByGroup(group: Group) : ClientEntry[] {
- let result = [];
-
- for(let client of this.clients) {
- if(client.groupAssigned(group))
- result.push(client);
- }
-
- return result;
- }
-
- clientsByChannel(channel: ChannelEntry) : ClientEntry[] {
- let result = [];
-
- for(let client of this.clients) {
- if(client.currentChannel() == channel)
- result.push(client);
- }
-
- return result;
- }
-
- reset(){
- const voice_connection = this.client.serverConnection ? this.client.serverConnection.voice_connection() : undefined;
- for(const client of this.clients) {
- if(client.get_audio_handle() && voice_connection) {
- voice_connection.unregister_client(client.get_audio_handle());
- client.set_audio_handle(undefined);
- }
- client.destroy();
- }
- this.clients = [];
-
- for(const channel of this.channels)
- channel.destroy();
- this.channels = [];
-
- this._tag_entries.children().detach(); //Dont remove listeners
-
- this.channel_first = undefined;
- this.channel_last = undefined;
- }
-
- spawnCreateChannel(parent?: ChannelEntry) {
- createChannelModal(this.client, undefined, parent, this.client.permissions, (properties?, permissions?) => {
- if(!properties) return;
- properties["cpid"] = parent ? parent.channelId : 0;
- log.debug(LogCategory.CHANNEL, tr("Creating a new channel.\nProperties: %o\nPermissions: %o"), properties);
- this.client.serverConnection.send_command("channelcreate", properties).then(() => {
- let channel = this.find_channel_by_name(properties.channel_name, parent, true);
- if(!channel) {
- log.error(LogCategory.CHANNEL, tr("Failed to resolve channel after creation. Could not apply permissions!"));
- return;
- }
- if(permissions && permissions.length > 0) {
- let perms = [];
- for(let perm of permissions) {
- perms.push({
- permvalue: perm.value,
- permnegated: false,
- permskip: false,
- permid: perm.type.id
- });
- }
-
- perms[0]["cid"] = channel.channelId;
- return this.client.serverConnection.send_command("channeladdperm", perms, {
- flagset: ["continueonerror"]
- }).then(() => new Promise(resolve => { resolve(channel); }));
- }
-
- return new Promise(resolve => { resolve(channel); })
- }).then(channel => {
- this.client.log.log(server_log.Type.CHANNEL_CREATE, {
- channel: channel.log_data(),
- creator: this.client.getClient().log_data(),
- own_action: true
- });
- this.client.sound.play(Sound.CHANNEL_CREATED);
- });
- });
- }
-
- handle_resized() {
- for(let channel of this.channels)
- channel.handle_frame_resized();
- }
-
- private select_next_channel(channel: ChannelEntry, select_client: boolean) {
- if(select_client) {
- const clients = channel.clients_ordered();
- if(clients.length > 0) {
- this.onSelect(clients[0], true);
- return;
- }
- }
-
- const children = channel.children();
- if(children.length > 0) {
- this.onSelect(children[0], true);
- return;
- }
-
- const next = channel.channel_next;
- if(next) {
- this.onSelect(next, true);
- return;
- }
-
- let parent = channel.parent_channel();
- while(parent) {
- const p_next = parent.channel_next;
- if(p_next) {
- this.onSelect(p_next, true);
- return;
- }
-
- parent = parent.parent_channel();
- }
- }
-
- handle_key_press(event: KeyboardEvent) {
- //console.log("Keydown: %o | %o | %o", this._focused, this.currently_selected, Array.isArray(this.currently_selected));
- if(!this._focused || !this.currently_selected || Array.isArray(this.currently_selected)) return;
-
- if(event.keyCode == KeyCode.KEY_UP) {
- event.preventDefault();
- if(this.currently_selected instanceof ChannelEntry) {
- let previous = this.currently_selected.channel_previous;
-
- if(previous) {
- while(true) {
- const siblings = previous.children();
- if(siblings.length == 0) break;
- previous = siblings.last();
- }
- const clients = previous.clients_ordered();
- if(clients.length > 0) {
- this.onSelect(clients.last(), true);
- return;
- } else {
- this.onSelect(previous, true);
- return;
- }
- } else if(this.currently_selected.hasParent()) {
- const channel = this.currently_selected.parent_channel();
- const clients = channel.clients_ordered();
- if(clients.length > 0) {
- this.onSelect(clients.last(), true);
- return;
- } else {
- this.onSelect(channel, true);
- return;
- }
- } else
- this.onSelect(this.server, true);
- } else if(this.currently_selected instanceof ClientEntry) {
- const channel = this.currently_selected.currentChannel();
- const clients = channel.clients_ordered();
- const index = clients.indexOf(this.currently_selected);
- if(index > 0) {
- this.onSelect(clients[index - 1], true);
- return;
- }
-
- this.onSelect(channel, true);
- return;
- }
-
- } else if(event.keyCode == KeyCode.KEY_DOWN) {
- event.preventDefault();
- if(this.currently_selected instanceof ChannelEntry) {
- this.select_next_channel(this.currently_selected, true);
- } else if(this.currently_selected instanceof ClientEntry){
- const channel = this.currently_selected.currentChannel();
- const clients = channel.clients_ordered();
- const index = clients.indexOf(this.currently_selected);
- if(index + 1 < clients.length) {
- this.onSelect(clients[index + 1], true);
- return;
- }
-
- this.select_next_channel(channel, false);
- } else if(this.currently_selected instanceof ServerEntry)
- this.onSelect(this.channel_first, true);
- } else if(event.keyCode == KeyCode.KEY_RETURN) {
- if(this.currently_selected instanceof ChannelEntry) {
- this.currently_selected.joinChannel();
- }
- }
- }
-
- toggle_server_queries(flag: boolean) {
- if(this._show_queries == flag) return;
- this._show_queries = flag;
-
- const channels: ChannelEntry[] = []
- for(const client of this.clients)
- if(client.properties.client_type == ClientType.CLIENT_QUERY) {
- if(this._show_queries)
- client.tag.show();
- else
- client.tag.hide();
- if(channels.indexOf(client.currentChannel()) == -1)
- channels.push(client.currentChannel());
- }
- }
-
- get_first_channel?() : ChannelEntry {
- return this.channel_first;
- }
-
- unsubscribe_all_channels(subscribe_specified?: boolean) {
- if(!this.client.serverConnection || !this.client.serverConnection.connected())
- return;
-
- this.client.serverConnection.send_command('channelunsubscribeall').then(() => {
- const channels: number[] = [];
- for(const channel of this.channels) {
- if(channel.subscribe_mode == ChannelSubscribeMode.SUBSCRIBED)
- channels.push(channel.getChannelId());
- }
-
- if(channels.length > 0) {
- this.client.serverConnection.send_command('channelsubscribe', channels.map(e => { return {cid: e}; })).catch(error => {
- console.warn(tr("Failed to subscribe to specific channels (%o)"), channels);
- });
- }
- }).catch(error => {
- console.warn(tr("Failed to unsubscribe to all channels! (%o)"), error);
- });
- }
-
- subscribe_all_channels() {
- if(!this.client.serverConnection || !this.client.serverConnection.connected())
- return;
-
- this.client.serverConnection.send_command('channelsubscribeall').then(() => {
- const channels: number[] = [];
- for(const channel of this.channels) {
- if(channel.subscribe_mode == ChannelSubscribeMode.UNSUBSCRIBED)
- channels.push(channel.getChannelId());
- }
-
- if(channels.length > 0) {
- this.client.serverConnection.send_command('channelunsubscribe', channels.map(e => { return {cid: e}; })).catch(error => {
- console.warn(tr("Failed to unsubscribe to specific channels (%o)"), channels);
- });
- }
- }).catch(error => {
- console.warn(tr("Failed to subscribe to all channels! (%o)"), error);
- });
- }
-}
\ No newline at end of file
diff --git a/shared/js/ui/view.tsx b/shared/js/ui/view.tsx
new file mode 100644
index 00000000..b7b2462e
--- /dev/null
+++ b/shared/js/ui/view.tsx
@@ -0,0 +1,1122 @@
+import * as contextmenu from "tc-shared/ui/elements/ContextMenu";
+import {MenuEntryType} from "tc-shared/ui/elements/ContextMenu";
+import * as log from "tc-shared/log";
+import {LogCategory} from "tc-shared/log";
+import {Settings, settings} from "tc-shared/settings";
+import {PermissionType} from "tc-shared/permission/PermissionType";
+import {KeyCode, SpecialKey} from "tc-shared/PPTListener";
+import {Sound} from "tc-shared/sound/Sounds";
+import {Group} from "tc-shared/permission/GroupManager";
+import * as server_log from "tc-shared/ui/frames/server_log";
+import {ServerAddress, ServerEntry} from "tc-shared/ui/server";
+import {ChannelEntry, ChannelSubscribeMode} from "tc-shared/ui/channel";
+import {ClientEntry, LocalClientEntry, MusicClientEntry} from "tc-shared/ui/client";
+import {ConnectionHandler, ViewReasonId} from "tc-shared/ConnectionHandler";
+import {createChannelModal} from "tc-shared/ui/modal/ModalCreateChannel";
+import {Registry} from "tc-shared/events";
+import {ChannelTreeView} from "tc-shared/ui/tree/View";
+import * as ReactDOM from "react-dom";
+import * as React from "react";
+import * as ppt from "tc-backend/ppt";
+
+import {batch_updates, BatchUpdateType, flush_batched_updates} from "tc-shared/ui/react-elements/ReactComponentBase";
+import {ChannelTreeEntry} from "tc-shared/ui/TreeEntry";
+import {createInputModal} from "tc-shared/ui/elements/Modal";
+import {spawnBanClient} from "tc-shared/ui/modal/ModalBanClient";
+import {formatMessage} from "tc-shared/ui/frames/chat";
+import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
+import {tra} from "tc-shared/i18n/localize";
+import {TreeEntryMove} from "tc-shared/ui/tree/TreeEntryMove";
+
+export interface ChannelTreeEvents {
+ action_select_entries: {
+ entries: ChannelTreeEntry[],
+ /**
+ * auto := Select/unselect/add/remove depending on the selected state & shift key state
+ * exclusive := Only selected these entries
+ * append := Append these entries to the current selection
+ * remove := Remove these entries from the current selection
+ */
+ mode: "auto" | "exclusive" | "append" | "remove";
+ },
+
+ notify_selection_changed: {},
+ notify_root_channel_changed: {},
+ notify_tree_reset: {},
+ notify_query_view_state_changed: { queries_shown: boolean },
+
+ notify_entry_move_begin: {},
+ notify_entry_move_end: {}
+}
+
+export class ChannelTreeEntrySelect {
+ readonly handle: ChannelTree;
+ selected_entries: ChannelTreeEntry[] = [];
+
+ private readonly handler_select_entries;
+
+ constructor(handle: ChannelTree) {
+ this.handle = handle;
+
+ this.handler_select_entries = e => {
+ batch_updates(BatchUpdateType.CHANNEL_TREE);
+ try {
+ this.handleSelectEntries(e)
+ } finally {
+ flush_batched_updates(BatchUpdateType.CHANNEL_TREE);
+ }
+ };
+
+ this.handle.events.on("action_select_entries", this.handler_select_entries);
+ }
+
+ reset() {
+ this.selected_entries.splice(0, this.selected_entries.length);
+ }
+
+ destroy() {
+ this.handle.events.off("action_select_entries", this.handler_select_entries);
+ this.selected_entries.splice(0, this.selected_entries.length);
+ }
+
+ is_multi_select() {
+ return this.selected_entries.length > 1;
+ }
+
+ is_anything_selected() {
+ return this.selected_entries.length > 0;
+ }
+
+ clear_selection() {
+ this.handleSelectEntries({
+ entries: [],
+ mode: "exclusive"
+ });
+ }
+
+ private handleSelectEntries(event: ChannelTreeEvents["action_select_entries"]) {
+ if(event.mode === "exclusive") {
+ let deleted_entries = this.selected_entries;
+ let new_entries = [];
+
+ this.selected_entries = [];
+ for(const new_entry of event.entries) {
+ if(!deleted_entries.remove(new_entry))
+ new_entries.push(new_entry);
+ this.selected_entries.push(new_entry);
+ }
+
+ for(const deleted of deleted_entries)
+ deleted["onUnselect"]();
+
+ for(const new_entry of new_entries)
+ new_entry["onSelect"](!this.is_multi_select());
+
+ if(deleted_entries.length !== 0 || new_entries.length !== 0)
+ this.handle.events.fire("notify_selection_changed");
+ } else if(event.mode === "append") {
+ let new_entries = [];
+ for(const entry of event.entries) {
+ if(this.selected_entries.findIndex(e => e === entry) !== -1)
+ continue;
+
+ this.selected_entries.push(entry);
+ new_entries.push(entry);
+ }
+
+ for(const new_entry of new_entries)
+ new_entry["onSelect"](!this.is_multi_select());
+
+ if(new_entries.length !== 0)
+ this.handle.events.fire("notify_selection_changed");
+ } else if(event.mode === "remove") {
+ let deleted_entries = [];
+ for(const entry of event.entries) {
+ if(this.selected_entries.remove(entry))
+ deleted_entries.push(entry);
+ }
+
+ for(const deleted of deleted_entries)
+ deleted["onUnselect"]();
+
+ if(deleted_entries.length !== 0)
+ this.handle.events.fire("notify_selection_changed");
+ } else if(event.mode === "auto") {
+ let deleted_entries = [];
+ let new_entries = [];
+
+ if(ppt.key_pressed(SpecialKey.SHIFT)) {
+ for(const entry of event.entries) {
+ const index = this.selected_entries.findIndex(e => e === entry);
+ if(index === -1) {
+ this.selected_entries.push(entry);
+ new_entries.push(entry);
+ } else {
+ this.selected_entries.splice(index, 1);
+ deleted_entries.push(entry);
+ }
+ }
+ } else {
+ deleted_entries = this.selected_entries.splice(0, this.selected_entries.length);
+ if(event.entries.length !== 0) {
+ const entry = event.entries[event.entries.length - 1];
+ this.selected_entries.push(entry);
+ if(!deleted_entries.remove(entry))
+ new_entries.push(entry); /* entry wans't selected yet */
+ }
+ }
+
+ for(const deleted of deleted_entries)
+ deleted["onUnselect"]();
+
+ for(const new_entry of new_entries)
+ new_entry["onSelect"](!this.is_multi_select());
+
+ if(deleted_entries.length !== 0 || new_entries.length !== 0)
+ this.handle.events.fire("notify_selection_changed");
+ } else {
+ console.warn("Received entry select event with unknown mode: %s", event.mode);
+ }
+
+ if(this.selected_entries.length === 1)
+ this.handle.view.current?.scrollEntryInView(this.selected_entries[0] as any);
+ }
+}
+
+export class ChannelTree {
+ readonly events: Registry;
+
+ client: ConnectionHandler;
+ server: ServerEntry;
+
+ channels: ChannelEntry[] = [];
+ clients: ClientEntry[] = [];
+
+ readonly view: React.RefObject;
+ readonly view_move: React.RefObject;
+ readonly selection: ChannelTreeEntrySelect;
+
+ private readonly _tag_container: JQuery;
+
+ private _show_queries: boolean;
+ private channel_last?: ChannelEntry;
+ private channel_first?: ChannelEntry;
+
+ private _tag_container_focused = false;
+ private _listener_document_click;
+ private _listener_document_key;
+
+ constructor(client) {
+ this.events = new Registry();
+ this.client = client;
+ this.view = React.createRef();
+ this.view_move = React.createRef();
+
+ this.server = new ServerEntry(this, "undefined", undefined);
+ this.selection = new ChannelTreeEntrySelect(this);
+
+ this._tag_container = $.spawn("div").addClass("channel-tree-container");
+ ReactDOM.render([
+ this.onChannelEntryMove(a, b)} tree={this} ref={this.view} />,
+ this.onMoveEnd(point.x, point.y)} ref={this.view_move} />
+ ], this._tag_container[0]);
+
+ this.reset();
+
+ if(!settings.static(Settings.KEY_DISABLE_CONTEXT_MENU, false)) {
+ this._tag_container.on("contextmenu", (event) => {
+ event.preventDefault();
+
+ const entry = this.view.current?.getEntryFromPoint(event.pageX, event.pageY);
+ if(entry) {
+ if(this.selection.is_multi_select())
+ this.open_multiselect_context_menu(this.selection.selected_entries, event.pageX, event.pageY);
+ } else {
+ this.selection.clear_selection();
+ this.showContextMenu(event.pageX, event.pageY);
+ }
+ });
+ }
+
+ this._listener_document_key = event => this.handle_key_press(event);
+ this._listener_document_click = event => {
+ this._tag_container_focused = false;
+ let element = event.target as HTMLElement;
+ while(element) {
+ if(element === this._tag_container[0]) {
+ this._tag_container_focused = true;
+ break;
+ }
+ element = element.parentNode as HTMLElement;
+ }
+ };
+ document.addEventListener('click', this._listener_document_click);
+ document.addEventListener('keydown', this._listener_document_key);
+ }
+
+ tag_tree() : JQuery {
+ return this._tag_container;
+ }
+
+ destroy() {
+ ReactDOM.unmountComponentAtNode(this._tag_container[0]);
+
+ this._listener_document_click && document.removeEventListener('click', this._listener_document_click);
+ this._listener_document_click = undefined;
+
+ this._listener_document_key && document.removeEventListener('keydown', this._listener_document_key);
+ this._listener_document_key = undefined;
+
+ if(this.server) {
+ this.server.destroy();
+ this.server = undefined;
+ }
+ this.reset(); /* cleanup channel and clients */
+
+ this.channel_first = undefined;
+ this.channel_last = undefined;
+
+ this._tag_container.remove();
+ this.selection.destroy();
+ this.events.destroy();
+ }
+
+ initialiseHead(serverName: string, address: ServerAddress) {
+ this.server.reset();
+ this.server.remote_address = Object.assign({}, address);
+ this.server.properties.virtualserver_name = serverName;
+ this.events.fire("notify_root_channel_changed");
+ }
+
+ rootChannel() : ChannelEntry[] {
+ const result = [];
+ let first = this.channel_first;
+ while(first) {
+ result.push(first);
+ first = first.channel_next;
+ }
+ return result;
+ }
+
+ deleteChannel(channel: ChannelEntry) {
+ channel.channelTree = null;
+
+ batch_updates(BatchUpdateType.CHANNEL_TREE);
+ try {
+ if(!this.channels.remove(channel))
+ log.warn(LogCategory.CHANNEL, tr("Deleting an unknown channel!"));
+ channel.children(false).forEach(e => this.deleteChannel(e));
+
+ if(channel.clients(false).length !== 0) {
+ log.warn(LogCategory.CHANNEL, tr("Deleting a non empty channel! This could cause some errors."));
+ for(const client of channel.clients(false))
+ this.deleteClient(client, false);
+ }
+
+ const is_root_tree = !!channel.parent;
+ this.unregisterChannelFromTree(channel);
+ if(is_root_tree) this.events.fire("notify_root_channel_changed");
+ } finally {
+ flush_batched_updates(BatchUpdateType.CHANNEL_TREE);
+ }
+ }
+
+ insertChannel(channel: ChannelEntry, previous: ChannelEntry, parent: ChannelEntry) {
+ channel.channelTree = this;
+ this.channels.push(channel);
+
+ this.moveChannel(channel, previous, parent);
+ }
+
+ findChannel(channelId: number) : ChannelEntry | undefined {
+ if(typeof channelId === "string") /* legacy fix */
+ channelId = parseInt(channelId);
+
+ for(let index = 0; index < this.channels.length; index++)
+ if(this.channels[index].channelId === channelId) return this.channels[index];
+ return undefined;
+ }
+
+ find_channel_by_name(name: string, parent?: ChannelEntry, force_parent: boolean = true) : ChannelEntry | undefined {
+ for(let index = 0; index < this.channels.length; index++)
+ if(this.channels[index].channelName() == name && (!force_parent || parent == this.channels[index].parent))
+ return this.channels[index];
+ return undefined;
+ }
+
+ private unregisterChannelFromTree(channel: ChannelEntry, new_parent?: ChannelEntry) {
+ if(channel.parent) {
+ if(channel.parent.child_channel_head === channel)
+ channel.parent.child_channel_head = channel.channel_next;
+
+ /* We need only trigger this once.
+ If the new parent is equal to the old one with applying the "new" parent this event will get triggered */
+ if(new_parent !== channel.parent)
+ channel.parent.events.fire("notify_children_changed");
+ }
+
+ if(channel.channel_previous)
+ channel.channel_previous.channel_next = channel.channel_next;
+
+ if(channel.channel_next)
+ channel.channel_next.channel_previous = channel.channel_previous;
+
+ if(channel === this.channel_last)
+ this.channel_last = channel.channel_previous;
+
+ if(channel === this.channel_first)
+ this.channel_first = channel.channel_next;
+
+ channel.channel_next = undefined;
+ channel.channel_previous = undefined;
+ channel.parent = undefined;
+ }
+
+ moveChannel(channel: ChannelEntry, channel_previous: ChannelEntry, parent: ChannelEntry) {
+ if(channel_previous != null && channel_previous.parent != parent) {
+ console.error(tr("Invalid channel move (different parents! (%o|%o)"), channel_previous.parent, parent);
+ return;
+ }
+
+ let root_tree_updated = !channel.parent;
+ this.unregisterChannelFromTree(channel, parent);
+ channel.channel_previous = channel_previous;
+ channel.channel_next = undefined;
+ channel.parent = parent;
+
+ if(channel_previous) {
+ if(channel_previous == this.channel_last)
+ this.channel_last = channel;
+
+ channel.channel_next = channel_previous.channel_next;
+ channel_previous.channel_next = channel;
+
+ if(channel.channel_next)
+ channel.channel_next.channel_previous = channel;
+
+ if(!channel.parent_channel())
+ root_tree_updated = true;
+ else
+ channel.parent.events.fire("notify_children_changed");
+ } else {
+ if(parent) {
+ let children = parent.children();
+ parent.child_channel_head = channel;
+ if(children.length === 0) { //Self should be already in there
+ channel.channel_next = undefined;
+ } else {
+ channel.channel_next = children[0];
+ channel.channel_next.channel_previous = channel;
+ }
+ parent.events.fire("notify_children_changed");
+ } else {
+ console.error("No previous & paretn!");
+ channel.channel_next = this.channel_first;
+ if(this.channel_first)
+ this.channel_first.channel_previous = channel;
+
+ this.channel_first = channel;
+ this.channel_last = this.channel_last || channel;
+ root_tree_updated = true;
+ }
+ }
+
+ //channel.update_family_index();
+ //channel.children(true).forEach(e => e.update_family_index());
+ //channel.clients(true).forEach(e => e.update_family_index());
+
+ if(channel.channel_previous == channel) { /* shall never happen */
+ channel.channel_previous = undefined;
+ debugger;
+ }
+ if(channel.channel_next == channel) { /* shall never happen */
+ channel.channel_next = undefined;
+ debugger;
+ }
+
+ if(root_tree_updated)
+ this.events.fire("notify_root_channel_changed");
+ }
+
+ deleteClient(client: ClientEntry, animate_tag?: boolean) {
+ const old_channel = client.currentChannel();
+ old_channel?.unregisterClient(client);
+ this.clients.remove(client);
+
+ if(old_channel) {
+ this.client.side_bar.info_frame().update_channel_client_count(old_channel);
+ }
+
+
+ //FIXME: Trigger the notify_clients_changed event!
+ const voice_connection = this.client.serverConnection.voice_connection();
+ if(client.get_audio_handle()) {
+ if(!voice_connection) {
+ log.warn(LogCategory.VOICE, tr("Deleting client with a voice handle, but we haven't a voice connection!"));
+ } else {
+ voice_connection.unregister_client(client.get_audio_handle());
+ }
+ }
+ client.set_audio_handle(undefined);
+ client.destroy();
+ }
+
+ registerClient(client: ClientEntry) {
+ this.clients.push(client);
+ client.channelTree = this;
+
+ const voice_connection = this.client.serverConnection.voice_connection();
+ if(voice_connection)
+ client.set_audio_handle(voice_connection.register_client(client.clientId()));
+ }
+
+ unregisterClient(client: ClientEntry) {
+ if(!this.clients.remove(client))
+ return;
+ }
+
+ insertClient(client: ClientEntry, channel: ChannelEntry) : ClientEntry {
+ batch_updates(BatchUpdateType.CHANNEL_TREE);
+ try {
+ let newClient = this.findClient(client.clientId());
+ if(newClient)
+ client = newClient; //Got new client :)
+ else {
+ this.registerClient(client);
+ }
+
+ client.currentChannel()?.unregisterClient(client);
+ client["_channel"] = channel;
+ channel.registerClient(client);
+
+ return client;
+ } finally {
+ flush_batched_updates(BatchUpdateType.CHANNEL_TREE);
+ }
+ }
+
+ moveClient(client: ClientEntry, channel: ChannelEntry) {
+ batch_updates(BatchUpdateType.CHANNEL_TREE);
+ try {
+ let oldChannel = client.currentChannel();
+ oldChannel?.unregisterClient(client);
+ client["_channel"] = channel;
+ channel?.registerClient(client);
+
+ if(oldChannel) {
+ this.client.side_bar.info_frame().update_channel_client_count(oldChannel);
+ }
+ if(channel) {
+ this.client.side_bar.info_frame().update_channel_client_count(channel);
+ }
+ client.speaking = false;
+ } finally {
+ flush_batched_updates(BatchUpdateType.CHANNEL_TREE);
+ }
+ }
+
+ findClient?(clientId: number) : ClientEntry {
+ for(let index = 0; index < this.clients.length; index++) {
+ if(this.clients[index].clientId() == clientId)
+ return this.clients[index];
+ }
+ return undefined;
+ }
+
+ find_client_by_dbid?(client_dbid: number) : ClientEntry {
+ for(let index = 0; index < this.clients.length; index++) {
+ if(this.clients[index].properties.client_database_id == client_dbid)
+ return this.clients[index];
+ }
+ return undefined;
+ }
+
+ find_client_by_unique_id?(unique_id: string) : ClientEntry {
+ for(let index = 0; index < this.clients.length; index++) {
+ if(this.clients[index].properties.client_unique_identifier == unique_id)
+ return this.clients[index];
+ }
+ return undefined;
+ }
+
+ showContextMenu(x: number, y: number, on_close: () => void = undefined) {
+ let channelCreate =
+ this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_TEMPORARY).granted(1) ||
+ this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_SEMI_PERMANENT).granted(1) ||
+ this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_PERMANENT).granted(1);
+
+ contextmenu.spawn_context_menu(x, y,
+ {
+ type: contextmenu.MenuEntryType.ENTRY,
+ icon_class: "client-channel_create",
+ name: tr("Create channel"),
+ invalidPermission: !channelCreate,
+ callback: () => this.spawnCreateChannel()
+ },
+ contextmenu.Entry.HR(),
+ {
+ type: contextmenu.MenuEntryType.ENTRY,
+ icon_class: "client-channel_collapse_all",
+ name: tr("Collapse all channels"),
+ callback: () => this.collapse_channels()
+ },
+ {
+ type: contextmenu.MenuEntryType.ENTRY,
+ icon_class: "client-channel_expand_all",
+ name: tr("Expend all channels"),
+ callback: () => this.expand_channels()
+ },
+ contextmenu.Entry.CLOSE(on_close)
+ );
+ }
+ private open_multiselect_context_menu(entries: ChannelTreeEntry[], x: number, y: number) {
+ const clients = entries.filter(e => e instanceof ClientEntry) as ClientEntry[];
+ const channels = entries.filter(e => e instanceof ChannelEntry) as ChannelEntry[];
+ const server = entries.find(e => e instanceof ServerEntry) as ServerEntry;
+
+ let client_menu: contextmenu.MenuEntry[];
+ let channel_menu: contextmenu.MenuEntry[];
+ let server_menu: contextmenu.MenuEntry[];
+
+ if(clients.length > 0) {
+ client_menu = [];
+
+ const music_only = clients.map(e => e instanceof MusicClientEntry ? 0 : 1).reduce((a, b) => a + b, 0) == 0;
+ const music_entry = clients.map(e => e instanceof MusicClientEntry ? 1 : 0).reduce((a, b) => a + b, 0) > 0;
+ const local_client = clients.map(e => e instanceof LocalClientEntry ? 1 : 0).reduce((a, b) => a + b, 0) > 0;
+
+ if (!music_entry && !local_client) { //Music bots or local client cant be poked
+ client_menu.push({
+ type: contextmenu.MenuEntryType.ENTRY,
+ icon_class: "client-poke",
+ name: tr("Poke clients"),
+ callback: () => {
+ createInputModal(tr("Poke clients"), tr("Poke message:
"), text => true, result => {
+ if (typeof(result) === "string") {
+ for (const client of clients)
+ this.client.serverConnection.send_command("clientpoke", {
+ clid: client.clientId(),
+ msg: result
+ });
+
+ this.selection.clear_selection();
+ }
+ }, {width: 400, maxLength: 512}).open();
+ }
+ });
+ }
+ client_menu.push({
+ type: contextmenu.MenuEntryType.ENTRY,
+ icon_class: "client-move_client_to_own_channel",
+ name: tr("Move clients to your channel"),
+ callback: () => {
+ const target = this.client.getClient().currentChannel().getChannelId();
+ for(const client of clients)
+ this.client.serverConnection.send_command("clientmove", {
+ clid: client.clientId(),
+ cid: target
+ });
+ this.selection.clear_selection();
+ }
+ });
+ if (!local_client) {//local client cant be kicked and/or banned or kicked
+ client_menu.push(contextmenu.Entry.HR());
+ client_menu.push({
+ type: contextmenu.MenuEntryType.ENTRY,
+ icon_class: "client-kick_channel",
+ name: tr("Kick clients from channel"),
+ callback: () => {
+ createInputModal(tr("Kick clients from channel"), tr("Kick reason:
"), text => true, result => {
+ if (result) {
+ for (const client of clients)
+ this.client.serverConnection.send_command("clientkick", {
+ clid: client.clientId(),
+ reasonid: ViewReasonId.VREASON_CHANNEL_KICK,
+ reasonmsg: result
+ });
+ }
+ }, {width: 400, maxLength: 255}).open();
+ this.selection.clear_selection();
+ }
+ });
+
+ if (!music_entry) { //Music bots cant be banned or kicked
+ client_menu.push({
+ type: contextmenu.MenuEntryType.ENTRY,
+ icon_class: "client-kick_server",
+ name: tr("Kick clients fom server"),
+ callback: () => {
+ this.selection.clear_selection();
+ createInputModal(tr("Kick clients from server"), tr("Kick reason:
"), text => true, result => {
+ if (result) {
+ for (const client of clients)
+ this.client.serverConnection.send_command("clientkick", {
+ clid: client.clientId(),
+ reasonid: ViewReasonId.VREASON_SERVER_KICK,
+ reasonmsg: result
+ });
+ }
+ }, {width: 400, maxLength: 255}).open();
+ }
+ }, {
+ type: contextmenu.MenuEntryType.ENTRY,
+ icon_class: "client-ban_client",
+ name: tr("Ban clients"),
+ invalidPermission: !this.client.permissions.neededPermission(PermissionType.I_CLIENT_BAN_MAX_BANTIME).granted(1),
+ callback: () => {
+ this.selection.clear_selection();
+ spawnBanClient(this.client, (clients).map(entry => {
+ return {
+ name: entry.clientNickName(),
+ unique_id: entry.properties.client_unique_identifier
+ }
+ }), (data) => {
+ for (const client of clients)
+ this.client.serverConnection.send_command("banclient", {
+ uid: client.properties.client_unique_identifier,
+ banreason: data.reason,
+ time: data.length
+ }, {
+ flagset: [data.no_ip ? "no-ip" : "", data.no_hwid ? "no-hardware-id" : "", data.no_name ? "no-nickname" : ""]
+ }).then(() => {
+ this.client.sound.play(Sound.USER_BANNED);
+ });
+ });
+ }
+ });
+ }
+ if(music_only) {
+ client_menu.push(contextmenu.Entry.HR());
+ client_menu.push({
+ name: tr("Delete bots"),
+ icon_class: "client-delete",
+ disabled: false,
+ callback: () => {
+ const param_string = clients.map((_, index) => "{" + index + "}").join(', ');
+ const param_values = clients.map(client => client.createChatTag(true));
+ const tag = $.spawn("div").append(...formatMessage(tr("Do you really want to delete ") + param_string, ...param_values));
+ const tag_container = $.spawn("div").append(tag);
+ spawnYesNo(tr("Are you sure?"), tag_container, result => {
+ if(result) {
+ for(const client of clients)
+ this.client.serverConnection.send_command("musicbotdelete", {
+ botid: client.properties.client_database_id
+ });
+ this.selection.clear_selection();
+ }
+ });
+ },
+ type: contextmenu.MenuEntryType.ENTRY
+ });
+ }
+ }
+ }
+ if(channels.length > 0) {
+ channel_menu = [];
+
+ //TODO: Subscribe mode settings
+ channel_menu.push({
+ type: MenuEntryType.ENTRY,
+ name: tr("Delete all channels"),
+ icon_class: "client-delete",
+ callback: () => {
+ spawnYesNo(tr("Are you sure?"), tra("Do you really want to delete {0} channels?", channels.length), result => {
+ if(typeof result === "boolean" && result) {
+ for(const channel of channels)
+ this.client.serverConnection.send_command("channeldelete", { cid: channel.channelId });
+ this.selection.clear_selection();
+ }
+ });
+ }
+ });
+ }
+ if(server)
+ server_menu = server.contextMenuItems();
+
+ const menus = [
+ {
+ text: tr("Apply to all clients"),
+ menu: client_menu,
+ icon: "client-user-account"
+ },
+ {
+ text: tr("Apply to all channels"),
+ menu: channel_menu,
+ icon: "client-channel_green"
+ },
+ {
+ text: tr("Server actions"),
+ menu: server_menu,
+ icon: "client-server_green"
+ }
+ ].filter(e => !!e.menu);
+ if(menus.length === 1) {
+ contextmenu.spawn_context_menu(x, y, ...menus[0].menu);
+ } else {
+ contextmenu.spawn_context_menu(x, y, ...menus.map(e => {
+ return {
+ icon_class: e.icon,
+ name: e.text,
+ type: MenuEntryType.SUB_MENU,
+ sub_menu: e.menu
+ } as contextmenu.MenuEntry
+ }));
+ }
+ }
+
+ clientsByGroup(group: Group) : ClientEntry[] {
+ let result = [];
+
+ for(let client of this.clients) {
+ if(client.groupAssigned(group))
+ result.push(client);
+ }
+
+ return result;
+ }
+
+ clientsByChannel(channel: ChannelEntry) : ClientEntry[] {
+ let result = [];
+
+ for(let client of this.clients) {
+ if(client.currentChannel() == channel)
+ result.push(client);
+ }
+
+ return result;
+ }
+
+ reset() {
+ batch_updates(BatchUpdateType.CHANNEL_TREE);
+
+ try {
+ this.selection.reset();
+
+ const voice_connection = this.client.serverConnection ? this.client.serverConnection.voice_connection() : undefined;
+ for(const client of this.clients) {
+ if(client.get_audio_handle() && voice_connection) {
+ voice_connection.unregister_client(client.get_audio_handle());
+ client.set_audio_handle(undefined);
+ }
+ client.destroy();
+ }
+ this.clients = [];
+
+ for(const channel of this.channels)
+ channel.destroy();
+
+ this.channels = [];
+ this.channel_last = undefined;
+ this.channel_first = undefined;
+ this.events.fire("notify_tree_reset");
+ } finally {
+ flush_batched_updates(BatchUpdateType.CHANNEL_TREE);
+ }
+ }
+
+ spawnCreateChannel(parent?: ChannelEntry) {
+ createChannelModal(this.client, undefined, parent, this.client.permissions, (properties?, permissions?) => {
+ if(!properties) return;
+ properties["cpid"] = parent ? parent.channelId : 0;
+ log.debug(LogCategory.CHANNEL, tr("Creating a new channel.\nProperties: %o\nPermissions: %o"), properties);
+ this.client.serverConnection.send_command("channelcreate", properties).then(() => {
+ let channel = this.find_channel_by_name(properties.channel_name, parent, true);
+ if(!channel) {
+ log.error(LogCategory.CHANNEL, tr("Failed to resolve channel after creation. Could not apply permissions!"));
+ return;
+ }
+ if(permissions && permissions.length > 0) {
+ let perms = [];
+ for(let perm of permissions) {
+ perms.push({
+ permvalue: perm.value,
+ permnegated: false,
+ permskip: false,
+ permid: perm.type.id
+ });
+ }
+
+ perms[0]["cid"] = channel.channelId;
+ return this.client.serverConnection.send_command("channeladdperm", perms, {
+ flagset: ["continueonerror"]
+ }).then(() => new Promise(resolve => { resolve(channel); }));
+ }
+
+ return new Promise(resolve => { resolve(channel); })
+ }).then(channel => {
+ this.client.log.log(server_log.Type.CHANNEL_CREATE, {
+ channel: channel.log_data(),
+ creator: this.client.getClient().log_data(),
+ own_action: true
+ });
+ this.client.sound.play(Sound.CHANNEL_CREATED);
+ });
+ });
+ }
+
+ private select_next_channel(channel: ChannelEntry, select_client: boolean) {
+ if(select_client) {
+ const clients = channel.clients_ordered();
+ if(clients.length > 0) {
+ this.events.fire("action_select_entries", {
+ mode: "exclusive",
+ entries: [ clients[0] ]
+ });
+ return;
+ }
+ }
+
+ const children = channel.children();
+ if(children.length > 0) {
+ this.events.fire("action_select_entries", {
+ mode: "exclusive",
+ entries: [ children[0] ]
+ });
+ return;
+ }
+
+ const next = channel.channel_next;
+ if(next) {
+ this.events.fire("action_select_entries", {
+ mode: "exclusive",
+ entries: [ next ]
+ });
+ return;
+ }
+
+ let parent = channel.parent_channel();
+ while(parent) {
+ const p_next = parent.channel_next;
+ if(p_next) {
+ this.events.fire("action_select_entries", {
+ mode: "exclusive",
+ entries: [ p_next ]
+ });
+ return;
+ }
+
+ parent = parent.parent_channel();
+ }
+ }
+
+ handle_key_press(event: KeyboardEvent) {
+ if(!this._tag_container_focused || !this.selection.is_anything_selected() || this.selection.is_multi_select()) return;
+
+ const selected = this.selection.selected_entries[0];
+ if(event.keyCode == KeyCode.KEY_UP) {
+ event.preventDefault();
+ if(selected instanceof ChannelEntry) {
+ let previous = selected.channel_previous;
+
+ if(previous) {
+ while(true) {
+ const siblings = previous.children();
+ if(siblings.length == 0) break;
+ previous = siblings.last();
+ }
+ const clients = previous.clients_ordered();
+ if(clients.length > 0) {
+ this.events.fire("action_select_entries", {
+ mode: "exclusive",
+ entries: [ clients.last() ]
+ });
+ return;
+ } else {
+ this.events.fire("action_select_entries", {
+ mode: "exclusive",
+ entries: [ previous ]
+ });
+ return;
+ }
+ } else if(selected.hasParent()) {
+ const channel = selected.parent_channel();
+ const clients = channel.clients_ordered();
+ if(clients.length > 0) {
+ this.events.fire("action_select_entries", {
+ mode: "exclusive",
+ entries: [ clients.last() ]
+ });
+ return;
+ } else {
+ this.events.fire("action_select_entries", {
+ mode: "exclusive",
+ entries: [ channel ]
+ });
+ return;
+ }
+ } else {
+ this.events.fire("action_select_entries", {
+ mode: "exclusive",
+ entries: [ this.server ]
+ });
+ }
+ } else if(selected instanceof ClientEntry) {
+ const channel = selected.currentChannel();
+ const clients = channel.clients_ordered();
+ const index = clients.indexOf(selected);
+ if(index > 0) {
+ this.events.fire("action_select_entries", {
+ mode: "exclusive",
+ entries: [ clients[index - 1] ]
+ });
+ return;
+ }
+ this.events.fire("action_select_entries", {
+ mode: "exclusive",
+ entries: [ channel ]
+ });
+ return;
+ }
+
+ } else if(event.keyCode == KeyCode.KEY_DOWN) {
+ event.preventDefault();
+ if(selected instanceof ChannelEntry) {
+ this.select_next_channel(selected, true);
+ } else if(selected instanceof ClientEntry){
+ const channel = selected.currentChannel();
+ const clients = channel.clients_ordered();
+ const index = clients.indexOf(selected);
+ if(index + 1 < clients.length) {
+ this.events.fire("action_select_entries", {
+ mode: "exclusive",
+ entries: [ clients[index + 1] ]
+ });
+ return;
+ }
+
+ this.select_next_channel(channel, false);
+ } else if(selected instanceof ServerEntry)
+ this.events.fire("action_select_entries", {
+ mode: "exclusive",
+ entries: [ this.channel_first ]
+ });
+ } else if(event.keyCode == KeyCode.KEY_RETURN) {
+ if(selected instanceof ChannelEntry) {
+ selected.joinChannel();
+ }
+ }
+ }
+
+ toggle_server_queries(flag: boolean) {
+ if(this._show_queries == flag) return;
+ this._show_queries = flag;
+
+ this.events.fire("notify_query_view_state_changed", { queries_shown: flag });
+ }
+ areServerQueriesShown() { return this._show_queries; }
+
+ get_first_channel?() : ChannelEntry {
+ return this.channel_first;
+ }
+
+ unsubscribe_all_channels(subscribe_specified?: boolean) {
+ if(!this.client.serverConnection || !this.client.serverConnection.connected())
+ return;
+
+ this.client.serverConnection.send_command('channelunsubscribeall').then(() => {
+ const channels: number[] = [];
+ for(const channel of this.channels) {
+ if(channel.subscribe_mode == ChannelSubscribeMode.SUBSCRIBED)
+ channels.push(channel.getChannelId());
+ }
+
+ if(channels.length > 0) {
+ this.client.serverConnection.send_command('channelsubscribe', channels.map(e => { return {cid: e}; })).catch(error => {
+ console.warn(tr("Failed to subscribe to specific channels (%o)"), channels);
+ });
+ }
+ }).catch(error => {
+ console.warn(tr("Failed to unsubscribe to all channels! (%o)"), error);
+ });
+ }
+
+ subscribe_all_channels() {
+ if(!this.client.serverConnection || !this.client.serverConnection.connected())
+ return;
+
+ this.client.serverConnection.send_command('channelsubscribeall').then(() => {
+ const channels: number[] = [];
+ for(const channel of this.channels) {
+ if(channel.subscribe_mode == ChannelSubscribeMode.UNSUBSCRIBED)
+ channels.push(channel.getChannelId());
+ }
+
+ if(channels.length > 0) {
+ this.client.serverConnection.send_command('channelunsubscribe', channels.map(e => { return {cid: e}; })).catch(error => {
+ console.warn(tr("Failed to unsubscribe to specific channels (%o)"), channels);
+ });
+ }
+ }).catch(error => {
+ console.warn(tr("Failed to subscribe to all channels! (%o)"), error);
+ });
+ }
+
+ expand_channels(root?: ChannelEntry) {
+ if(typeof root === "undefined")
+ this.rootChannel().forEach(e => this.expand_channels(e));
+ else {
+ root.collapsed = false;
+ for(const child of root.children(false))
+ this.expand_channels(child);
+ }
+ }
+
+ collapse_channels(root?: ChannelEntry) {
+ if(typeof root === "undefined")
+ this.rootChannel().forEach(e => this.collapse_channels(e));
+ else {
+ root.collapsed = true;
+ for(const child of root.children(false))
+ this.collapse_channels(child);
+ }
+ }
+
+ private onChannelEntryMove(start, current) {
+ const move = this.view_move.current;
+ if(!move) return;
+
+ const target = this.view.current.getEntryFromPoint(start.x, start.y);
+ if(target && this.selection.selected_entries.findIndex(e => e === target) === -1)
+ this.events.fire("action_select_entries", { mode: "auto", entries: [ target ]});
+
+ const selection = this.selection.selected_entries;
+ if(selection.length === 0 || selection.filter(e => !(e instanceof ClientEntry)).length > 0)
+ return;
+
+ move.enableEntryMove(this.view.current, selection.map(e => e as ClientEntry).map(e => e.clientNickName()).join(","), start, current, () => {
+ this.events.fire("notify_entry_move_begin");
+ });
+ }
+
+ private onMoveEnd(x: number, y: number) {
+ batch_updates(BatchUpdateType.CHANNEL_TREE);
+ try {
+ this.events.fire("notify_entry_move_end");
+
+ const selection = this.selection.selected_entries.filter(e => e instanceof ClientEntry) as ClientEntry[];
+ if(selection.length === 0) return;
+ this.selection.clear_selection();
+
+ const target = this.view.current.getEntryFromPoint(x, y);
+ let target_channel: ChannelEntry;
+ if(target instanceof ClientEntry)
+ target_channel = target.currentChannel();
+ else if(target instanceof ChannelEntry)
+ target_channel = target;
+ if(!target_channel) return;
+
+ selection.filter(e => e.currentChannel() !== target_channel).forEach(e => {
+ this.client.serverConnection.send_command("clientmove", {
+ clid: e.clientId(),
+ cid: target_channel.channelId
+ });
+ });
+ } finally {
+ flush_batched_updates(BatchUpdateType.CHANNEL_TREE);
+ }
+ }
+
+ isClientMoveActive() {
+ return !!this.view_move.current?.isActive();
+ }
+}
\ No newline at end of file
diff --git a/tools/dtsgen/declare_fixup.ts b/tools/dtsgen/declare_fixup.ts
index b39eea28..cc9b0f12 100644
--- a/tools/dtsgen/declare_fixup.ts
+++ b/tools/dtsgen/declare_fixup.ts
@@ -12,4 +12,6 @@ export function fix_declare_global(nodes: ts.Node[]) : ts.Node[] {
if(has_export) return nodes;
return [];
-}
\ No newline at end of file
+}
+
+SyntaxKind.PlusEqualsToken
\ No newline at end of file
diff --git a/tsbaseconfig.json b/tsbaseconfig.json
index 167763c2..2d9d3f12 100644
--- a/tsbaseconfig.json
+++ b/tsbaseconfig.json
@@ -16,6 +16,7 @@
"webpack/ManifestPlugin.ts",
"webpack/EJSGenerator.ts",
"webpack/WatLoader.ts",
+ "webpack/DevelBlocks.ts",
"file.ts"
],
diff --git a/web/js/codec/CodecWrapperWorker.ts b/web/js/codec/CodecWrapperWorker.ts
index 7350bbdb..91526a66 100644
--- a/web/js/codec/CodecWrapperWorker.ts
+++ b/web/js/codec/CodecWrapperWorker.ts
@@ -54,19 +54,22 @@ export class CodecWrapperWorker extends BasicCodec {
async initialise() : Promise {
if(this._initialized) return;
+ if(this._initialize_promise)
+ return await this._initialize_promise;
this._initialize_promise = this.spawn_worker().then(() => this.execute("initialise", {
type: this.type,
channelCount: this.channelCount,
})).then(result => {
- if(result.success)
+ if(result.success) {
+ this._initialized = true;
return Promise.resolve(true);
+ }
log.error(LogCategory.VOICE, tr("Failed to initialize codec %s: %s"), CodecType[this.type], result.error);
return Promise.reject(result.error);
});
- this._initialized = true;
await this._initialize_promise;
}
@@ -81,6 +84,8 @@ export class CodecWrapperWorker extends BasicCodec {
}
async decode(data: Uint8Array): Promise {
+ if(!this.initialized()) throw "codec not initialized/initialize failed";
+
const result = await this.execute("decodeSamples", { data: data, length: data.length });
if(result.timings.downstream > 5 || result.timings.upstream > 5 || result.timings.handle > 5)
log.warn(LogCategory.VOICE, tr("Worker message stock time: {downstream: %dms, handle: %dms, upstream: %dms}"), result.timings.downstream, result.timings.handle, result.timings.upstream);
@@ -102,6 +107,8 @@ export class CodecWrapperWorker extends BasicCodec {
}
async encode(data: AudioBuffer) : Promise {
+ if(!this.initialized()) throw "codec not initialized/initialize failed";
+
let buffer = new Float32Array(this.channelCount * data.length);
for (let offset = 0; offset < data.length; offset++) {
for (let channel = 0; channel < this.channelCount; channel++)
diff --git a/web/js/connection/ServerConnection.ts b/web/js/connection/ServerConnection.ts
index 7eb3ba2c..ccd2898b 100644
--- a/web/js/connection/ServerConnection.ts
+++ b/web/js/connection/ServerConnection.ts
@@ -345,9 +345,11 @@ export class ServerConnection extends AbstractServerConnection {
return;
}
if(json["type"] === "command") {
+ /* devel-block(log-networking-commands) */
let group = log.group(log.LogType.DEBUG, LogCategory.NETWORKING, tr("Handling command '%s'"), json["command"]);
group.log(tr("Handling command '%s'"), json["command"]);
group.group(log.LogType.TRACE, tr("Json:")).collapsed(true).log("%o", json).end();
+ /* devel-block-end */
this._command_boss.invoke_handle({
command: json["command"],
@@ -361,7 +363,9 @@ export class ServerConnection extends AbstractServerConnection {
if(this._voice_connection)
this._voice_connection.start_rtc_session(); /* FIXME: Move it to a handler boss and not here! */
}
+ /* devel-block(log-networking-commands) */
group.end();
+ /* devel-block-end */
} else if(json["type"] === "WebRTC") {
if(this._voice_connection)
this._voice_connection.handleControlPacket(json);
diff --git a/web/js/voice/VoiceHandler.ts b/web/js/voice/VoiceHandler.ts
index c4077273..0eef79c4 100644
--- a/web/js/voice/VoiceHandler.ts
+++ b/web/js/voice/VoiceHandler.ts
@@ -77,10 +77,7 @@ export namespace codec {
this.entries[index].instance.initialise().then((flag) => {
//TODO test success flag
this.ownCodec(clientId, callback_encoded, false).then(resolve).catch(reject);
- }).catch(error => {
- log.error(LogCategory.VOICE, tr("Could not initialize codec!\nError: %o"), error);
- reject(typeof(error) === 'string' ? error : tr("Could not initialize codec!"));
- });
+ }).catch(reject);
}
return;
} else if(this.entries[index].owner == 0) {
@@ -388,8 +385,9 @@ export class VoiceConnection extends AbstractVoiceConnection {
let config: RTCConfiguration = {};
config.iceServers = [];
config.iceServers.push({ urls: 'stun:stun.l.google.com:19302' });
+ //config.iceServers.push({ urls: "stun:stun.teaspeak.de:3478" });
this.rtcPeerConnection = new RTCPeerConnection(config);
- const dataChannelConfig = { ordered: true, maxRetransmits: 0 };
+ const dataChannelConfig = { ordered: false, maxRetransmits: 0 };
this.dataChannel = this.rtcPeerConnection.createDataChannel('main', dataChannelConfig);
this.dataChannel.onmessage = this.on_data_channel_message.bind(this);
@@ -401,6 +399,8 @@ export class VoiceConnection extends AbstractVoiceConnection {
sdpConstraints.offerToReceiveVideo = false;
sdpConstraints.voiceActivityDetection = true;
+ this.rtcPeerConnection.onicegatheringstatechange = () => console.log("ICE gathering state changed to %s", this.rtcPeerConnection.iceGatheringState);
+ this.rtcPeerConnection.oniceconnectionstatechange = () => console.log("ICE connection state changed to %s", this.rtcPeerConnection.iceConnectionState);
this.rtcPeerConnection.onicecandidate = this.on_local_ice_candidate.bind(this);
if(this.local_audio_stream) { //May a typecheck?
this.rtcPeerConnection.addStream(this.local_audio_stream.stream);
@@ -431,8 +431,26 @@ export class VoiceConnection extends AbstractVoiceConnection {
this.connection.client.update_voice_status(undefined);
}
+ private registerRemoteICECandidate(candidate: RTCIceCandidate) {
+ if(candidate.candidate === "") {
+ console.log("Adding end candidate");
+ this.rtcPeerConnection.addIceCandidate(null).catch(error => {
+ log.info(LogCategory.VOICE, tr("Failed to add remote cached ice candidate finish: %o"), error);
+ });
+ return;
+ }
+
+ const pcandidate = new RTCIceCandidate(candidate);
+ if(pcandidate.protocol !== "tcp") return; /* UDP does not work currently */
+
+ log.info(LogCategory.VOICE, tr("Add remote ice! (%o)"), pcandidate);
+ this.rtcPeerConnection.addIceCandidate(pcandidate).catch(error => {
+ log.info(LogCategory.VOICE, tr("Failed to add remote cached ice candidate %o: %o"), candidate, error);
+ });
+ }
+
private _ice_use_cache: boolean = true;
- private _ice_cache: any[] = [];
+ private _ice_cache: RTCIceCandidate[] = [];
handleControlPacket(json) {
if(json["request"] === "answer") {
const session_description = new RTCSessionDescription(json["msg"]);
@@ -440,24 +458,20 @@ export class VoiceConnection extends AbstractVoiceConnection {
this.rtcPeerConnection.setRemoteDescription(session_description).then(() => {
log.info(LogCategory.VOICE, tr("Answer applied successfully. Applying ICE candidates (%d)."), this._ice_cache.length);
this._ice_use_cache = false;
- for(let msg of this._ice_cache) {
- this.rtcPeerConnection.addIceCandidate(new RTCIceCandidate(msg)).catch(error => {
- log.info(LogCategory.VOICE, tr("Failed to add remote cached ice candidate %s: %o"), msg, error);
- });
- }
+
+ for(let candidate of this._ice_cache)
+ this.registerRemoteICECandidate(candidate);
this._ice_cache = [];
}).catch(error => {
log.info(LogCategory.VOICE, tr("Failed to apply remote description: %o"), error); //FIXME error handling!
});
- } else if(json["request"] === "ice") {
+ } else if(json["request"] === "ice" || json["request"] === "ice_finish") {
+ const candidate = new RTCIceCandidate(json["msg"]);
if(!this._ice_use_cache) {
- log.info(LogCategory.VOICE, tr("Add remote ice! (%o)"), json["msg"]);
- this.rtcPeerConnection.addIceCandidate(new RTCIceCandidate(json["msg"])).catch(error => {
- log.info(LogCategory.VOICE, tr("Failed to add remote ice candidate %s: %o"), json["msg"], error);
- });
+ this.registerRemoteICECandidate(candidate);
} else {
log.info(LogCategory.VOICE, tr("Cache remote ice! (%o)"), json["msg"]);
- this._ice_cache.push(json["msg"]);
+ this._ice_cache.push(candidate);
}
} else if(json["request"] == "status") {
if(json["state"] == "failed") {
@@ -472,22 +486,25 @@ export class VoiceConnection extends AbstractVoiceConnection {
}
//TODO handle fail specially when its not allowed to reconnect
}
+ } else {
+ log.warn(LogCategory.NETWORKING, tr("Received unknown web client control packet: %s"), json["request"]);
}
}
private on_local_ice_candidate(event: RTCPeerConnectionIceEvent) {
if (event) {
- //if(event.candidate && event.candidate.protocol !== "udp")
- // return;
+ if(event.candidate && event.candidate.protocol !== "tcp")
+ return;
- log.info(LogCategory.VOICE, tr("Gathered local ice candidate %o."), event.candidate);
if(event.candidate) {
+ log.info(LogCategory.VOICE, tr("Gathered local ice candidate for stream %d: %s"), event.candidate.sdpMLineIndex, event.candidate.candidate);
this.connection.sendData(JSON.stringify({
type: 'WebRTC',
request: "ice",
msg: event.candidate,
}));
} else {
+ log.info(LogCategory.VOICE, tr("Local ICE candidate gathering finish."));
this.connection.sendData(JSON.stringify({
type: 'WebRTC',
request: "ice_finish"
diff --git a/web/js/workers/codec/CodecWorker.ts b/web/js/workers/codec/CodecWorker.ts
index 9ef301b4..e27ddad3 100644
--- a/web/js/workers/codec/CodecWorker.ts
+++ b/web/js/workers/codec/CodecWorker.ts
@@ -64,6 +64,9 @@ async function handle_message(command: string, data: any) : Promise { return {
{
loader: 'css-loader',
options: {
- modules: true,
+ modules: {
+ mode: "local",
+ localIdentName: isDevelopment ? "[path][name]__[local]--[hash:base64:5]" : "[hash:base64]",
+ },
sourceMap: isDevelopment
}
},
@@ -141,6 +144,12 @@ export const config = async (target: "web" | "client") => { return {
};
}
}
+ },
+ {
+ loader: "./webpack/DevelBlocks.js",
+ options: {
+ enabled: true
+ }
}
]
},
diff --git a/webpack/DevelBlocks.ts b/webpack/DevelBlocks.ts
new file mode 100644
index 00000000..06431988
--- /dev/null
+++ b/webpack/DevelBlocks.ts
@@ -0,0 +1,27 @@
+import {RawSourceMap} from "source-map";
+import * as webpack from "webpack";
+import * as loaderUtils from "loader-utils";
+
+import LoaderContext = webpack.loader.LoaderContext;
+
+export default function loader(this: LoaderContext, source: string | Buffer, sourceMap?: RawSourceMap): string | Buffer | void | undefined {
+ this.cacheable();
+
+ const options = loaderUtils.getOptions(this);
+ if(!options.enabled) {
+ this.callback(null, source);
+ return;
+ }
+
+ const start_regex = "devel-block\\((?\\S+)\\)";
+ const end_regex = "devel-block-end";
+
+ const pattern = new RegExp("[\\t ]*\\/\\* ?" + start_regex + " ?\\*\\/[\\s\\S]*?\\/\\* ?" + end_regex + " ?\\*\\/[\\t ]*\\n?", "g");
+ source = (source as string).replace(pattern, (value, type) => {
+ if(type === "log-networking-commands")
+ return value;
+
+ return "/* snipped block \"" + type + "\" */";
+ });
+ this.callback(null, source);
+}
\ No newline at end of file
diff --git a/webpack/ManifestPlugin.ts b/webpack/ManifestPlugin.ts
index d0e3d0a3..42d57b3e 100644
--- a/webpack/ManifestPlugin.ts
+++ b/webpack/ManifestPlugin.ts
@@ -1,5 +1,5 @@
import * as webpack from "webpack";
-import * as fs from "fs";
+import * as fs from "fs-extra";
import * as path from "path";
interface Options {
@@ -79,6 +79,8 @@ class ManifestGenerator {
});
compiler.hooks.done.tap(this.constructor.name, () => {
+ const file = this.options.file || "manifest.json";
+ fs.mkdirpSync(path.dirname(file));
fs.writeFileSync(this.options.file || "manifest.json", JSON.stringify(this.manifest_content));
});
}