Merge pull request #88 from TeaSpeak/develop

React - Channel tree
This commit is contained in:
WolverinDEV 2020-04-21 17:47:42 +02:00 committed by GitHub
commit 056dc7c8d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
77 changed files with 4248 additions and 3632 deletions

View file

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

11
auth/.gitignore vendored
View file

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

View file

@ -1,250 +0,0 @@
<?php
$GLOBALS["COOKIE_NAME_USER_DATA"] = "user_data";
$GLOBALS["COOKIE_NAME_USER_SIGN"] = "user_sign";
$host = gethostname();
$localhost = false;
if($host == "WolverinDEV")
$localhost = true;
function authPath() {
if (file_exists("auth")) {
return "auth/";
} else return "";
}
function mainPath() {
global $localhost;
if ($localhost) {
return "../";
} else return "";
}
function remoteAddress()
{
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
} else {
$ip = $_SERVER['REMOTE_ADDR'];
}
return $ip;
}
/** @return \XF\App */
function getXF()
{
if (isset($GLOBALS["XF_APP"])) return $GLOBALS["XF_APP"];
if (file_exists("/var/www/forum.teaspeak"))
$dir = "/var/www/forum.teaspeak";
else if (file_exists(__DIR__ . "/xf"))
$dir = __DIR__ . "/xf";
else if (file_exists(__DIR__ . "/auth/xf"))
$dir = __DIR__ . "/auth/xf";
else
return null;
require($dir . '/src/XF.php');
XF::start($dir);
return ($GLOBALS["XF_APP"] = XF::setupApp('XF\Pub\App'));
}
function milliseconds()
{
$mt = explode(' ', microtime());
return ((int)$mt[1]) * 1000 + ((int)round($mt[0] * 1000));
}
/**
* @param $username
* @param $password
* @return array
*/
function checkLogin($username, $password) {
$allowedXFGroups = [
3, //Administrator
6, //Web tester
5 //Premium
];
$app = getXF();
$response = [];
$response["success"] = false;
if(!$app) goto _return;
if (!isset($username) || !isset($password)) {
$response["msg"] = "missing credentials";
goto _return;
}
/** @var \XF\Service\User\Login $loginService */
$loginService = $app->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"
]));
}
}

View file

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

View file

@ -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);
}
//<i class="fa fa-circle-o-notch fa-spin" id="login-loader"></i>
$("#user").on('keydown', event => {
if(event.key == "Enter") $("#pass").focus();
});
$("#pass").on('keydown', event => {
if(event.key == "Enter") $("#btn_login").trigger("click");
});

View file

@ -1,37 +0,0 @@
<?php
include_once('auth.php');
$session = test_session();
if($session == 0) {
header('Location: ' . mainPath() . 'index.php');
die();
}
?>
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="css/auth.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<script src="https://code.jquery.com/jquery-latest.min.js"></script>
</head>
<body>
<div class="inner-container">
<div class="box">
<h1>Login</h1>
<div id="login">
<a class="error">some error code</a>
<input type="text" placeholder="Username" id="user"/>
<input type="password" placeholder="Password" id="pass"/>
<button id="btn_login" target="<?php echo mainPath() . "index.php"; ?>">Login</button>
<p>Create a account on <a href="//forum.teaspeak.de">forum.teaspeak.de</a></p>
</div>
<div id="success">
<a> Successful logged in!</a><br>
<a>You will be redirected in 3 seconds</a>
</div>
</div>
</div>
<script src="js/auth.js"></script>
</body>
</html>

137
file.ts
View file

@ -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 = "<?php\n";
script += "$params = json_decode(urldecode(\"" + encodeURIComponent(JSON.stringify(query)) + "\")); \n";
script += "foreach($params as $key => $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 <client|web> <dev|rel> | List all project files");
console.log(" node files.js develop <client|web> [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 */

View file

@ -4,4 +4,6 @@ window["loader"] = loader_base;
/* let the loader register himself at the window first */
setTimeout(loader.run, 0);
export {};
export {};
//window.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject = function () {};

View file

@ -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 */

15
package-lock.json generated
View file

@ -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",

View file

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

View file

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

View file

@ -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`.

View file

@ -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"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -166,6 +166,7 @@
width: 25%;
min-width: 10em;
min-height: 10em;
overflow: hidden;
background-color: #222226;

View file

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

View file

@ -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 = <BufferSource>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<Response>;
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<Icon>} = {};
readonly events: Registry<IconManagerEvents>;
private loading_timestamps: {[key: number]: IconManagerLoadingData} = {};
constructor(handle: FileManager) {
this.handle = handle;
this.events = new Registry<IconManagerEvents>();
}
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<void> {
@ -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<Icon> {
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<Icon>} = {};
static load_cached_icon(id: number, ignore_age?: boolean) : Promise<Icon> | 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<Response> {
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<Icon> {
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<Icon> {
return this._loading_promises[id] || (this._loading_promises[id] = this._load_icon(id));
}
async resolve_icon(id: number) : Promise<Icon> {
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> | Icon | undefined, options?: {
static generate_tag(icon: LocalIcon | undefined, options?: {
animate?: boolean
}) : JQuery<HTMLDivElement> {
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<HTMLDivElement> {
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<Icon>} = {};
private _loading_promises: {[response_avatar_id:number]:Promise<any>} = {};
constructor(handle: FileManager) {
this.handle = handle;

View file

@ -68,6 +68,7 @@ export interface Bookmark {
connect_profile: string;
last_icon_id?: number;
last_icon_server_id?: string;
}
export interface DirectoryBookmark {

View file

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

View file

@ -262,7 +262,7 @@ export class CommandHelper extends AbstractCommandHandler {
});
}
request_playlist_songs(playlist_id: number) : Promise<PlaylistSong[]> {
request_playlist_songs(playlist_id: number, process_result?: boolean) : Promise<PlaylistSong[]> {
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) {

View file

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

View file

@ -9,6 +9,7 @@ export enum ErrorID {
PLAYLIST_IS_IN_USE = 0x2103,
FILE_ALREADY_EXISTS = 2050,
FILE_NOT_FOUND = 2051,
CLIENT_INVALID_ID = 0x0200,

View file

@ -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<Events> {
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<Events> {
}
}
off<T extends keyof Events>(handler: (event?: Event<Events, T>) => void);
off<T extends keyof Events>(event: T, handler: (event?: Event<Events, T>) => void);
off<T extends keyof Events>(handler: (event?) => void);
off<T extends keyof Events>(event: T, handler: (event?: Events[T] & Event<Events, T>) => void);
off(event: (keyof Events)[], handler: (event?: Event<Events, keyof Events>) => void);
off(handler_or_events, handler?) {
if(typeof handler_or_events === "function") {
@ -107,36 +106,49 @@ export class Registry<Events> {
this.connections[event].remove(target as any);
}
fire<T extends keyof Events>(event_type: T, data?: Events[T]) {
fire<T extends keyof Events>(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<T extends keyof Events>(event_type: T, data?: Events[T]) {
setTimeout(() => this.fire(event_type, data));
fire_async<T extends keyof Events>(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<ObjectClass = React.Component<any, any>, 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<ObjectClass = React.Component<any, any>, 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<channel_tree.client>();
/*
const eclient = new Registry<ClientEvents>();
const emusic = new Registry<sidebar.music>();
eclient.on("property_update", event => { event.as<"playlist_song_loaded">(); });
eclient.connect("playlist_song_loaded", emusic);
eclient.connect("playlist_song_loaded", emusic);
eclient.connect("playlist_song_loaded", emusic);
*/

View file

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

View file

@ -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<PermissionValue[]>;
}
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<GroupEvents>;
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<GroupEvents>();
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<PermissionValue[]> { //database_empty_result

View file

@ -492,7 +492,8 @@ export class PermissionManager extends AbstractCommandHandler {
requestClientChannelPermissions(client_id: number, channel_id: number) : Promise<PermissionValue[]> {
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));
}

View file

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

View file

@ -156,7 +156,8 @@ export class Settings extends StaticSettings {
static readonly KEY_DISABLE_CONTEXT_MENU: SettingsKey<boolean> = {
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<boolean> = {
@ -365,6 +366,13 @@ export class Settings extends StaticSettings {
}
};
static readonly FN_SERVER_CHANNEL_COLLAPSED: (channel_id: number) => SettingsKey<boolean> = channel => {
return {
key: 'channel_collapsed_' + channel,
default_value: false
}
};
static readonly FN_PROFILE_RECORD: (name: string) => SettingsKey<any> = name => {
return {
key: 'profile_record' + name
@ -485,14 +493,15 @@ export class ServerSettings extends SettingsBase {
server?<T>(key: string | SettingsKey<T>, _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<T>(key: string | SettingsKey<T>, 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);

View file

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

39
shared/js/ui/TreeEntry.ts Normal file
View file

@ -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<Events extends ChannelTreeEntryEvents> {
readonly events: Registry<Events>;
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_; }
}

View file

@ -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<ChannelEvents> {
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<ChannelEvents>;
readonly view: React.RefObject<ChannelEntryView>;
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<HTMLElement>; /* container for the channel, client and children tag */
private _tag_siblings: JQuery<HTMLElement>; /* container for all sub channels */
private _tag_clients: JQuery<HTMLElement>; /* container for all clients */
private _tag_channel: JQuery<HTMLElement>; /* 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<ChannelEvents>();
this.view = React.createRef<ChannelEntryView>();
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<string> {
@ -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<HTMLElement> {
return this._tag_root;
}
channelTag() : JQuery<HTMLElement> {
return this._tag_channel;
}
siblingTag() : JQuery<HTMLElement> {
return this._tag_siblings;
}
clientTag() : JQuery<HTMLElement>{
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() ? "<b>" + text + "</b>" : 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(),

View file

@ -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<channel_tree.client>;
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<ClientEvents> {
readonly events: Registry<ClientEvents>;
readonly view: React.RefObject<ClientEntryView> = React.createRef<ClientEntryView>();
protected _clientId: number;
protected _channel: ChannelEntry;
protected _tag: JQuery<HTMLElement>;
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<channel_tree.client>();
super();
this.events = new Registry<ClientEvents>();
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<HTMLElement> {
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<boolean> {
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
});
});
}

View file

@ -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 (<ClientEntry>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 (<ClientEntry>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 = $(<HTMLTextAreaElement>elements[0]);
element.val((element.val() || "") + this.bbcode_text());
}
}
}
deactivate() {
this.callback = undefined;
this.finish_listener(undefined);
}
}

View file

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

View file

@ -27,7 +27,7 @@ export interface ButtonProperties {
}
export class Button extends ReactComponentBase<ButtonProperties, ButtonState> {
protected default_state(): ButtonState {
protected defaultState(): ButtonState {
return {
switched: false,
dropdownShowed: false,
@ -66,13 +66,13 @@ export class Button extends ReactComponentBase<ButtonProperties, ButtonState> {
}
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<ButtonProperties, ButtonState> {
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 });
}
}

View file

@ -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<HTMLDivElement>;
icon?: string | LocalIcon;
text: JSX.Element | string;
onClick?: (event) => void;
@ -12,7 +13,7 @@ export interface DropdownEntryProperties {
}
export class DropdownEntry extends ReactComponentBase<DropdownEntryProperties, {}> {
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<DropdownContainerProperties, DropdownContainerState> {
protected default_state() {
protected defaultState() {
return { };
}

View file

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

View file

@ -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<InternalControlBarEvents> }, 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<InternalControlBarEvents>("update_connect_state")
private handleStateUpdate(state: ConnectionState) {
this.updateState(state);
this.setState(state);
}
}
@ -96,7 +96,7 @@ class BookmarkButton extends ReactComponentBase<{ event_registry: Registry<Inter
this.button_ref = React.createRef();
}
protected default_state() {
protected defaultState() {
return {};
}
@ -118,7 +118,7 @@ class BookmarkButton extends ReactComponentBase<{ event_registry: Registry<Inter
private renderBookmark(bookmark: Bookmark) {
return (
<DropdownEntry key={bookmark.unique_id}
icon={IconManager.generate_tag(IconManager.load_cached_icon(bookmark.last_icon_id || 0), {animate: false})}
icon={icon_cache_loader.load_icon(bookmark.last_icon_id, bookmark.last_icon_server_id)}
text={bookmark.display_name}
onClick={BookmarkButton.onBookmarkClick.bind(undefined, bookmark.unique_id)}
onContextMenu={this.onBookmarkContextMenu.bind(this, bookmark.unique_id)}/>
@ -146,7 +146,7 @@ class BookmarkButton extends ReactComponentBase<{ event_registry: Registry<Inter
const bookmark = find_bookmark(bookmark_id) as Bookmark;
if(!bookmark) return;
this.button_ref.current?.updateState({ dropdownForceShow: true });
this.button_ref.current?.setState({ dropdownForceShow: true });
contextmenu.spawn_context_menu(event.pageX, event.pageY, {
type: contextmenu.MenuEntryType.ENTRY,
name: tr("Connect"),
@ -159,7 +159,7 @@ class BookmarkButton extends ReactComponentBase<{ event_registry: Registry<Inter
callback: () => 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<InternalControlBarEvents> }, AwayState> {
protected default_state(): AwayState {
protected defaultState(): AwayState {
return {
away: false,
awayAnywhere: false,
@ -226,7 +226,7 @@ class AwayButton extends ReactComponentBase<{ event_registry: Registry<InternalC
@EventHandler<InternalControlBarEvents>("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<InternalControlBarEvents> }, ChannelSubscribeState> {
protected default_state(): ChannelSubscribeState {
protected defaultState(): ChannelSubscribeState {
return { subscribeEnabled: false };
}
@ -247,7 +247,7 @@ class ChannelSubscribeButton extends ReactComponentBase<{ event_registry: Regist
@EventHandler<InternalControlBarEvents>("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<InternalControlBarEvents> }, MicrophoneState> {
protected default_state(): MicrophoneState {
protected defaultState(): MicrophoneState {
return {
enabled: false,
muted: false
@ -278,7 +278,7 @@ class MicrophoneButton extends ReactComponentBase<{ event_registry: Registry<Int
@EventHandler<InternalControlBarEvents>("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<InternalControlBarEvents> }, SpeakerState> {
protected default_state(): SpeakerState {
protected defaultState(): SpeakerState {
return {
muted: false
};
@ -304,7 +304,7 @@ class SpeakerButton extends ReactComponentBase<{ event_registry: Registry<Intern
@EventHandler<InternalControlBarEvents>("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<InternalControlBarEvents> }, QueryState> {
protected default_state() {
protected defaultState() {
return {
queryShown: false
};
@ -340,7 +340,7 @@ class QueryButton extends ReactComponentBase<{ event_registry: Registry<Internal
@EventHandler<InternalControlBarEvents>("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<InternalControlBarEvents> }, HostButtonState> {
protected default_state() {
protected defaultState() {
return {
url: undefined,
target_url: undefined
@ -382,7 +382,7 @@ class HostButton extends ReactComponentBase<{ event_registry: Registry<InternalC
@EventHandler<InternalControlBarEvents>("update_host_button")
private handleStateUpdate(state: HostButtonState) {
this.updateState(state);
this.setState(state);
}
}
@ -446,6 +446,7 @@ export class ControlBar extends React.Component<ControlBarProperties, {}> {
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<ControlBarProperties, {}> {
}
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() });
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<JQuery<HTMLDivElement>>;
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));

View file

@ -64,18 +64,18 @@ export function spawnPermissionEdit<T extends keyof OptionMap>(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<number>(resolve => {
spawnIconSelect(connection, id => resolve(new Int32Array([id])[0]), current_icon);
});

View file

@ -61,7 +61,7 @@ interface KeyActionEntryProperties {
@ReactEventHandler(e => e.props.eventRegistry)
class KeyActionEntry extends ReactComponentBase<KeyActionEntryProperties, KeyActionEntryState> {
protected default_state() : KeyActionEntryState {
protected defaultState() : KeyActionEntryState {
return {
assignedKey: undefined,
selected: false,
@ -133,7 +133,7 @@ class KeyActionEntry extends ReactComponentBase<KeyActionEntryProperties, KeyAct
@EventHandler<KeyMapEvents>("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<KeyActionEntryProperties, KeyAct
if(event.action !== this.props.action) return;
if(event.query_type !== "general") return;
this.updateState({
this.setState({
state: "loading"
});
}
@ -153,12 +153,12 @@ class KeyActionEntry extends ReactComponentBase<KeyActionEntryProperties, KeyAct
if(event.action !== this.props.action) return;
if(event.status === "success") {
this.updateState({
this.setState({
state: "loaded",
assignedKey: event.key
});
} else {
this.updateState({
this.setState({
state: "error",
error: event.status === "timeout" ? tr("query timeout") : event.error
});
@ -169,7 +169,7 @@ class KeyActionEntry extends ReactComponentBase<KeyActionEntryProperties, KeyAct
private handleSetKeymap(event: KeyMapEvents["set_keymap"]) {
if(event.action !== this.props.action) return;
this.updateState({ state: "applying" });
this.setState({ state: "applying" });
}
@EventHandler<KeyMapEvents>("set_keymap_result")
@ -177,12 +177,12 @@ class KeyActionEntry extends ReactComponentBase<KeyActionEntryProperties, KeyAct
if(event.action !== this.props.action) return;
if(event.status === "success") {
this.updateState({
this.setState({
state: "loaded",
assignedKey: event.key
});
} else {
this.updateState({ state: "loaded" });
this.setState({ state: "loaded" });
createErrorModal(tr("Failed to change key"), tra("Failed to change key for action \"{}\":{:br:}{}", this.props.action, event.status === "timeout" ? tr("timeout") : event.error));
}
}
@ -195,7 +195,7 @@ interface KeyActionGroupProperties {
}
class KeyActionGroup extends ReactComponentBase<KeyActionGroupProperties, { collapsed: boolean }> {
protected default_state(): { collapsed: boolean } {
protected defaultState(): { collapsed: boolean } {
return { collapsed: false }
}
@ -213,7 +213,7 @@ class KeyActionGroup extends ReactComponentBase<KeyActionGroupProperties, { coll
}
private toggleCollapsed() {
this.updateState({
this.setState({
collapsed: !this.state.collapsed
});
}
@ -224,7 +224,7 @@ interface KeyActionListProperties {
}
class KeyActionList extends ReactComponentBase<KeyActionListProperties, {}> {
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<KeyMapEvents> }, 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<KeyMapEven
@EventHandler<KeyMapEvents>("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<KeyMapEven
@EventHandler<KeyMapEvents>("query_keymap_result")
private handleQueryKeymapResult(event: KeyMapEvents["query_keymap_result"]) {
this.updateState({
this.setState({
loading: false,
has_key: event.status === "success" && !!event.key
});

View file

@ -16,7 +16,7 @@ export interface ButtonState {
}
export class Button extends ReactComponentBase<ButtonProperties, ButtonState> {
protected default_state(): ButtonState {
protected defaultState(): ButtonState {
return {
disabled: undefined
};

View file

@ -1,35 +1,63 @@
import * as React from "react";
import {LocalIcon} from "tc-shared/FileManager";
export interface IconProperties {
icon: string | JQuery<HTMLDivElement>;
icon: string | LocalIcon;
title?: string;
}
export class IconRenderer extends React.Component<IconProperties, {}> {
private readonly icon_ref: React.RefObject<HTMLDivElement>;
render() {
if(!this.props.icon)
return <div className={"icon-container icon-empty"} title={this.props.title} />;
else if(typeof this.props.icon === "string")
return <div className={"icon " + this.props.icon} title={this.props.title} />;
else if(this.props.icon instanceof LocalIcon)
return <LocalIconRenderer icon={this.props.icon} title={this.props.title} />;
else throw "JQuery icons are not longer supported";
}
}
export interface LoadedIconRenderer {
icon: LocalIcon;
title?: string;
}
export class LocalIconRenderer extends React.Component<LoadedIconRenderer, {}> {
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 <div className={"icon-container icon-empty"} />;
else if(typeof this.props.icon === "string")
return <div className={"icon " + this.props.icon} />;
return <div ref={this.icon_ref} />;
const icon = this.props.icon;
if(!icon || icon.status === "empty" || icon.status === "destroyed")
return <div className={"icon-container icon-empty"} title={this.props.title} />;
else if(icon.status === "loaded") {
if(icon.icon_id >= 0 && icon.icon_id <= 1000) {
if(icon.icon_id === 0)
return <div className={"icon-container icon-empty"} title={this.props.title} />;
return <div className={"icon_em client-group_" + icon.icon_id} />;
}
return <div key={"icon"} className={"icon-container"}><img src={icon.loaded_url} alt={this.props.title || ("icon " + icon.icon_id)} /></div>;
} else if(icon.status === "loading")
return <div key={"loading"} className={"icon-container"} title={this.props.title}><div className={"icon_loading"} /></div>;
else if(icon.status === "error")
return <div key={"error"} className={"icon client-warning"} title={icon.error_message || tr("Failed to load icon")} />;
}
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);
}
}

View file

@ -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<Properties, State> extends React.Component<Properties, State> {
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<K extends keyof State>(
state: ((prevState: Readonly<State>, props: Readonly<Properties>) => (Pick<State, K> | State | null)) | (Pick<State, K> | 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<Properties, State> 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);
}
}
});
}

View file

@ -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<ServerEvents> {
remote_address: ServerAddress;
channelTree: ChannelTree;
properties: ServerProperties;
readonly events: Registry<ServerEvents>;
readonly view: React.Ref<ServerEntryView>;
private info_request_promise: Promise<void> = 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<HTMLElement>;
private _destroyed = false;
constructor(tree, name, address: ServerAddress) {
super();
this.events = new Registry<ServerEvents>();
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;
}
}

View file

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

View file

@ -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<ChannelEntryIcons>(e => e.props.channel.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ChannelEntryIcons extends ReactComponentBase<ChannelEntryIconsProperties, ChannelEntryIconsState> {
private static readonly SimpleIcon = (props: { iconClass: string, title: string }) => {
return <div className={"icon " + props.iconClass} title={props.title} />
};
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(<ChannelEntryIcons.SimpleIcon key={"icon-default"} iconClass={"client-channel_default"} title={tr("Default channel")} />);
if(this.state.is_password_protected)
icons.push(<ChannelEntryIcons.SimpleIcon key={"icon-password"} iconClass={"client-register"} title={tr("The channel is password protected")} />); //TODO: "client-register" is really the right icon?
if(this.state.is_music_quality)
icons.push(<ChannelEntryIcons.SimpleIcon key={"icon-music"} iconClass={"client-music"} title={tr("Music quality")} />);
if(this.state.is_moderated)
icons.push(<ChannelEntryIcons.SimpleIcon key={"icon-moderated"} iconClass={"client-moderated"} title={tr("Channel is moderated")} />);
if(this.state.custom_icon_id)
icons.push(<LocalIconRenderer key={"icon-custom"} icon={this.props.channel.channelTree.client.fileManager.icons.load_icon(this.state.custom_icon_id)} title={tr("Client icon")} />);
if(!this.state.is_codec_supported) {
icons.push(<div key={"icon-unsupported"} className={channelStyle.icon_no_sound}>
<div className={"icon_entry icon client-conflict-icon"} title={tr("You don't support the channel codec")} />
<div className={channelStyle.background} />
</div>);
}
return <span className={channelStyle.icons}>
{icons}
</span>
}
@EventHandler<ChannelEvents>("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<ChannelEntryIcon>(e => e.props.channel.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ChannelEntryIcon extends ReactComponentBase<ChannelEntryIconProperties, {}> {
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 <div className={"icon client-channel_" + type + (this.props.channel.flag_subscribed ? "_subscribed" : "") + " " + channelStyle.channelType} />;
}
@EventHandler<ChannelEvents>("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<ChannelEvents>("notify_clients_changed")
private handleClientsUpdated() {
this.forceUpdate();
}
@EventHandler<ChannelEvents>("notify_cached_password_updated")
private handleCachedPasswordUpdate() {
this.forceUpdate();
}
@EventHandler<ChannelEvents>("notify_subscribe_state_changed")
private handleSubscribeModeChanges() {
this.forceUpdate();
}
}
interface ChannelEntryNameProperties {
channel: ChannelEntryController;
}
@ReactEventHandler<ChannelEntryName>(e => e.props.channel.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ChannelEntryName extends ReactComponentBase<ChannelEntryNameProperties, {}> {
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 <div className={this.classList(channelStyle.containerChannelName, channelStyle[class_name])}>
<a className={channelStyle.channelName}>{text}</a>
</div>;
}
@EventHandler<ChannelEvents>("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 <div className={channelStyle.containerArrow + (!props.collapsed ? " " + channelStyle.down : "")}><div className={"arrow " + (props.collapsed ? "right" : "down")} onClick={event => {
event.preventDefault();
props.onToggle();
}} /></div>
};
@ReactEventHandler<ChannelEntryView>(e => e.props.channel.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
export class ChannelEntryView extends TreeEntry<ChannelEntryViewProperties, {}> {
shouldComponentUpdate(nextProps: Readonly<ChannelEntryViewProperties>, 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 <div className={this.classList(viewStyle.treeEntry, channelStyle.channelEntry, this.props.channel.isSelected() && viewStyle.selected)}
style={{ paddingLeft: this.props.depth * 16 + 2, top: this.props.offset }}
onMouseUp={e => this.onMouseUp(e)}
onDoubleClick={() => this.onDoubleClick()}
onContextMenu={e => this.onContextMenu(e)}
>
<UnreadMarker entry={this.props.channel} />
{collapsed_indicator && <ChannelCollapsedIndicator key={"collapsed-indicator"} onToggle={() => this.onCollapsedToggle()} collapsed={this.props.channel.collapsed} />}
<ChannelEntryIcon channel={this.props.channel} />
<ChannelEntryName channel={this.props.channel} />
<ChannelEntryIcons channel={this.props.channel} />
</div>;
}
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<ChannelEvents>("notify_select_state_change")
private handleSelectStateChange() {
this.forceUpdate();
}
}

View file

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

View file

@ -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<ClientSpeakIcon>(e => e.props.client.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ClientSpeakIcon extends ReactComponentBase<ClientIconProperties, {}> {
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 <div className={"clicon " + clicon} />;
else if(icon.length > 0)
return <div className={"icon " + icon} />;
else
return null;
}
@EventHandler<ClientEvents>("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<ClientEvents>("notify_mute_state_change")
private handleMuteStateChange() {
this.forceUpdate();
}
@EventHandler<ClientEvents>("notify_speak_state_change")
private handleSpeakStateChange() {
this.forceUpdate();
}
}
interface ClientServerGroupIconsProperties {
client: ClientEntryController;
}
@ReactEventHandler<ClientServerGroupIcons>(e => e.props.client.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ClientServerGroupIcons extends ReactComponentBase<ClientServerGroupIconsProperties, {}> {
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 <LocalIconRenderer key={"group-icon-" + e.id} icon={this.props.client.channelTree.client.fileManager.icons.load_icon(e.properties.iconid)} />;
})
];
}
@EventHandler<ClientEvents>("notify_properties_updated")
private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) {
if(typeof event.updated_properties.client_servergroups)
this.forceUpdate();
}
}
interface ClientChannelGroupIconProperties {
client: ClientEntryController;
}
@ReactEventHandler<ClientChannelGroupIcon>(e => e.props.client.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ClientChannelGroupIcon extends ReactComponentBase<ClientChannelGroupIconProperties, {}> {
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 <LocalIconRenderer key={"cg-icon"} icon={this.props.client.channelTree.client.fileManager.icons.load_icon(channel_group.properties.iconid)} />;
}
@EventHandler<ClientEvents>("notify_properties_updated")
private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) {
if(typeof event.updated_properties.client_servergroups)
this.forceUpdate();
}
}
interface ClientIconsProperties {
client: ClientEntryController;
}
@ReactEventHandler<ClientIcons>(e => e.props.client.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ClientIcons extends ReactComponentBase<ClientIconsProperties, {}> {
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(<div key={"muted"} className={"icon icon_talk_power client-input_muted"} />);
icons.push(<ClientServerGroupIcons key={"sg-icons"} client={this.props.client} />);
icons.push(<ClientChannelGroupIcon key={"channel-icons"} client={this.props.client} />);
if(this.props.client.properties.client_icon_id !== 0)
icons.push(<LocalIconRenderer key={"client-icon"} icon={this.props.client.channelTree.client.fileManager.icons.load_icon(this.props.client.properties.client_icon_id)} />);
return (
<div className={clientStyle.containerIcons}>
{icons}
</div>
)
}
@EventHandler<ClientEvents>("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<ClientNameProperties, ClientNameState> {
protected defaultState(): ClientNameState {
return {
group_prefix: "",
away_message: "",
group_suffix: ""
}
}
render() {
return <div className={this.classList(clientStyle.clientName, this.props.client instanceof LocalClientEntry && clientStyle.clientNameOwn)}>
{this.state.group_prefix + this.props.client.clientNickName() + this.state.group_suffix + this.state.away_message}
</div>
}
@EventHandler<ClientEvents>("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<ClientNameEditProps, {}> {
private readonly ref_div: React.RefObject<HTMLDivElement> = React.createRef();
componentDidMount(): void {
this.ref_div.current.focus();
}
render() {
return <div
className={this.classList(clientStyle.clientName, clientStyle.edit)}
contentEditable={true}
ref={this.ref_div}
dangerouslySetInnerHTML={{__html: DOMPurify.sanitize(this.props.initialName)}}
onBlur={e => 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<ClientEntry>(e => e.props.client.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
export class ClientEntry extends TreeEntry<ClientEntryProperties, ClientEntryState> {
shouldComponentUpdate(nextProps: Readonly<ClientEntryProperties>, nextState: Readonly<ClientEntryState>, 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 (
<div className={this.classList(clientStyle.clientEntry, viewStyle.treeEntry, this.props.client.isSelected() && viewStyle.selected)}
style={{ paddingLeft: (this.props.depth * 16 + 2) + "px", top: this.props.offset }}
onDoubleClick={() => this.onDoubleClick()}
onMouseUp={e => this.onMouseUp(e)}
onContextMenu={e => this.onContextMenu(e)}
>
<UnreadMarker entry={this.props.client} />
<ClientSpeakIcon client={this.props.client} />
{this.state.rename ?
<ClientNameEdit key={"rename"} editFinished={name => this.onEditFinished(name)} initialName={this.state.renameInitialName || this.props.client.properties.client_nickname} /> :
[<ClientName key={"name"} client={this.props.client} />, <ClientIcons key={"icons"} client={this.props.client} />] }
</div>
)
}
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<ClientEvents>("notify_select_state_change")
private handleSelectChangeState() {
this.forceUpdate();
}
}

View file

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

View file

@ -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<ServerEntry>(e => e.props.server.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
export class ServerEntry extends TreeEntry<ServerEntryProperties, ServerEntryState> {
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<ServerEntryProperties>, nextState: Readonly<ServerEntryState>, 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 <div className={this.classList(serverStyle.serverEntry, viewStyle.treeEntry, this.props.server.isSelected() && viewStyle.selected )}
style={{ top: this.props.offset }}
onMouseUp={e => this.onMouseUp(e)}
onContextMenu={e => this.onContextMenu(e)}
>
<UnreadMarker entry={this.props.server} />
<div className={"icon client-server_green " + serverStyle.server_type} />
<div className={this.classList(serverStyle.name)}>{name}</div>
<LocalIconRenderer icon={this.props.server.channelTree.client.fileManager?.icons.load_icon(this.props.server.properties.virtualserver_icon_id)} />
</div>
}
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<ServerEvents>("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<ServerEvents>("notify_select_state_change")
private handleServerSelectStateChange() {
this.forceUpdate();
}
}

View file

@ -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<any>;
}
@ReactEventHandler<UnreadMarker>(e => e.props.entry.events)
export class UnreadMarker extends ReactComponentBase<UnreadMarkerProperties, {}> {
render() {
if(!this.props.entry.isUnread())
return null;
return <div className={viewStyle.markerUnread} />;
}
@EventHandler<ChannelTreeEntryEvents>("notify_unread_state_change")
private handleUnreadStateChange() {
this.forceUpdate();
}
}
export class TreeEntry<Props, State> extends ReactComponentBase<Props, State> { }

View file

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

View file

@ -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<TreeEntryMoveProps, TreeEntryMoveState> {
private readonly domContainer;
private readonly document_mouse_out_listener;
private readonly document_mouse_listener;
private readonly ref_container: React.RefObject<HTMLDivElement>;
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 <div style={{ top: this.current.y, left: this.current.x }} className={moveStyle.moveContainer} ref={this.ref_container} >
{this.state.description}
</div>;
}
}

122
shared/js/ui/tree/View.scss Normal file
View file

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

331
shared/js/ui/tree/View.tsx Normal file
View file

@ -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<ChannelTreeView>(e => e.props.tree.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewProperties, ChannelTreeViewState> {
private static readonly EntryHeight = 18;
private readonly ref_container = React.createRef<HTMLDivElement>();
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 (
<div
className={viewStyle.channelTreeContainer}
onScroll={() => this.onScroll()}
ref={this.ref_container}
onMouseDown={e => this.onMouseDown(e)}
onMouseMove={e => this.onMouseMove(e)} >
<div className={this.classList(viewStyle.channelTree, this.props.tree.isClientMoveActive() && viewStyle.move)} style={{height: (this.flat_tree.length * ChannelTreeView.EntryHeight) + "px"}}>
{elements}
</div>
</div>
)
}
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: <ChannelEntryView key={this.state.tree_version + "-channel-" + entry.channelId} channel={entry} offset={this.build_top_offset += ChannelTreeView.EntryHeight} depth={depth} ref={entry.view} />
});
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: <ClientEntryView key={this.state.tree_version + "-client-" + e.clientId()} client={e} offset={this.build_top_offset += ChannelTreeView.EntryHeight} depth={depth + 1} ref={e.view} />
};
}));
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: <ServerEntryView key={this.state.tree_version + "-server"} server={tree.server} offset={this.build_top_offset += ChannelTreeView.EntryHeight} ref={tree.server.view} />
}];
for (const channel of tree.rootChannel())
this.build_sub_tree(channel, 1);
}
@EventHandler<ChannelTreeEvents>("notify_root_channel_changed")
private handleRootChannelChanged() {
this.handleTreeUpdate();
}
@EventHandler<ChannelTreeEvents>("notify_query_view_state_changed")
private handleQueryViewStateChange() {
this.handleTreeUpdate();
}
@EventHandler<ChannelTreeEvents>("notify_entry_move_begin")
private handleEntryMoveBegin() {
this.handleTreeUpdate();
}
@EventHandler<ChannelTreeEvents>("notify_entry_move_end")
private handleEntryMoveEnd() {
this.handleTreeUpdate();
}
@EventHandler<ChannelTreeEvents>("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;
}
}

View file

@ -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<ChannelEntry>();
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:<br>"), 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:<br>"), 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:<br>"), 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<ChannelEntry>(resolve => { resolve(channel); }));
}
return new Promise<ChannelEntry>(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);
});
}
}

1122
shared/js/ui/view.tsx Normal file

File diff suppressed because it is too large Load diff

View file

@ -12,4 +12,6 @@ export function fix_declare_global(nodes: ts.Node[]) : ts.Node[] {
if(has_export) return nodes;
return [];
}
}
SyntaxKind.PlusEqualsToken

View file

@ -16,6 +16,7 @@
"webpack/ManifestPlugin.ts",
"webpack/EJSGenerator.ts",
"webpack/WatLoader.ts",
"webpack/DevelBlocks.ts",
"file.ts"
],

View file

@ -54,19 +54,22 @@ export class CodecWrapperWorker extends BasicCodec {
async initialise() : Promise<Boolean> {
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<AudioBuffer> {
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<Uint8Array> {
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++)

View file

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

View file

@ -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"

View file

@ -64,6 +64,9 @@ async function handle_message(command: string, data: any) : Promise<string | obj
return {};
case "encodeSamples":
if(!codec_instance)
return "codec not initialized/initialize failed";
let encodeArray = new Float32Array(data.length);
for(let index = 0; index < encodeArray.length; index++)
encodeArray[index] = data.data[index];
@ -74,6 +77,9 @@ async function handle_message(command: string, data: any) : Promise<string | obj
else
return { data: encodeResult, length: encodeResult.length };
case "decodeSamples":
if(!codec_instance)
return "codec not initialized/initialize failed";
let decodeArray = new Uint8Array(data.length);
for(let index = 0; index < decodeArray.length; index++)
decodeArray[index] = data.data[index];

View file

@ -111,7 +111,10 @@ export const config = async (target: "web" | "client") => { 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
}
}
]
},

27
webpack/DevelBlocks.ts Normal file
View file

@ -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\\((?<name>\\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);
}

View file

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