Reworked the file transfer system, added a channel file browser and fixed some minor bugs

canary
WolverinDEV 2020-06-10 18:13:56 +02:00
parent 00b0a40fd9
commit 804f8de18d
57 changed files with 6252 additions and 6046 deletions

View File

@ -1,4 +1,17 @@
# Changelog:
* **10.06.20**
- Finalize the channel file explorer
- Reworked the file transfer system
- Using an appropriate hash function for the avatar id generation
- Fixed icon over clipping for the channel tree and favorites
* **21.05.20**
- Updated the volume adjustment bar
* **18.05.20**
- Fixed client name change does not update the name in the channel tree
- Fixed hostbanner height
* **03.05.20**
- Splitup the file transfer & management part
- Added the ability to register a custom file transfer provider (required for the native client)

View File

@ -5,7 +5,8 @@ import * as template_loader from "./template_loader";
declare global {
interface Window {
tr(message: string) : string;
tra(message: string, ...args: any[]);
tra(message: string, ...args: (string | number | boolean)[]) : string;
tra(message: string, ...args: any[]) : JQuery[];
log: any;
StaticSettings: any;

64
package-lock.json generated
View File

@ -1641,6 +1641,11 @@
}
}
},
"can-use-dom": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/can-use-dom/-/can-use-dom-0.1.0.tgz",
"integrity": "sha1-IsxKNKCrxDlQ9CxkEQJKP2NmtFo="
},
"capture-stack-trace": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz",
@ -2094,6 +2099,11 @@
"is-plain-object": "^2.0.1"
}
},
"core-js": {
"version": "3.6.5",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz",
"integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA=="
},
"core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
@ -6696,12 +6706,27 @@
"integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=",
"dev": true
},
"lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
},
"lodash.has": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/lodash.has/-/lodash.has-4.5.2.tgz",
"integrity": "sha1-0Z9NwQlQWMzL4rDN9O4P5Ko3yGI=",
"dev": true
},
"lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
"integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4="
},
"lodash.throttle": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ="
},
"log-symbols": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz",
@ -9897,6 +9922,34 @@
"integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==",
"dev": true
},
"simple-html-tokenizer": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/simple-html-tokenizer/-/simple-html-tokenizer-0.1.1.tgz",
"integrity": "sha1-BcLuxXn//+FFoDCsJs/qYbmA+r4=",
"dev": true
},
"simplebar": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/simplebar/-/simplebar-5.2.0.tgz",
"integrity": "sha512-CpVSINCQ/XAYABUdUAnVWHyjkBYoFu+s12IUrZgVNfXzILNXP0MP+5OaIBjylzjYxIE/rsuC1K50/xJldPGGpQ==",
"requires": {
"can-use-dom": "^0.1.0",
"core-js": "^3.0.1",
"lodash.debounce": "^4.0.8",
"lodash.memoize": "^4.1.2",
"lodash.throttle": "^4.1.1",
"resize-observer-polyfill": "^1.5.1"
}
},
"simplebar-react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/simplebar-react/-/simplebar-react-2.2.0.tgz",
"integrity": "sha512-MOhBo2RabguVxUG4b8khO+zhOhktGUt3BjRsbTs8EpA0DzeCtAxq6RzdtDqGeBUj2c2+/CpF2vQmuvRCARln/A==",
"requires": {
"prop-types": "^15.6.1",
"simplebar": "^5.2.0"
}
},
"slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
@ -10376,6 +10429,17 @@
"es6-symbol": "^3.1.1"
}
},
"svg-inline-loader": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/svg-inline-loader/-/svg-inline-loader-0.8.2.tgz",
"integrity": "sha512-kbrcEh5n5JkypaSC152eGfGcnT4lkR0eSfvefaUJkLqgGjRQJyKDvvEE/CCv5aTSdfXuc+N98w16iAojhShI3g==",
"dev": true,
"requires": {
"loader-utils": "^1.1.0",
"object-assign": "^4.0.1",
"simple-html-tokenizer": "^0.1.1"
}
},
"tapable": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz",

View File

@ -60,16 +60,17 @@
"sass-loader": "^8.0.2",
"sha256": "^0.2.0",
"style-loader": "^1.1.3",
"svg-inline-loader": "^0.8.2",
"terser": "^4.2.1",
"terser-webpack-plugin": "latest",
"ts-loader": "^6.2.2",
"tsd": "latest",
"typescript": "^3.7.0",
"wabt": "^1.0.13",
"webpack": "^4.42.1",
"webpack-bundle-analyzer": "^3.6.1",
"webpack-cli": "^3.3.11",
"worker-plugin": "^4.0.2",
"tsd": "latest"
"worker-plugin": "^4.0.2"
},
"repository": {
"type": "git",
@ -85,6 +86,7 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"resize-observer-polyfill": "^1.5.1",
"simplebar-react": "^2.2.0",
"webrtc-adapter": "^7.5.1"
}
}

View File

@ -842,45 +842,6 @@ $tooltip_height: 1.8em;
.tooltip {
display: none;
/*
position: absolute;
top: -($tooltip_height + .6em);
left: -($tooltip_width - $thumb_width) / 2;
line-height: 1em;
height: $tooltip_height;
width: $tooltip_width;
background-color: #232222;
border-radius: $border_radius_middle;
text-align: center;
display: flex;
flex-direction: column;
justify-content: space-around;
opacity: 0;
@include transition(opacity .5s ease-in-out);
&:before {
content: '';
position: absolute;
left: ($tooltip_width - $thumb_width) / 2 - .25em;
right: 0;
bottom: -.4em;
width: 0;
height: 0;
border-style: solid;
border-width: .5em .5em 0 .5em;
border-color: #232222 transparent transparent transparent;
}
*/
}
}

View File

@ -0,0 +1,21 @@
<svg version="1.1" fill="#7289da" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="438.533px" height="438.533px" viewBox="0 0 438.533 438.533" style="enable-background:new 0 0 438.533 438.533;"
xml:space="preserve">
<g>
<g>
<path d="M396.283,130.188c-3.806-9.135-8.371-16.365-13.703-21.695l-89.078-89.081c-5.332-5.325-12.56-9.895-21.697-13.704
C262.672,1.903,254.297,0,246.687,0H63.953C56.341,0,49.869,2.663,44.54,7.993c-5.33,5.327-7.994,11.799-7.994,19.414v383.719
c0,7.617,2.664,14.089,7.994,19.417c5.33,5.325,11.801,7.991,19.414,7.991h310.633c7.611,0,14.079-2.666,19.407-7.991
c5.328-5.332,7.994-11.8,7.994-19.417V155.313C401.991,147.699,400.088,139.323,396.283,130.188z M255.816,38.826
c5.517,1.903,9.418,3.999,11.704,6.28l89.366,89.366c2.279,2.286,4.374,6.186,6.276,11.706H255.816V38.826z M365.449,401.991
H73.089V36.545h146.178v118.771c0,7.614,2.662,14.084,7.992,19.414c5.332,5.327,11.8,7.994,19.417,7.994h118.773V401.991z"/>
<path d="M319.77,292.355h-201c-2.663,0-4.853,0.855-6.567,2.566c-1.709,1.711-2.568,3.901-2.568,6.563v18.274
c0,2.67,0.856,4.859,2.568,6.57c1.715,1.711,3.905,2.567,6.567,2.567h201c2.663,0,4.854-0.856,6.564-2.567s2.566-3.9,2.566-6.57
v-18.274c0-2.662-0.855-4.853-2.566-6.563C324.619,293.214,322.429,292.355,319.77,292.355z"/>
<path d="M112.202,221.831c-1.709,1.712-2.568,3.901-2.568,6.571v18.271c0,2.666,0.856,4.856,2.568,6.567
c1.715,1.711,3.905,2.566,6.567,2.566h201c2.663,0,4.854-0.855,6.564-2.566s2.566-3.901,2.566-6.567v-18.271
c0-2.663-0.855-4.854-2.566-6.571c-1.715-1.709-3.905-2.564-6.564-2.564h-201C116.107,219.267,113.917,220.122,112.202,221.831z"
/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,7 @@
<svg version="1.1" fill="#7289da" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="16px" height="16px" viewBox="0 0 16 16" style="enable-background:new 0 0 16 16;"
xml:space="preserve">
<g>
<path fill="#7289da" d="M13.801 3.864h-5.693v-0.752c0-0.593-0.481-1.074-1.074-1.074h-4.834c-0.593 0-1.074 0.481-1.074 1.074v9.775c0 0.593 0.481 1.074 1.074 1.074h11.602c0.593 0 1.074-0.481 1.074-1.074v-7.949c-0-0.593-0.481-1.074-1.074-1.074z"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 518 B

View File

@ -0,0 +1,5 @@
<svg fill="#7289da" style="enable-background:new 0 0 16 16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="m13.801 3.864h-5.693v-.752c0-.593-.481-1.074-1.074-1.074h-4.834c-.593 0-1.074.481-1.074 1.074v9.775c0 .593.481 1.074 1.074 1.074h11.602c.593 0 1.074-.481 1.074-1.074v-7.949c-0-.593-.481-1.074-1.074-1.074z" fill="#7289da"/>
<path d="m6.751 7.5653c0-.352.123-.651.368-.896s.54-.369.885-.369.638.123.882.369c.243.246.364.545.364.896v.952h.832v-.952c0-.383-.093-.735-.28-1.058s-.439-.579-.757-.768c-.319-.189-.667-.284-1.044-.284s-.725.095-1.044.284-.571.445-.758.768-.28.676-.28 1.058v.952h.833v-.952z" fill="#f2f2f2"/>
<path d="m5.409 8.4193h5.185c.076.017.143.054.2.112.08.081.12.181.12.3v2.953c0 .118-.04.219-.12.3s-.178.122-.296.122h-4.997c-.113 0-.21-.04-.293-.122s-.124-.182-.124-.3v-2.953c0-.119.041-.218.124-.3.059-.058.125-.095.2-.112zm3.047 1.149c0-.169-.137-.307-.307-.307h-.298c-.169 0-.307.138-.307.307v1.586c0 .169.138.307.307.307h.298c.169 0 .307-.137.307-.307z" fill="#f2f2f2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -6,6 +6,7 @@ import {ServerSettings, Settings, settings, StaticSettings} from "tc-shared/sett
import {Sound, SoundManager} from "tc-shared/sound/Sounds";
import {LocalClientEntry} from "tc-shared/ui/client";
import * as server_log from "tc-shared/ui/frames/server_log";
import {ServerLog} from "tc-shared/ui/frames/server_log";
import {ConnectionProfile, default_profile, find_profile} from "tc-shared/profiles/ConnectionProfile";
import {ServerAddress} from "tc-shared/ui/server";
import * as log from "tc-shared/log";
@ -16,10 +17,8 @@ import {HandshakeHandler} from "tc-shared/connection/HandshakeHandler";
import * as htmltags from "./ui/htmltags";
import {ChannelEntry} from "tc-shared/ui/channel";
import {InputStartResult, InputState} from "tc-shared/voice/RecorderBase";
import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
import {guid} from "tc-shared/crypto/uid";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import * as bipc from "./BrowserIPC";
import {FileManager, transfer_provider, UploadKey} from "tc-shared/file/FileManager";
import {RecorderProfile} from "tc-shared/voice/RecorderProfile";
import {Frame} from "tc-shared/ui/frames/chat_frame";
import {Hostbanner} from "tc-shared/ui/frames/hostbanner";
@ -31,7 +30,11 @@ import * as connection from "tc-backend/connection";
import * as dns from "tc-backend/dns";
import * as top_menu from "tc-shared/ui/frames/MenuBar";
import {EventHandler, Registry} from "tc-shared/events";
import {ServerLog} from "tc-shared/ui/frames/server_log";
import {FileManager} from "tc-shared/file/FileManager";
import {FileTransferState, TransferProvider} from "tc-shared/file/Transfer";
import {guid} from "tc-shared/crypto/uid";
import {traj} from "tc-shared/i18n/localize";
import {md5} from "tc-shared/crypto/md5";
export enum DisconnectReason {
HANDLER_DESTROYED,
@ -871,51 +874,35 @@ export class ConnectionHandler {
} else {
log.info(LogCategory.CLIENT, tr("Uploading new avatar"));
(async () => {
let key: UploadKey;
try {
key = await this.fileManager.upload_file({
size: data.byteLength,
path: '',
name: '/avatar',
overwrite: true,
channel: undefined,
channel_password: undefined
});
} catch(error) {
log.error(LogCategory.GENERAL, tr("Failed to initialize avatar upload: %o"), error);
let message;
if(error instanceof CommandResult) {
//TODO: Resolve permission name
//i_client_max_avatar_filesize
if(error.id == ErrorID.PERMISSION_ERROR) {
message = formatMessage(tr("Failed to initialize avatar upload.{:br:}Missing permission {0}"), error["failed_permid"]);
} else {
message = formatMessage(tr("Failed to initialize avatar upload.{:br:}Error: {0}"), error.extra_message || error.message);
}
const transfer = this.fileManager.initializeFileUpload({
name: "/avatar",
path: "",
channel: 0,
channelPassword: undefined,
source: async () => await TransferProvider.provider().createBufferSource(data)
});
await transfer.awaitFinished();
if(transfer.transferState() !== FileTransferState.FINISHED) {
if(transfer.transferState() === FileTransferState.ERRORED) {
log.warn(LogCategory.FILE_TRANSFER, tr("Failed to upload clients avatar: %o"), transfer.currentError());
createErrorModal(tr("Failed to upload avatar"), traj("Failed to upload avatar:{:br:}{0}", transfer.currentErrorMessage())).open();
return;
} else if(transfer.transferState() === FileTransferState.CANCELED) {
createErrorModal(tr("Failed to upload avatar"), tr("Your avatar upload has been canceled.")).open();
return;
} else {
createErrorModal(tr("Failed to upload avatar"), tr("Avatar upload finished with an unknown finished state.")).open();
return;
}
if(!message)
message = formatMessage(tr("Failed to initialize avatar upload.{:br:}Lookup the console for more details"));
createErrorModal(tr("Failed to upload avatar"), message).open();
return;
}
try {
await transfer_provider().spawn_upload_transfer(key).put_data(data);
} catch(error) {
log.error(LogCategory.GENERAL, tr("Failed to upload avatar: %o"), error);
let message;
if(typeof(error) === "string")
message = formatMessage(tr("Failed to upload avatar.{:br:}Error: {0}"), error);
if(!message)
message = formatMessage(tr("Failed to initialize avatar upload.{:br:}Lookup the console for more details"));
createErrorModal(tr("Failed to upload avatar"), message).open();
return;
}
try {
await this.serverConnection.send_command('clientupdate', {
client_flag_avatar: guid()
client_flag_avatar: md5(new Uint8Array(data))
});
} catch(error) {
log.error(LogCategory.GENERAL, tr("Failed to update avatar flag: %o"), error);

View File

@ -161,9 +161,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
}
handleCommandResult(json) {
json = json[0]; //Only one bulk
let code : string = json["return_code"];
let code : string = json[0]["return_code"];
if(!code || code.length == 0) {
log.warn(LogCategory.NETWORKING, tr("Invalid return code! (%o)"), json);
return;
@ -512,7 +510,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
attach: true
});
if(conversation)
client.flag_text_unread = conversation.is_unread();
client.setUnread(conversation.is_unread());
}
if(client instanceof LocalClientEntry) {

View File

@ -18,6 +18,38 @@ export enum ErrorID {
CONVERSATION_IS_PRIVATE = 0x2202
}
export enum ErrorCode {
FILE_INVALID_NAME = 0X800,
FILE_INVALID_PERMISSIONS = 0X801,
FILE_ALREADY_EXISTS = 0X802,
FILE_NOT_FOUND = 0X803,
FILE_IO_ERROR = 0X804,
FILE_INVALID_TRANSFER_ID = 0X805,
FILE_INVALID_PATH = 0X806,
FILE_NO_FILES_AVAILABLE = 0X807,
FILE_OVERWRITE_EXCLUDES_RESUME = 0X808,
FILE_INVALID_SIZE = 0X809,
FILE_ALREADY_IN_USE = 0X80A,
FILE_COULD_NOT_OPEN_CONNECTION = 0X80B,
FILE_NO_SPACE_LEFT_ON_DEVICE = 0X80C,
FILE_EXCEEDS_FILE_SYSTEM_MAXIMUM_SIZE = 0X80D,
FILE_TRANSFER_CONNECTION_TIMEOUT = 0X80E,
FILE_CONNECTION_LOST = 0X80F,
FILE_EXCEEDS_SUPPLIED_SIZE = 0X810,
FILE_TRANSFER_COMPLETE = 0X811,
FILE_TRANSFER_CANCELED = 0X812,
FILE_TRANSFER_INTERRUPTED = 0X813,
FILE_TRANSFER_SERVER_QUOTA_EXCEEDED = 0X814,
FILE_TRANSFER_CLIENT_QUOTA_EXCEEDED = 0X815,
FILE_TRANSFER_RESET = 0X816,
FILE_TRANSFER_LIMIT_REACHED = 0X817,
FILE_API_TIMEOUT = 0X820,
FILE_VIRTUAL_SERVER_NOT_REGISTERED = 0X821,
FILE_SERVER_TRANSFER_LIMIT_REACHED = 0X822,
FILE_CLIENT_TRANSFER_LIMIT_REACHED = 0X823,
}
export class CommandResult {
success: boolean;
id: number;
@ -26,16 +58,26 @@ export class CommandResult {
json: any;
constructor(json) {
this.json = json;
this.id = parseInt(json["id"]);
this.message = json["msg"];
bulks: any[];
this.extra_message = "";
if(json["extra_msg"]) this.extra_message = json["extra_msg"];
constructor(bulks) {
this.bulks = bulks;
this.json = bulks[0];
this.id = parseInt(this.json["id"]);
this.message = this.json["msg"];
this.extra_message = this.json["extra_msg"] || "";
this.success = this.id == 0;
}
getBulks() : CommandResult[] {
return this.bulks.map(e => new CommandResult([e]));
}
formattedMessage() {
return this.extra_message ? this.message + " (" + this.extra_message + ")" : this.message;
}
}
export interface ClientNameInfo {

172
shared/js/crypto/md5.ts Normal file
View File

@ -0,0 +1,172 @@
export function md5(uint8Array: Uint8Array) : string {
function md5cycle(x, k) {
var a = x[0], b = x[1], c = x[2], d = x[3];
a = ff(a, b, c, d, k[0], 7, -680876936);
d = ff(d, a, b, c, k[1], 12, -389564586);
c = ff(c, d, a, b, k[2], 17, 606105819);
b = ff(b, c, d, a, k[3], 22, -1044525330);
a = ff(a, b, c, d, k[4], 7, -176418897);
d = ff(d, a, b, c, k[5], 12, 1200080426);
c = ff(c, d, a, b, k[6], 17, -1473231341);
b = ff(b, c, d, a, k[7], 22, -45705983);
a = ff(a, b, c, d, k[8], 7, 1770035416);
d = ff(d, a, b, c, k[9], 12, -1958414417);
c = ff(c, d, a, b, k[10], 17, -42063);
b = ff(b, c, d, a, k[11], 22, -1990404162);
a = ff(a, b, c, d, k[12], 7, 1804603682);
d = ff(d, a, b, c, k[13], 12, -40341101);
c = ff(c, d, a, b, k[14], 17, -1502002290);
b = ff(b, c, d, a, k[15], 22, 1236535329);
a = gg(a, b, c, d, k[1], 5, -165796510);
d = gg(d, a, b, c, k[6], 9, -1069501632);
c = gg(c, d, a, b, k[11], 14, 643717713);
b = gg(b, c, d, a, k[0], 20, -373897302);
a = gg(a, b, c, d, k[5], 5, -701558691);
d = gg(d, a, b, c, k[10], 9, 38016083);
c = gg(c, d, a, b, k[15], 14, -660478335);
b = gg(b, c, d, a, k[4], 20, -405537848);
a = gg(a, b, c, d, k[9], 5, 568446438);
d = gg(d, a, b, c, k[14], 9, -1019803690);
c = gg(c, d, a, b, k[3], 14, -187363961);
b = gg(b, c, d, a, k[8], 20, 1163531501);
a = gg(a, b, c, d, k[13], 5, -1444681467);
d = gg(d, a, b, c, k[2], 9, -51403784);
c = gg(c, d, a, b, k[7], 14, 1735328473);
b = gg(b, c, d, a, k[12], 20, -1926607734);
a = hh(a, b, c, d, k[5], 4, -378558);
d = hh(d, a, b, c, k[8], 11, -2022574463);
c = hh(c, d, a, b, k[11], 16, 1839030562);
b = hh(b, c, d, a, k[14], 23, -35309556);
a = hh(a, b, c, d, k[1], 4, -1530992060);
d = hh(d, a, b, c, k[4], 11, 1272893353);
c = hh(c, d, a, b, k[7], 16, -155497632);
b = hh(b, c, d, a, k[10], 23, -1094730640);
a = hh(a, b, c, d, k[13], 4, 681279174);
d = hh(d, a, b, c, k[0], 11, -358537222);
c = hh(c, d, a, b, k[3], 16, -722521979);
b = hh(b, c, d, a, k[6], 23, 76029189);
a = hh(a, b, c, d, k[9], 4, -640364487);
d = hh(d, a, b, c, k[12], 11, -421815835);
c = hh(c, d, a, b, k[15], 16, 530742520);
b = hh(b, c, d, a, k[2], 23, -995338651);
a = ii(a, b, c, d, k[0], 6, -198630844);
d = ii(d, a, b, c, k[7], 10, 1126891415);
c = ii(c, d, a, b, k[14], 15, -1416354905);
b = ii(b, c, d, a, k[5], 21, -57434055);
a = ii(a, b, c, d, k[12], 6, 1700485571);
d = ii(d, a, b, c, k[3], 10, -1894986606);
c = ii(c, d, a, b, k[10], 15, -1051523);
b = ii(b, c, d, a, k[1], 21, -2054922799);
a = ii(a, b, c, d, k[8], 6, 1873313359);
d = ii(d, a, b, c, k[15], 10, -30611744);
c = ii(c, d, a, b, k[6], 15, -1560198380);
b = ii(b, c, d, a, k[13], 21, 1309151649);
a = ii(a, b, c, d, k[4], 6, -145523070);
d = ii(d, a, b, c, k[11], 10, -1120210379);
c = ii(c, d, a, b, k[2], 15, 718787259);
b = ii(b, c, d, a, k[9], 21, -343485551);
x[0] = add32(a, x[0]);
x[1] = add32(b, x[1]);
x[2] = add32(c, x[2]);
x[3] = add32(d, x[3]);
}
function cmn(q, a, b, x, s, t) {
a = add32(add32(a, q), add32(x, t));
return add32((a << s) | (a >>> (32 - s)), b);
}
function ff(a, b, c, d, x, s, t) {
return cmn((b & c) | ((~b) & d), a, b, x, s, t);
}
function gg(a, b, c, d, x, s, t) {
return cmn((b & d) | (c & (~d)), a, b, x, s, t);
}
function hh(a, b, c, d, x, s, t) {
return cmn(b ^ c ^ d, a, b, x, s, t);
}
function ii(a, b, c, d, x, s, t) {
return cmn(c ^ (b | (~d)), a, b, x, s, t);
}
function md51(s) {
var n = s.length,
state = [1732584193, -271733879, -1732584194, 271733878], i;
for (i = 64; i <= s.length; i += 64) {
md5cycle(state, md5blk(s.subarray(i - 64, i)));
}
s = s.subarray(i - 64);
var tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
for (i = 0; i < s.length; i++)
tail[i >> 2] |= s[i] << ((i % 4) << 3);
tail[i >> 2] |= 0x80 << ((i % 4) << 3);
if (i > 55) {
md5cycle(state, tail);
for (i = 0; i < 16; i++) tail[i] = 0;
}
tail[14] = n * 8;
md5cycle(state, tail);
return state;
}
/* there needs to be support for Unicode here,
* unless we pretend that we can redefine the MD-5
* algorithm for multi-byte characters (perhaps
* by adding every four 16-bit characters and
* shortening the sum to 32 bits). Otherwise
* I suggest performing MD-5 as if every character
* was two bytes--e.g., 0040 0025 = @%--but then
* how will an ordinary MD-5 sum be matched?
* There is no way to standardize text to something
* like UTF-8 before transformation; speed cost is
* utterly prohibitive. The JavaScript standard
* itself needs to look at this: it should start
* providing access to strings as preformed UTF-8
* 8-bit unsigned value arrays.
*/
function md5blk(s) { /* I figured global was faster. */
var md5blks = [], i; /* Andy King said do it this way. */
for (i = 0; i < 64; i += 4) {
md5blks[i >> 2] = s[i]
+ (s[i + 1] << 8)
+ (s[i + 2] << 16)
+ (s[i + 3] << 24);
}
return md5blks;
}
var hex_chr = '0123456789abcdef'.split('');
function rhex(n) {
var s = '', j = 0;
for (; j < 4; j++)
s += hex_chr[(n >> (j * 8 + 4)) & 0x0F]
+ hex_chr[(n >> (j * 8)) & 0x0F];
return s;
}
function hex(x) {
for (var i = 0; i < x.length; i++)
x[i] = rhex(x[i]);
return x.join('');
}
function md5(s) {
return hex(md51(s));
}
function add32(a, b) {
return (a + b) & 0xFFFFFFFF;
}
return md5(uint8Array);
}

View File

@ -1,6 +1,7 @@
import {ClientEvents, MusicClientEntry, SongInfo} from "tc-shared/ui/client";
import {guid} from "tc-shared/crypto/uid";
import * as React from "react";
import {useEffect} from "react";
export interface Event<Events, T = keyof Events> {
readonly type: T;
@ -91,6 +92,20 @@ export class Registry<Events> {
}
}
/* special helper methods for react components */
reactUse<T extends keyof Events>(event: T, handler: (event?: Events[T] & Event<Events, T>) => void, condition?: boolean) {
if(typeof condition === "boolean" && !condition) {
useEffect(() => {});
return;
}
const handlers = this.handler[event as any] || (this.handler[event as any] = []);
useEffect(() => {
handlers.push(handler);
return () => handlers.remove(handler);
});
}
connect<EOther, T extends keyof Events & keyof EOther>(events: T | T[], target: Registry<EOther>) {
for(const event of Array.isArray(events) ? events : [events])
(this.connections[event as string] || (this.connections[event as string] = [])).push(target as any);
@ -178,8 +193,10 @@ export class Registry<Events> {
this.on(event, ev_handler);
}
}
if(Object.keys(registered_events).length === 0)
throw "no events found in event handler";
if(Object.keys(registered_events).length === 0) {
console.warn(tr("no events found in event handler"));
return;
}
this.event_handler_objects.push({
handlers: registered_events,
@ -189,7 +206,8 @@ export class Registry<Events> {
unregister_handler(handler: any) {
const data = this.event_handler_objects.find(e => e.object === handler);
if(!data) throw "unknown event handler";
if(!data) return;
this.event_handler_objects.remove(data);
for(const key of Object.keys(data.handlers)) {

View File

@ -2,11 +2,18 @@ import * as log from "tc-shared/log";
import {LogCategory} from "tc-shared/log";
import {ClientEntry} from "tc-shared/ui/client";
import * as hex from "tc-shared/crypto/hex";
import {
DownloadKey,
FileManager, transfer_provider
} from "tc-shared/file/FileManager";
import {image_type, ImageCache, ImageType, media_image_type} from "tc-shared/file/ImageCache";
import {FileManager} from "tc-shared/file/FileManager";
import {
FileDownloadTransfer,
FileTransferState,
ResponseTransferTarget, TransferProvider,
TransferTargetType
} from "tc-shared/file/Transfer";
import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
import {tra} from "tc-shared/i18n/localize";
import {server_connections} from "tc-shared/ui/frames/connection_handlers";
import {icon_cache_loader} from "tc-shared/file/Icons";
export class Avatar {
client_avatar_id: string; /* the base64 uid thing from a-m */
@ -47,11 +54,11 @@ export class AvatarManager {
}
async resolved_cached?(client_avatar_id: string, avatar_version?: string) : Promise<Avatar> {
let avatar: Avatar = this._cached_avatars[avatar_version];
if(avatar) {
if(typeof(avatar_version) !== "string" || avatar.avatar_id == avatar_version)
return avatar;
avatar = undefined;
let cachedAvatar: Avatar = this._cached_avatars[avatar_version];
if(cachedAvatar) {
if(typeof(avatar_version) !== "string" || cachedAvatar.avatar_id == avatar_version)
return cachedAvatar;
delete this._cached_avatars[avatar_version];
}
if(!AvatarManager.cache.setupped())
@ -74,37 +81,66 @@ export class AvatarManager {
};
}
create_avatar_download(client_avatar_id: string) : Promise<DownloadKey> {
create_avatar_download(client_avatar_id: string) : FileDownloadTransfer {
log.debug(LogCategory.GENERAL, "Requesting download for avatar %s", client_avatar_id);
return this.handle.download_file("", "/avatar_" + client_avatar_id);
return this.handle.initializeFileDownload({
path: "",
name: "/avatar_" + client_avatar_id,
targetSupplier: async () => await TransferProvider.provider().createResponseTarget()
});
}
private async _load_avatar(client_avatar_id: string, avatar_version: string) {
try {
let download_key: DownloadKey;
let transfer = this.create_avatar_download(client_avatar_id);
try {
download_key = await this.create_avatar_download(client_avatar_id);
await transfer.awaitFinished();
if(transfer.transferState() === FileTransferState.CANCELED) {
throw tr("download canceled");
} else if(transfer.transferState() === FileTransferState.ERRORED) {
throw transfer.currentError();
} else if(transfer.transferState() === FileTransferState.FINISHED) {
} else {
throw tr("Unknown transfer finished state");
}
} catch(error) {
log.error(LogCategory.GENERAL, tr("Could not request download for avatar %s: %o"), client_avatar_id, error);
throw "failed to request avatar download";
if(typeof error === "object" && 'error' in error && error.error === "initialize") {
const commandResult = error.commandResult;
if(commandResult instanceof CommandResult) {
if(commandResult.id === ErrorID.FILE_NOT_FOUND)
throw tr("Avatar could not be found");
else if(commandResult.id === ErrorID.PERMISSION_ERROR)
throw tr("No permissions to download avatar");
else
throw commandResult.message + (commandResult.extra_message ? " (" + commandResult.extra_message + ")" : "");
}
}
log.error(LogCategory.CLIENT, tr("Could not request download for avatar %s: %o"), client_avatar_id, error);
if(error === transfer.currentError())
throw transfer.currentErrorMessage();
throw typeof error === "string" ? error : tr("Avatar download failed");
}
const downloader = transfer_provider().spawn_download_transfer(download_key);
let response: Response;
try {
response = await downloader.request_file();
} catch(error) {
log.error(LogCategory.GENERAL, tr("Could not download avatar %s: %o"), client_avatar_id, error);
throw "failed to download avatar";
}
/* could only be tested here, because before we don't know which target we have */
if(transfer.target.type !== TransferTargetType.RESPONSE)
throw "unsupported transfer target";
const type = image_type(response.headers.get('X-media-bytes'));
const response = transfer.target as ResponseTransferTarget;
if(!response.hasResponse())
throw tr("Transfer has no response");
const type = image_type(response.getResponse().headers.get('X-media-bytes'));
const media = media_image_type(type);
await AvatarManager.cache.put_cache('avatar_' + client_avatar_id, response.clone(), "image/" + media, {
await AvatarManager.cache.put_cache('avatar_' + client_avatar_id, response.getResponse().clone(), "image/" + media, {
"X-avatar-version": avatar_version
});
const url = await this._response_url(response.clone(), type);
const url = await this._response_url(response.getResponse().clone(), type);
return this._cached_avatars[client_avatar_id] = {
client_avatar_id: client_avatar_id,
@ -249,9 +285,9 @@ export class AvatarManager {
generate_chat_tag(client: { id?: number; database_id?: number; }, client_unique_id: string, callback_loaded?: (successfully: boolean, error?: any) => any) : JQuery {
let client_handle;
if(typeof(client.id) == "number")
client_handle = this.handle.handle.channelTree.findClient(client.id);
client_handle = this.handle.connectionHandler.channelTree.findClient(client.id);
if(!client_handle && typeof(client.id) == "number") {
client_handle = this.handle.handle.channelTree.find_client_by_dbid(client.database_id);
client_handle = this.handle.connectionHandler.channelTree.find_client_by_dbid(client.database_id);
}
if(client_handle && client_handle.clientUid() !== client_unique_id)
@ -314,4 +350,14 @@ export class AvatarManager {
return container;
}
}
flush_cache() {
this._cached_avatars = undefined;
this._loading_promises = undefined;
}
}
(window as any).flush_avatar_cache = async () => {
server_connections.all_connections().forEach(e => {
e.fileManager.avatars.flush_cache();
});
};

File diff suppressed because it is too large Load Diff

View File

@ -3,12 +3,14 @@ import {LogCategory} from "tc-shared/log";
import {Registry} from "tc-shared/events";
import {format_time} from "tc-shared/ui/frames/chat";
import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
import {
DownloadKey,
FileEntry,
FileManager, transfer_provider
} from "tc-shared/file/FileManager";
import {image_type, ImageCache, ImageType, media_image_type} from "tc-shared/file/ImageCache";
import {FileInfo, FileManager} from "tc-shared/file/FileManager";
import {
FileDownloadTransfer,
FileTransferState, ResponseTransferTarget, TransferProvider,
TransferTargetType
} from "tc-shared/file/Transfer";
import {server_connections} from "tc-shared/ui/frames/connection_handlers";
const icon_cache: ImageCache = new ImageCache("icons");
export interface IconManagerEvents {
@ -215,6 +217,15 @@ window.addEventListener("beforeunload", () => {
icon_cache_loader.clear_memory_cache();
});
(window as any).flush_icon_cache = async () => {
icon_cache_loader.clear_memory_cache();
await icon_cache_loader.clear_cache();
server_connections.all_connections().forEach(e => {
e.fileManager.icons.flush_cache();
});
};
type IconManagerLoadingData = {
result: "success" | "error" | "unset";
next_retry?: number;
@ -238,17 +249,21 @@ export class IconManager {
if(id <= 1000)
throw "invalid id!";
await this.handle.delete_file({
await this.handle.deleteFile({
name: '/icon_' + id
});
}
iconList() : Promise<FileEntry[]> {
iconList() : Promise<FileInfo[]> {
return this.handle.requestFileList("/icons");
}
create_icon_download(id: number) : Promise<DownloadKey> {
return this.handle.download_file("", "/icon_" + id);
createIconDownload(id: number) : FileDownloadTransfer {
return this.handle.initializeFileDownload({
path: "",
name: "/icon_" + id,
targetSupplier: async () => await TransferProvider.provider().createResponseTarget()
});
}
private async server_icon_loader(icon: LocalIcon) : Promise<Response> {
@ -262,9 +277,20 @@ export class IconManager {
}
try {
let download_key: DownloadKey;
let transfer = this.createIconDownload(icon.icon_id);
try {
download_key = await this.create_icon_download(icon.icon_id);
await transfer.awaitFinished();
if(transfer.transferState() === FileTransferState.CANCELED) {
throw tr("download canceled");
} else if(transfer.transferState() === FileTransferState.ERRORED) {
throw transfer.currentError();
} else if(transfer.transferState() === FileTransferState.FINISHED) {
} else {
throw tr("Unknown transfer finished state");
}
} catch(error) {
if(error instanceof CommandResult) {
if(error.id === ErrorID.FILE_NOT_FOUND)
@ -275,20 +301,21 @@ export class IconManager {
throw error.extra_message || error.message;
}
log.error(LogCategory.CLIENT, tr("Could not request download for icon %d: %o"), icon.icon_id, error);
if(error === transfer.currentError())
throw transfer.currentErrorMessage();
throw typeof error === "string" ? error : tr("Failed to initialize icon download");
}
const downloader = transfer_provider().spawn_download_transfer(download_key);
let response: Response;
try {
response = await downloader.request_file();
} catch(error) {
log.error(LogCategory.CLIENT, tr("Could not download icon %d: %o"), icon.icon_id, error);
throw "failed to download icon";
}
/* could only be tested here, because before we don't know which target we have */
if(transfer.target.type !== TransferTargetType.RESPONSE)
throw "unsupported transfer target";
const response = transfer.target as ResponseTransferTarget;
if(!response.hasResponse())
throw tr("Transfer has no response");
loading_data.result = "success";
return response;
return response.getResponse();
} catch (error) {
loading_data.result = "error";
loading_data.error = error as string;
@ -365,7 +392,7 @@ export class IconManager {
}
load_icon(id: number) : LocalIcon {
const server_uid = this.handle.handle.channelTree.server.properties.virtualserver_unique_identifier;
const server_uid = this.handle.connectionHandler.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 => {
@ -376,4 +403,8 @@ export class IconManager {
}
return icon;
}
flush_cache() {
this.loading_timestamps = {};
}
}

417
shared/js/file/Transfer.ts Normal file
View File

@ -0,0 +1,417 @@
import {Registry} from "tc-shared/events";
import {CommandResult, ErrorCode} from "tc-shared/connection/ServerConnectionDeclaration";
/* Transfer source types */
export enum TransferSourceType {
BROWSER_FILE,
BUFFER,
TEXT
}
export abstract class TransferSource {
readonly type: TransferSourceType;
protected constructor(type: TransferSourceType) {
this.type = type;
}
abstract fileSize() : Promise<number>;
}
export abstract class BrowserFileTransferSource extends TransferSource {
protected constructor() {
super(TransferSourceType.BROWSER_FILE);
}
abstract getFile() : File;
}
export abstract class BufferTransferSource extends TransferSource {
protected constructor() {
super(TransferSourceType.BUFFER);
}
abstract getBuffer() : ArrayBuffer;
}
export abstract class TextTransferSource extends TransferSource {
protected constructor() {
super(TransferSourceType.TEXT);
}
abstract getText() : string;
}
export type TransferSourceSupplier = (transfer: FileUploadTransfer) => Promise<TransferSource>;
/* Transfer target types */
export enum TransferTargetType {
RESPONSE,
DOWNLOAD
}
export abstract class TransferTarget {
readonly type: TransferTargetType;
protected constructor(type: TransferTargetType) {
this.type = type;
}
}
export abstract class DownloadTransferTarget extends TransferTarget {
protected constructor() {
super(TransferTargetType.DOWNLOAD);
}
}
export abstract class ResponseTransferTarget extends TransferTarget {
protected constructor() {
super(TransferTargetType.RESPONSE);
}
abstract hasResponse() : boolean;
abstract getResponse() : Response;
}
export type TransferTargetSupplier = (transfer: FileDownloadTransfer) => Promise<TransferTarget>;
export enum FileTransferState {
PENDING, /* bending because other transfers already going on */
INITIALIZING,
CONNECTING,
RUNNING,
FINISHED,
ERRORED,
CANCELED
}
export enum CancelReason {
USER_ACTION,
SERVER_DISCONNECTED
}
export enum FileTransferDirection {
UPLOAD,
DOWNLOAD
}
export interface FileTransferEvents {
"notify_state_updated": { oldState: FileTransferState, newState: FileTransferState },
"notify_progress": { progress: TransferProgress },
"action_request_cancel": { reason: CancelReason },
"notify_transfer_canceled": {}
}
export interface TransferProperties {
channel_id: number | 0;
path: string;
name: string;
}
export interface InitializedTransferProperties {
serverTransferId: number;
transferKey: string;
addresses: {
serverAddress: string;
serverPort: number;
}[];
protocol: number; /* should be constant 1 */
seekOffset: number;
fileSize?: number;
}
export interface TransferInitializeError {
error: "initialize";
commandResult: string | CommandResult;
}
export interface TransferConnectError {
error: "connection";
reason: "missing-provider" | "provider-initialize-error" | "network-error";
extraMessage?: string;
}
export interface TransferIOError {
error: "io";
reason: "unsupported-target" | "failed-to-initialize-target" | "buffer-transfer-failed";
extraMessage?: string;
}
export interface TransferErrorStatus {
error: "status";
status: ErrorCode;
extraMessage: string;
}
export interface TransferErrorTimeout {
error: "timeout";
}
export type TransferErrorType = TransferInitializeError | TransferConnectError | TransferIOError | TransferErrorStatus | TransferErrorTimeout;
export interface TransferProgress {
timestamp: number;
file_bytes_transferred: number;
file_current_offset: number;
file_start_offset: number;
file_total_size: number;
network_bytes_received: number;
network_bytes_send: number;
network_current_speed: number;
network_average_speed: number;
}
export interface TransferTimings {
timestampScheduled: number;
timestampExecuted: number;
timestampTransferBegin: number;
timestampEnd: number;
}
export interface FinishedFileTransfer {
readonly clientTransferId: number;
readonly timings: TransferTimings;
readonly properties: TransferProperties;
readonly direction: FileTransferDirection;
readonly state: FileTransferState.CANCELED | FileTransferState.FINISHED | FileTransferState.ERRORED;
/* only set if state is ERRORED */
readonly transferError?: TransferErrorType;
readonly transferErrorMessage?: string;
readonly bytesTransferred: number;
}
export class FileTransfer {
readonly events: Registry<FileTransferEvents>;
readonly clientTransferId: number;
readonly direction: FileTransferDirection;
readonly properties: TransferProperties;
readonly timings: TransferTimings;
lastStateUpdate: number;
private cancelReason: CancelReason;
private transferProperties_: InitializedTransferProperties;
private transferError_: TransferErrorType;
private transferErrorMessage_: string;
private transferState_: FileTransferState;
private progress_: TransferProgress;
protected constructor(direction, clientTransferId, properties) {
this.direction = direction;
this.clientTransferId = clientTransferId;
this.properties = properties;
this.timings = {
timestampExecuted: 0,
timestampTransferBegin: 0,
timestampEnd: 0,
timestampScheduled: Date.now()
};
this.setTransferState(FileTransferState.PENDING);
this.events = new Registry<FileTransferEvents>();
this.events.on("notify_transfer_canceled", event => {
this.setTransferState(FileTransferState.CANCELED);
});
}
isRunning() {
return this.transferState_ === FileTransferState.CONNECTING || this.transferState_ === FileTransferState.RUNNING || this.transferState_ === FileTransferState.INITIALIZING;
}
isPending() {
return this.transferState_ === FileTransferState.PENDING;
}
isFinished() {
return this.transferState() === FileTransferState.FINISHED || this.transferState() === FileTransferState.ERRORED || this.transferState() === FileTransferState.CANCELED;
}
transferState() {
return this.transferState_;
}
transferProperties() : InitializedTransferProperties | undefined {
return this.transferProperties_;
}
currentError() : TransferErrorType | undefined {
return this.transferError_;
}
currentErrorMessage() : string | undefined {
return this.transferErrorMessage_;
}
lastProgressInfo() : TransferProgress | undefined {
return this.progress_;
}
setFailed(error: TransferErrorType, asMessage: string) {
if(this.isFinished())
throw tr("invalid transfer state");
if(typeof asMessage !== "string")
debugger;
this.transferErrorMessage_ = asMessage;
this.transferError_ = error;
this.setTransferState(FileTransferState.ERRORED);
}
setProperties(properties: InitializedTransferProperties) {
if(this.transferState() !== FileTransferState.INITIALIZING)
throw tr("invalid transfer state");
this.transferProperties_ = properties;
this.setTransferState(FileTransferState.CONNECTING);
}
requestCancel(reason: CancelReason) {
if(this.isFinished())
throw tr("invalid transfer state");
this.cancelReason = reason;
this.events.fire("action_request_cancel");
}
setTransferState(newState: FileTransferState) {
if(this.transferState_ === newState)
return;
const newIsFinishedState = newState === FileTransferState.CANCELED || newState === FileTransferState.ERRORED || newState === FileTransferState.FINISHED;
try {
switch (this.transferState_) {
case undefined:
if(newState !== FileTransferState.PENDING)
throw void 0;
this.timings.timestampScheduled = Date.now();
break;
case FileTransferState.PENDING:
if(newState !== FileTransferState.INITIALIZING && !newIsFinishedState)
throw void 0;
break;
case FileTransferState.INITIALIZING:
if(newState !== FileTransferState.CONNECTING && !newIsFinishedState)
throw void 0;
break;
case FileTransferState.CONNECTING:
if(newState !== FileTransferState.RUNNING && !newIsFinishedState)
throw void 0;
break;
case FileTransferState.RUNNING:
if(!newIsFinishedState)
throw void 0;
break;
case FileTransferState.FINISHED:
case FileTransferState.CANCELED:
case FileTransferState.ERRORED:
if(this.isFinished())
throw void 0;
this.timings.timestampEnd = Date.now();
break;
}
switch (newState) {
case FileTransferState.INITIALIZING:
this.timings.timestampExecuted = Date.now();
break;
case FileTransferState.RUNNING:
this.timings.timestampTransferBegin = Date.now();
break;
case FileTransferState.FINISHED:
case FileTransferState.CANCELED:
case FileTransferState.ERRORED:
this.timings.timestampEnd = Date.now();
break;
}
} catch (e) {
throw "invalid transfer state transform from " + this.transferState_ + " to " + newState;
return;
}
const oldState = this.transferState_;
this.transferState_ = newState;
this.events?.fire("notify_state_updated", { oldState: oldState, newState: newState });
}
updateProgress(progress: TransferProgress) {
this.progress_ = progress;
this.events.fire_async("notify_progress", { progress: progress });
}
awaitFinished() : Promise<void> {
return new Promise(resolve => {
if(this.isFinished()) {
resolve();
return;
}
const listenerStatus = () => {
if(this.isFinished()) {
this.events.off("notify_state_updated", listenerStatus);
resolve();
}
};
this.events.on("notify_state_updated", listenerStatus);
});
}
}
export class FileDownloadTransfer extends FileTransfer {
public readonly targetSupplier: TransferTargetSupplier;
public target: TransferTarget;
constructor(direction, clientTransferId, properties: TransferProperties, targetSupplier) {
super(direction, clientTransferId, properties);
this.targetSupplier = targetSupplier;
}
}
export class FileUploadTransfer extends FileTransfer {
public readonly sourceSupplier: TransferSourceSupplier;
public source: TransferSource;
public fileSize: number;
constructor(direction, clientTransferId, properties: TransferProperties, sourceSupplier) {
super(direction, clientTransferId, properties);
this.sourceSupplier = sourceSupplier;
}
}
export abstract class TransferProvider {
private static instance_;
public static provider() : TransferProvider { return this.instance_; }
public static setProvider(provider: TransferProvider) {
this.instance_ = provider;
}
abstract executeFileDownload(transfer: FileDownloadTransfer);
abstract executeFileUpload(transfer: FileUploadTransfer);
abstract targetSupported(type: TransferTargetType);
abstract sourceSupported(type: TransferSourceType);
async createResponseTarget() : Promise<ResponseTransferTarget> { throw tr("response target isn't supported"); }
async createDownloadTarget(filename?: string) : Promise<DownloadTransferTarget> { throw tr("download target isn't supported"); }
async createBufferSource(buffer: ArrayBuffer) : Promise<BufferTransferSource> { throw tr("buffer source isn't supported"); }
async createTextSource(text: string) : Promise<TextTransferSource> { throw tr("text source isn't supported"); };
async createBrowserFileSource(file: File) : Promise<BrowserFileTransferSource> { throw tr("browser file source isn't supported"); }
}

View File

@ -4,7 +4,7 @@ import {guid} from "tc-shared/crypto/uid";
import {StaticSettings} from "tc-shared/settings";
import {createErrorModal} from "tc-shared/ui/elements/Modal";
import * as loader from "tc-loader";
import {formatMessage} from "tc-shared/ui/frames/chat";
import {formatMessage, formatMessageString} from "tc-shared/ui/frames/chat";
export interface TranslationKey {
message: string;
@ -69,9 +69,21 @@ export function tr(message: string, key?: string) {
return translated;
}
export function tra(message: string, ...args: any[]) {
export function tra(message: string, ...args: (string | number | boolean)[]) : string;
export function tra(message: string, ...args: any[]) : JQuery[];
export function tra(message: string, ...args: any[]) : any {
message = /* @tr-ignore */ tr(message);
return formatMessage(message, ...args);
for(const element of args) {
if(typeof element !== "string" && typeof element !== "number" && typeof element !== "boolean")
return formatMessage(message, ...args);
}
if(message.indexOf("{:") !== -1)
return formatMessage(message, ...args);
return formatMessageString(message, ...args);
}
export function traj(message: string, ...args: any[]) : JQuery[] {
return tra(message, ...args, {});
}
async function load_translation_file(url: string, path: string) : Promise<TranslationFile> {

View File

@ -18,7 +18,8 @@ export enum LogCategory {
IPC,
IDENTITIES,
STATISTICS,
DNS
DNS,
FILE_TRANSFER
}
export enum LogType {
@ -30,22 +31,23 @@ export enum LogType {
}
let category_mapping = new Map<number, string>([
[LogCategory.CHANNEL, "Channel "],
[LogCategory.CHANNEL_PROPERTIES, "Channel "],
[LogCategory.CLIENT, "Client "],
[LogCategory.SERVER, "Server "],
[LogCategory.BOOKMARKS, "Bookmark "],
[LogCategory.PERMISSIONS, "Permission "],
[LogCategory.GENERAL, "General "],
[LogCategory.NETWORKING, "Network "],
[LogCategory.VOICE, "Voice "],
[LogCategory.AUDIO, "Audio "],
[LogCategory.CHANNEL, "Chat "],
[LogCategory.I18N, "I18N "],
[LogCategory.IDENTITIES, "Identities "],
[LogCategory.IPC, "IPC "],
[LogCategory.STATISTICS, "Statistics "],
[LogCategory.DNS, "DNS "]
[LogCategory.CHANNEL, "Channel "],
[LogCategory.CHANNEL_PROPERTIES, "Channel "],
[LogCategory.CLIENT, "Client "],
[LogCategory.SERVER, "Server "],
[LogCategory.BOOKMARKS, "Bookmark "],
[LogCategory.PERMISSIONS, "Permission "],
[LogCategory.GENERAL, "General "],
[LogCategory.NETWORKING, "Network "],
[LogCategory.VOICE, "Voice "],
[LogCategory.AUDIO, "Audio "],
[LogCategory.CHANNEL, "Chat "],
[LogCategory.I18N, "I18N "],
[LogCategory.IDENTITIES, "Identities "],
[LogCategory.IPC, "IPC "],
[LogCategory.STATISTICS, "Statistics "],
[LogCategory.DNS, "DNS "],
[LogCategory.FILE_TRANSFER, "FILE_TRANSFER"]
]);
export let enabled_mapping = new Map<number, boolean>([
@ -64,7 +66,8 @@ export let enabled_mapping = new Map<number, boolean>([
[LogCategory.IDENTITIES, true],
[LogCategory.IPC, true],
[LogCategory.STATISTICS, true],
[LogCategory.DNS, true]
[LogCategory.DNS, true],
[LogCategory.FILE_TRANSFER, true]
]);
//Values will be overridden by initialize()

View File

@ -10,7 +10,6 @@ import * as i18n from "./i18n/localize";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {createInfoModal} from "tc-shared/ui/elements/Modal";
import {tra} from "./i18n/localize";
import {RequestFileUpload} from "tc-shared/file/FileManager";
import * as stats from "./stats";
import * as fidentity from "./profiles/identities/TeaForumIdentity";
import {default_recorder, RecorderProfile, set_default_recorder} from "tc-shared/voice/RecorderProfile";
@ -30,6 +29,11 @@ import * as ReactDOM from "react-dom";
import * as cbar from "./ui/frames/control-bar";
import * as global_ev_handler from "./events/ClientGlobalControlHandler";
import {global_client_actions} from "tc-shared/events/GlobalEvents";
import {
FileTransferState,
TransferProvider,
} from "tc-shared/file/Transfer";
import {spawnFileTransferModal} from "tc-shared/ui/modal/transfer/ModalFileTransfer";
/* required import for init */
require("./proto").initialize();
@ -351,6 +355,7 @@ function main() {
server_connections.set_active_connection(server_connections.all_connections()[0]);
/*
(window as any).test_upload = (message?: string) => {
message = message || "Hello World";
@ -375,6 +380,38 @@ function main() {
});
})
};
*/
(window as any).test_download = async () => {
const connection = server_connections.active_connection();
const download = connection.fileManager.initializeFileDownload({
targetSupplier: async () => await TransferProvider.provider().createDownloadTarget(),
name: "HomeStudent2019Retail.img",
path: "/",
channel: 4
});
console.log("Download stated");
await download.awaitFinished();
console.log("Download finished (%s)", FileTransferState[download.transferState()]);
//console.log(await (download.target as ResponseTransferTarget).getResponse().blob());
console.log("Have buffer");
};
(window as any).test_upload = async () => {
const connection = server_connections.active_connection();
const download = connection.fileManager.initializeFileUpload({
source: async () => await TransferProvider.provider().createTextSource("Hello my lovely world...."),
name: "test-upload.txt",
path: "/",
channel: 4
});
console.log("Download stated");
await download.awaitFinished();
console.log("Download finished (%s)", FileTransferState[download.transferState()]);
//console.log(await (download.target as ResponseTransferTarget).getResponse().blob());
console.log("Have buffer");
};
/* schedule it a bit later then the main because the main function is still within the loader */
setTimeout(() => {
@ -436,11 +473,13 @@ function main() {
*/
/* for testing */
if(settings.static_global(Settings.KEY_USER_IS_NEW)) {
const modal = openModalNewcomer();
modal.close_listener.push(() => settings.changeGlobal(Settings.KEY_USER_IS_NEW, false));
}
(window as any).spawnFileTransferModal = spawnFileTransferModal;
spawnFileTransferModal(0);
}
const task_teaweb_starter: loader.Task = {

View File

@ -1,6 +1,8 @@
import {settings, Settings} from "tc-shared/settings";
import * as loader from "tc-loader";
import * as fidentity from "./TeaForumIdentity";
import * as log from "../../log";
import {LogCategory} from "../../log";
declare global {
interface Window {
@ -366,5 +368,25 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
if(_data && _data.is_expired()) {
console.error(tr("TeaForo data is expired. TeaForo connection isn't available!"));
}
setInterval(() => {
/* if we don't have any _data object set we could not renew anything */
if(_data) {
log.info(LogCategory.IDENTITIES, tr("Renewing TeaForo data."));
renew_data().then(status => {
if(status === "success") {
log.info(LogCategory.IDENTITIES,tr("TeaForo data has been successfully renewed."));
} else {
log.warn(LogCategory.IDENTITIES,tr("Failed to renew TeaForo data. New login required."));
localStorage.removeItem("teaspeak-forum-data");
localStorage.removeItem("teaspeak-forum-sign");
localStorage.removeItem("teaspeak-forum-auth");
}
}).catch(error => {
console.warn(tr("Failed to renew TeaForo data. An error occurred: %o"), error);
});
}
}, 24 * 60 * 60 * 1000);
}
})
});

View File

@ -6,6 +6,7 @@ declare global {
last?(): T;
pop_front(): T | undefined;
toggle(entry: T) : boolean;
}
interface JSON {
@ -172,6 +173,18 @@ if (!Array.prototype.pop_front) {
}
}
if (!Array.prototype.toggle) {
Array.prototype.toggle = function<T>(element: T): boolean {
const index = this.findIndex(e => e === element);
if(index === -1) {
this.push(element);
return true;
} else {
this.splice(index, 1);
return false;
}
}
}
if (!Array.prototype.last){
Array.prototype.last = function(){

View File

@ -354,6 +354,12 @@ export class Settings extends StaticSettings {
default_value: "tea-web"
};
static readonly KEY_TRANSFERS_SHOW_FINISHED: SettingsKey<boolean> = {
key: 'transfers_show_finished',
default_value: true,
description: "Show finished file transfers in the file transfer list"
};
static readonly FN_INVITE_LINK_SETTING: (name: string) => SettingsKey<string> = name => {
return {
key: 'invite_link_setting_' + name

View File

@ -20,6 +20,7 @@ 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";
import {spawnFileTransferModal} from "tc-shared/ui/modal/transfer/ModalFileTransfer";
export enum ChannelType {
PERMANENT,
@ -170,7 +171,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
//HTML DOM elements
private _destroyed = false;
private _cachedPassword: string;
private cachedPasswordHash: string;
private _cached_channel_description: string = undefined;
private _cached_channel_description_promise: Promise<string> = undefined;
private _cached_channel_description_promise_resolve: any = undefined;
@ -390,6 +391,12 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
icon_class: "client-channel_switch",
name: bold(tr("Switch to channel")),
callback: () => this.joinChannel()
},{
type: contextmenu.MenuEntryType.ENTRY,
icon_class: "client-filetransfer",
name: bold(tr("Open channel file browser")),
callback: () => spawnFileTransferModal(this.getChannelId()),
visible: false /* FIXME: Enable this */
}, {
type: contextmenu.MenuEntryType.ENTRY,
icon_class: "client-channel_switch",
@ -623,32 +630,47 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
}
joinChannel() {
if(this.properties.channel_flag_password == true &&
!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) !== "string") return;
hashPassword(text).then(result => {
this._cachedPassword = result;
this.events.fire("notify_cached_password_updated", { reason: "password-entered", new_hash: result });
this.joinChannel();
});
}).open();
} else if(this.channelTree.client.getClient().currentChannel() != this)
this.channelTree.client.getServerConnection().command_helper.joinChannel(this, this._cachedPassword).then(() => {
this.channelTree.client.sound.play(Sound.CHANNEL_JOINED);
}).catch(error => {
if(error instanceof CommandResult) {
if(error.id == 781) { //Invalid password
this._cachedPassword = undefined;
this.events.fire("notify_cached_password_updated", { reason: "password-miss-match" });
}
}
if(this.properties.channel_flag_password === true && !this.cachedPasswordHash) {
this.requestChannelPassword(PermissionType.B_CHANNEL_JOIN_IGNORE_PASSWORD).then(password => {
this.joinChannel();
});
return;
}
this.channelTree.client.getServerConnection().command_helper.joinChannel(this, this.cachedPasswordHash).then(() => {
this.channelTree.client.sound.play(Sound.CHANNEL_JOINED);
}).catch(error => {
if(error instanceof CommandResult) {
if(error.id == 781) { //Invalid password
this.invalidateCachedPassword();
}
}
});
}
cached_password() { return this._cachedPassword; }
async requestChannelPassword(ignorePermission: PermissionType) : Promise<{ hash: string } | undefined> {
if(this.cachedPasswordHash)
return { hash: this.cachedPasswordHash };
if(this.channelTree.client.permissions.neededPermission(ignorePermission).granted(1))
return { hash: "having ignore permission" };
const password = await new Promise(resolve => createInputModal(tr("Channel password"), tr("Channel password:"), () => true, resolve).open())
if(typeof(password) !== "string" || !password)
return;
const hash = await hashPassword(password);
this.cachedPasswordHash = hash;
this.events.fire("notify_cached_password_updated", { reason: "password-entered", new_hash: hash });
return { hash: this.cachedPasswordHash };
}
invalidateCachedPassword() {
this.cachedPasswordHash = undefined;
this.events.fire("notify_cached_password_updated", { reason: "password-miss-match" });
}
cached_password() { return this.cachedPasswordHash; }
async subscribe() : Promise<void> {
if(this.subscribe_mode == ChannelSubscribeMode.SUBSCRIBED)

View File

@ -19,7 +19,6 @@ import {spawnPermissionEdit} from "tc-shared/ui/modal/permission/ModalPermission
import {createServerGroupAssignmentModal} from "tc-shared/ui/modal/ModalGroupAssignment";
import {openClientInfo} from "tc-shared/ui/modal/ModalClientInfo";
import {spawnBanClient} from "tc-shared/ui/modal/ModalBanClient";
import {spawnChangeVolume} from "tc-shared/ui/modal/ModalChangeVolume";
import {spawnChangeLatency} from "tc-shared/ui/modal/ModalChangeLatency";
import {spawnPlaylistEdit} from "tc-shared/ui/modal/ModalPlaylistEdit";
import {formatMessage} from "tc-shared/ui/frames/chat";

View File

@ -95,6 +95,46 @@ export function formatMessage(pattern: string, ...objects: any[]) : JQuery[] {
return result;
}
export function formatMessageString(pattern: string, ...args: string[]) : string {
let begin = 0, found = 0;
let result: string[] = [];
do {
found = pattern.indexOf('{', found);
if(found == -1 || pattern.length <= found + 1) {
result.push(pattern.substr(begin));
break;
}
if(found > 0 && pattern[found - 1] == '\\') {
//TODO remove the escape!
found++;
continue;
}
result.push(pattern.substr(begin, found - begin)); //Append the text
let offset = 0;
let number;
while ("0123456789".includes(pattern[found + 1 + offset])) offset++;
number = parseInt(offset > 0 ? pattern.substr(found + 1, offset) : "0");
if(pattern[found + offset + 1] != '}') {
found++;
continue;
}
if(args.length < number)
log.warn(LogCategory.GENERAL, tr("Message to format contains invalid index (%o)"), number);
result.push(args[number]);
found = found + 1 + offset;
begin = found + 1;
} while(found++);
return result.join("");
}
//TODO: Remove this (only legacy)
export function bbcode_chat(message: string) : JQuery[] {
return bbcode.format(message, {

View File

@ -39,7 +39,7 @@ interface VolumeChangeModalState {
}
@ReactEventHandler(e => e.props.events)
class VolumeChangeModal extends React.Component<{ clientName: string, remote: boolean, events: Registry<VolumeChangeEvents> }, VolumeChangeModalState> {
class VolumeChangeModal extends React.Component<{ clientName: string, maxVolume?: number, remote: boolean, events: Registry<VolumeChangeEvents> }, VolumeChangeModalState> {
private readonly refSlider = React.createRef<Slider>();
private originalValue: number;
@ -141,7 +141,7 @@ class VolumeChangeModal extends React.Component<{ clientName: string, remote: bo
private onApplyClick() {
this.props.events.fire("apply-volume", {
newValue: this.state.volumeModifier,
newValue: this.state.volumeModifier,
origin: "user-input"
});
}
@ -275,7 +275,7 @@ export function spawnMusicBotVolumeChange(client: MusicClientEntry, maxValue: nu
const modal = spawnReactModal(class extends Modal {
renderBody() {
return <VolumeChangeModal remote={true} clientName={client.clientNickName()} events={events} />;
return <VolumeChangeModal remote={true} clientName={client.clientNickName()} maxVolume={maxValue} events={events} />;
}
title(): string {

View File

@ -1,15 +1,15 @@
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import PermissionType from "tc-shared/permission/PermissionType";
import {createErrorModal, createModal} from "tc-shared/ui/elements/Modal";
import {FileEntry, UploadKey} from "tc-shared/file/FileManager";
import {LogCategory} from "tc-shared/log";
import * as log from "tc-shared/log";
import {LogCategory} from "tc-shared/log";
import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
import {tra} from "tc-shared/i18n/localize";
import {tra, traj} from "tc-shared/i18n/localize";
import {arrayBufferBase64} from "tc-shared/utils/buffers";
import {Settings, settings} from "tc-shared/settings";
import * as crc32 from "tc-shared/crypto/crc32";
import {transfer_provider} from "tc-shared/file/FileManager";
import {FileInfo} from "tc-shared/file/FileManager";
import {FileTransferState, TransferProvider} from "tc-shared/file/Transfer";
export function spawnIconSelect(client: ConnectionHandler, callback_icon?: (id: number) => any, selected_icon?: number) {
selected_icon = selected_icon || 0;
@ -89,7 +89,7 @@ export function spawnIconSelect(client: ConnectionHandler, callback_icon?: (id:
container_icons_remote.detach().empty();
const chunk_size = 50;
const icon_chunks: FileEntry[][] = [];
const icon_chunks: FileInfo[][] = [];
let index = 0;
while(icons.length > index) {
icon_chunks.push(icons.slice(index, index + chunk_size));
@ -388,54 +388,53 @@ function handle_icon_upload(file: File, client: ConnectionHandler) : UploadingIc
bar.set_value(25);
bar.set_message(tr("initializing"));
let upload_key: UploadKey;
try {
upload_key = await client.fileManager.upload_file({
channel: undefined,
channel_password: undefined,
name: '/icon_' + icon.icon_id,
overwrite: false,
path: '',
size: icon.file.size
})
} catch(error) {
if(error instanceof CommandResult && error.id == ErrorID.FILE_ALREADY_EXISTS) {
if(!settings.static_global(Settings.KEY_DISABLE_COSMETIC_SLOWDOWN, false))
await new Promise(resolve => setTimeout(resolve, 500 + Math.floor(Math.random() * 500)));
bar.set_message(tr("icon already exists"));
bar.set_value(100);
icon.upload_state = "uploaded";
return;
const transfer = client.fileManager.initializeFileUpload({
channel: 0,
channelPassword: undefined,
path: "",
name: "/icon_" + icon.icon_id,
source: async () => await TransferProvider.provider().createBrowserFileSource(icon.file)
});
transfer.events.on("notify_state_updated", event => {
switch (event.newState) {
case FileTransferState.PENDING:
bar.set_value(10);
bar.set_message(tr("pending"));
break;
case FileTransferState.INITIALIZING:
case FileTransferState.CONNECTING:
bar.set_value(30);
bar.set_message(tr("connecting"));
break;
case FileTransferState.RUNNING:
bar.set_value(50);
bar.set_message(tr("uploading"));
break;
case FileTransferState.FINISHED:
bar.set_value(100);
bar.set_message(tr("upload completed"));
icon.upload_state = "uploaded";
break;
case FileTransferState.ERRORED:
log.warn(LogCategory.FILE_TRANSFER, tr("Failed to upload icon %s: %o"), icon.file.name, transfer.currentError());
bar.set_value(100);
bar.set_error(tr("upload failed: ") + transfer.currentErrorMessage());
icon.upload_state = "error";
break;
case FileTransferState.CANCELED:
bar.set_value(100);
bar.set_error(tr("upload canceled"));
icon.upload_state = "error";
break;
}
console.error(tr("Failed to initialize upload: %o"), error);
bar.set_error(tr("failed to initialize upload"));
icon.upload_state = "error";
return;
}
bar.set_value(50);
bar.set_message(tr("uploading"));
const connection = transfer_provider().spawn_upload_transfer(upload_key);
try {
await connection.put_data(icon.file)
} catch(error) {
console.error(tr("Icon upload failed for icon %s: %o"), icon.file.name, error);
if(typeof(error) === "string")
bar.set_error(tr("upload failed: ") + error);
else if(typeof(error.message) === "string")
bar.set_error(tr("upload failed: ") + error.message);
else
bar.set_error(tr("upload failed"));
icon.upload_state = "error";
return;
}
const time_end = Date.now();
if(!settings.static_global(Settings.KEY_DISABLE_COSMETIC_SLOWDOWN, false))
await new Promise(resolve => setTimeout(resolve, Math.max(0, 1000 - (time_end - time_begin))));
bar.set_value(100);
bar.set_message(tr("upload completed"));
icon.upload_state = "uploaded";
});
await transfer.awaitFinished();
};
};
}
@ -467,7 +466,7 @@ export function spawnIconUpload(client: ConnectionHandler) {
const update_upload_button = () => {
const icon_count = icons.filter(e => e.state === "valid").length;
button_upload.empty();
tra("Upload icons ({})", icon_count).forEach(e => e.appendTo(button_upload));
traj("Upload icons ({})", icon_count).forEach(e => e.appendTo(button_upload));
button_upload.prop("disabled", icon_count == 0);
};
update_upload_button();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,339 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
.container {
padding: 1em;
position: relative;
padding-bottom: 4em; /* for the transfer info */
.navigation {
.containerIcon {
margin: auto .25em;
padding: .2em;
display: flex;
flex-direction: column;
justify-content: center;
cursor: pointer;
> div {
padding: .1em;
}
}
.refreshIcon {
border-radius: 1px;
@include transition(background-color $button_hover_animation_time ease-in-out);
> div {
padding: .1em;
}
&.enabled {
cursor: pointer;
&:hover {
background-color: #ffffff0e;
}
}
}
.directoryIcon {
margin-right: -.25em;
}
input {
margin-left: .5em;
}
.containerPath {
@include user-select(none);
display: flex;
flex-direction: row;
justify-content: flex-start;
overflow: hidden;
white-space: nowrap;
width: calc(100% - 1em); /* some space for the text editing */
a.pathShrink {
flex-shrink: 1;
min-width: 5em;
@include text-dotdotdot();
}
a {
cursor: pointer;
@include transition(color $button_hover_animation_time ease-in-out);
&:hover, &.hovered {
color: #E6E6E6;
}
}
}
}
.fileTable {
min-height: 5em;
max-height: 40em;
height: 400px;
margin-top: 1em;
border: 1px #161616 solid;
border-radius: 0.2em;
background-color: #28292b;
.header {
z-index: 1;
padding-top: .2em;
padding-bottom: .2em;
background-color: #28292b;
.columnName, .columnSize, .columnType, .columnChanged {
position: relative;
display: flex;
flex-direction: row;
justify-content: center;
.seperator {
position: absolute;
right: .2em;
top: .2em;
bottom: .2em;
width: .1em;
background-color: #999999;
}
}
.columnSize {
width: 8em;
text-align: end;
}
.columnName {
padding-left: .5em;
}
> div:last-of-type {
.seperator {
display: none;
}
}
}
.body {
@include user-select(none);
@include chat-scrollbar-vertical();
.columnName {
padding-left: .5em;
display: flex;
flex-direction: row;
justify-content: flex-start;
a, div, img {
align-self: center;
margin-right: .5em;
@include text-dotdotdot();
}
img, div {
flex-shrink: 0;
height: 1em;
width: 1em;
}
input {
height: 1.3em;
align-self: center;
flex-grow: 1;
margin-right: .5em;
border-style: inherit;
padding: .1em;
}
}
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
a {
text-align: center;
}
}
.overlayError {
a {
font-size: 1.2em;
color: #9e9494;
}
}
.overlayEmptyFolder {
align-self: center;
margin-top: 1em;
}
.directoryEntry {
cursor: pointer;
&:hover {
background-color: #2c2d2f;
}
&.selected {
background-color: #1a1a1b;
}
/* drag hovered overrides selected */
&.hovered {
background-color: #2c2d2f;
}
$indicator_transform_time: .5s;
.indicator {
position: absolute;
left: 0;
right: 30%;
top: 0;
bottom: 0;
opacity: .4;
margin-right: 10px; /* for the gradient at the end */
.status {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
@include transition(all $indicator_transform_time ease-in-out);
}
&:after {
content: ' ';
position: absolute;
top: 0;
right: 0;
bottom: 0;
height: 100%;
width: 10px;
background-image: linear-gradient(to right, #28292b, #28292b);
@include transition(all $indicator_transform_time ease-in-out);
}
@include transition(all $indicator_transform_time ease-in-out);
@mixin define-indicator($color, $colorLight) {
background-color: $color;
.status {
background-color: $colorLight;
-webkit-box-shadow: 0 0 12px 3px $colorLight;
-moz-box-shadow: 0 0 12px 3px $colorLight;
box-shadow: 0 0 12px 3px $colorLight;
}
&:after {
background-image: linear-gradient(to right, $color, #28292b);
}
}
&.red {
@include define-indicator(#a10000, #e60000);
}
&.blue {
@include define-indicator(#005fa1, #007acc);
}
&.green {
@include define-indicator(#389738, #4ecc4e);
}
&.hidden {
@include define-indicator(#28292b00, #28292b00);
}
}
}
}
.columnSize {
text-align: end;
a {
margin-right: 1em;
}
}
.columnType {
text-align: center;
}
}
.row {
}
}
.arrow {
width: 1em;
flex-shrink: 0;
flex-grow: 0;
display: flex;
flex-direction: column;
justify-content: center;
.inner {
flex-grow: 0;
flex-shrink: 0;
align-self: center;
margin-left: -.09em;
transform: rotate(-45deg);
-webkit-transform: rotate(-45deg);
display: inline-block;
border: solid #999999;
border-width: 0 0.125em 0.125em 0;
padding: 0.15em;
height: 0.15em;
width: .15em;
}
}

View File

@ -0,0 +1,226 @@
import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import * as React from "react";
import {FileType} from "tc-shared/file/FileManager";
import {Registry} from "tc-shared/events";
import {server_connections} from "tc-shared/ui/frames/connection_handlers";
import {FileBrowser, NavigationBar} from "tc-shared/ui/modal/transfer/FileBrowser";
import {
TransferInfo,
TransferInfoEvents
} from "tc-shared/ui/modal/transfer/TransferInfo";
import {initializeRemoteFileBrowserController} from "tc-shared/ui/modal/transfer/RemoteFileBrowserController";
import {ChannelEntry} from "tc-shared/ui/channel";
import {initializeTransferInfoController} from "tc-shared/ui/modal/transfer/TransferInfoController";
const cssStyle = require("./ModalFileTransfer.scss");
export const channelPathPrefix = tr("Channel") + " ";
export const iconPathPrefix = tr("Icons");
export const avatarsPathPrefix = tr("Avatars");
export const FileTransferUrlMediaType = "application/x-teaspeak-ft-urls";
export type TransferStatus = "pending" | "transferring" | "finished" | "errored" | "none";
export type FileMode = "password" | "empty" | "create" | "creating" | "normal" | "uploading";
export type ListedFileInfo = {
path: string;
name: string;
type: FileType;
datetime: number;
size: number;
virtual: boolean;
mode: FileMode;
transfer?: {
id: number;
direction: "upload" | "download";
status: TransferStatus;
percent: number;
} | undefined
};
export type PathInfo = {
channelId: number;
channel: ChannelEntry;
path: string;
type: "icon" | "avatar" | "channel" | "root";
}
export interface FileBrowserEvents {
query_files: { path: string },
query_files_result: {
path: string,
status: "success" | "timeout" | "error" | "no-permissions" | "invalid-password",
error?: string,
files?: ListedFileInfo[]
},
action_navigate_to: {
path: string
},
action_navigate_to_result: {
path: string,
status: "success" | "timeout" | "error";
error?: string;
pathInfo?: PathInfo
}
action_delete_file: {
files: {
path: string,
name: string
}[] | "selection";
mode: "force" | "ask";
},
action_delete_file_result: {
results: {
path: string,
name: string,
status: "success" | "timeout" | "error";
error?: string;
}[],
},
action_start_create_directory: {
defaultName: string
},
action_create_directory: {
path: string,
name: string
},
action_create_directory_result: {
path: string,
name: string,
status: "success" | "timeout" | "error";
error?: string;
},
action_rename_file: {
oldPath: string,
oldName: string,
newPath: string;
newName: string
},
action_rename_file_result: {
oldPath: string,
oldName: string,
status: "success" | "timeout" | "error" | "no-changes";
newPath?: string,
newName?: string,
error?: string;
},
action_start_rename: {
path: string;
name: string;
},
action_select_files: {
files: {
name: string,
type: FileType
}[]
mode: "exclusive" | "toggle"
},
action_selection_context_menu: {
pageX: number,
pageY: number
},
action_start_download: {
files: {
path: string,
name: string
}[]
},
action_start_upload: {
path: string;
mode: "files" | "browse";
files?: File[];
},
notify_transfer_start: {
path: string;
name: string;
id: number;
mode: "upload" | "download";
},
notify_transfer_status: {
id: number;
status: TransferStatus;
fileSize?: number;
},
notify_transfer_progress: {
id: number;
progress: number;
fileSize: number;
status: TransferStatus
}
notify_modal_closed: {},
notify_drag_ended: {},
/* Attention: Only use in sync mode! */
notify_drag_started: {
event: DragEvent
}
}
class FileTransferModal extends Modal {
readonly remoteBrowseEvents = new Registry<FileBrowserEvents>();
readonly transferInfoEvents = new Registry<TransferInfoEvents>();
private readonly defaultChannelId;
constructor(defaultChannelId: number) {
super();
this.defaultChannelId = defaultChannelId;
this.remoteBrowseEvents.enable_debug("remote-file-browser");
this.transferInfoEvents.enable_debug("transfer-info");
initializeRemoteFileBrowserController(server_connections.active_connection(), this.remoteBrowseEvents);
initializeTransferInfoController(server_connections.active_connection(), this.transferInfoEvents);
}
protected onInitialize() {
const path = this.defaultChannelId ? "/" + channelPathPrefix + this.defaultChannelId + "/" : "/";
this.remoteBrowseEvents.fire("action_navigate_to", { path: path });
}
protected onDestroy() {
this.remoteBrowseEvents.fire("notify_modal_closed");
this.transferInfoEvents.fire("notify_modal_closed");
}
title(): string {
return "File Browser";
}
renderBody() {
const path = this.defaultChannelId ? "/" + channelPathPrefix + this.defaultChannelId + "/" : "/";
return <div className={cssStyle.container} style={{width: "600px"}}>
<NavigationBar events={this.remoteBrowseEvents} currentPath={path} />
<FileBrowser events={this.remoteBrowseEvents} currentPath={path} />
<TransferInfo events={this.transferInfoEvents} />
</div>
}
}
export function spawnFileTransferModal(channel: number) {
const modal = spawnReactModal(FileTransferModal, channel);
modal.show();
}

View File

@ -0,0 +1,780 @@
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {Registry} from "tc-shared/events";
import {FileType} from "tc-shared/file/FileManager";
import {CommandResult, ErrorCode, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
import PermissionType from "tc-shared/permission/PermissionType";
import * as log from "tc-shared/log";
import {LogCategory} from "tc-shared/log";
import {Entry, MenuEntry, MenuEntryType, spawn_context_menu} from "tc-shared/ui/elements/ContextMenu";
import * as ppt from "tc-backend/ppt";
import {SpecialKey} from "tc-shared/PPTListener";
import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
import {tra, traj} from "tc-shared/i18n/localize";
import {FileTransfer, FileTransferState, FileUploadTransfer, TransferProvider} from "tc-shared/file/Transfer";
import {createErrorModal} from "tc-shared/ui/elements/Modal";
import {
avatarsPathPrefix,
channelPathPrefix,
FileBrowserEvents,
iconPathPrefix, ListedFileInfo, PathInfo
} from "tc-shared/ui/modal/transfer/ModalFileTransfer";
function parsePath(path: string, connection: ConnectionHandler) : PathInfo {
if(path === "/" || !path) {
return {
channel: undefined,
channelId: 0,
path: "/",
type: "root"
};
} else if(path.startsWith("/" + channelPathPrefix)) {
const pathParts = path.split("/");
const channelId = parseInt(pathParts[1].substr(channelPathPrefix.length));
if(isNaN(channelId)) {
throw tr("Invalid channel id (ID is NaN)");
}
const channel = connection.channelTree.findChannel(channelId);
if(!channel) {
throw tr("Channel not visible anymore");
}
return {
type: "channel",
path: "/" + pathParts.slice(2).join("/"),
channelId: channelId,
channel: channel
};
} else if(path == "/" + iconPathPrefix + "/") {
return {
type: "icon",
path: "/icons/",
channelId: 0,
channel: undefined
};
} else if(path == "/" + avatarsPathPrefix + "/") {
return {
type: "avatar",
path: "/",
channelId: 0,
channel: undefined
};
} else {
throw tr("Unknown path");
}
}
export function initializeRemoteFileBrowserController(connection: ConnectionHandler, events: Registry<FileBrowserEvents>) {
events.on("action_navigate_to", event => {
try {
const info = parsePath(event.path, connection);
events.fire_async("action_navigate_to_result", {
path: event.path || "/",
status: "success",
pathInfo: info
});
} catch (error) {
events.fire_async("action_navigate_to_result", {
path: event.path,
status: "error",
error: error
});
}
});
events.on("query_files", event => {
let path: PathInfo;
try {
path = parsePath(event.path, connection);
} catch (error) {
events.fire_async("query_files_result", {
path: event.path,
status: "error",
error: error
});
return;
}
let request: Promise<ListedFileInfo[]>;
if(path.type === "root") {
request = (async () => {
const result: ListedFileInfo[] = [];
result.push({
type: FileType.DIRECTORY,
name: iconPathPrefix,
size: 0,
datetime: 0,
mode: "normal",
virtual: true,
path: "/"
});
result.push({
type: FileType.DIRECTORY,
name: avatarsPathPrefix,
size: 0,
datetime: 0,
mode: "normal",
virtual: true,
path: "/"
});
const requestArray = connection.channelTree.channels.map(e => {
return {
request: {
path: "/",
channelId: e.channelId
},
name: channelPathPrefix + e.getChannelId(),
channel: e
}
});
const channelInfos = await connection.fileManager.requestFileInfo(requestArray.map(e => e.request));
for(let index = 0; index < requestArray.length; index++) {
const response = channelInfos[index];
if(response instanceof CommandResult) {
/* some kind of error occured (maybe password set, or non existing) */
result.push({
type: FileType.DIRECTORY,
name: requestArray[index].name,
size: 0,
datetime: 0,
mode: requestArray[index].channel.properties.channel_flag_password ? "password" : "empty",
virtual: true,
path: "/"
});
} else {
result.push({
type: FileType.DIRECTORY,
name: requestArray[index].name,
size: 0,
datetime: 0,
mode: response.empty ? "empty" : "normal",
virtual: true,
path: "/"
});
}
}
return result;
})();
} else if(path.type === "channel") {
request = (async () => {
const hash = path.channel.properties.channel_flag_password ? await path.channel.requestChannelPassword(PermissionType.B_FT_IGNORE_PASSWORD) : undefined;
return connection.fileManager.requestFileList(path.path, path.channelId, hash?.hash).then(result => result.map(e => {
const transfer = connection.fileManager.findTransfer(path.channelId, path.path, e.name);
return {
datetime: e.datetime,
name: e.name,
size: e.size,
type: e.type,
path: event.path,
mode: e.empty ? "empty" : "normal",
virtual: false,
transfer: !transfer ? undefined : {
id: transfer.clientTransferId,
percent: transfer.isRunning() && transfer.lastProgressInfo() ? transfer.lastProgressInfo().file_current_offset / transfer.lastProgressInfo().file_total_size : 0,
status: transfer.isPending() ? "pending" : transfer.isRunning() ? "transferring" : "finished"
}
} as ListedFileInfo;
})).catch(async error => {
/* patch for the case that the channel directory hasn't been created yet */
if(error instanceof CommandResult) {
if(error.id === ErrorCode.FILE_NOT_FOUND && path.path === "/") {
return [];
} else if(error.id === 781) { //Invalid password
path.channel.invalidateCachedPassword();
}
}
throw error;
});
})();
} else if(path.type === "icon" || path.type === "avatar") {
request = connection.fileManager.requestFileList(path.path, 0).then(result => result.map(e => {
return {
datetime: e.datetime,
name: e.name,
size: e.size,
type: e.type,
mode: e.empty ? "empty" : "normal",
path: event.path
} as ListedFileInfo;
}));
} else {
events.fire_async("query_files_result", {
path: event.path,
status: "error",
error: tr("Unknown parsed path type")
});
return;
}
request.then(files => {
events.fire_async("query_files_result", {
path: event.path,
status: "success",
files: files.map(e => { e.datetime *= 1000; return e; })
});
}).catch(error => {
let message;
if(error instanceof CommandResult) {
if(error.id === ErrorID.PERMISSION_ERROR) {
const permission = connection.permissions.resolveInfo(error.json["failed_permid"] as number);
events.fire_async("query_files_result", {
path: event.path,
status: "no-permissions",
error: permission ? permission.name : "unknown"
});
return;
} else if(error.id === 781) { //Invalid password
events.fire_async("query_files_result", {
path: event.path,
status: "invalid-password"
});
return;
}
message = error.message + (error.extra_message ? " (" + error.extra_message + ")" : "");
} else if(typeof error === "string") {
message = error;
} else {
log.error(LogCategory.FILE_TRANSFER, tr("Failed to query channel directory files: %o"), error);
message = tr("lookup the console");
}
events.fire_async("query_files_result", {
path: event.path,
status: "error",
error: message
});
});
});
events.on("action_rename_file", event => {
if(event.newPath === event.oldPath && event.newName === event.oldName) {
events.fire_async("action_rename_file_result", {
oldPath: event.oldPath,
oldName: event.oldName,
newPath: event.newPath,
newName: event.newName,
status: "no-changes"
});
return;
}
let sourcePath: PathInfo, targetPath: PathInfo;
try {
sourcePath = parsePath(event.oldPath, connection);
if(sourcePath.type !== "channel")
throw tr("Icon/avatars could not be renamed");
} catch (error) {
events.fire_async("action_rename_file_result", {
oldPath: event.oldPath,
oldName: event.oldName,
status: "error",
error: tr("Invalid source path") + " (" + error + ")"
});
return;
}
try {
targetPath = parsePath(event.newPath, connection);
if(sourcePath.type !== "channel")
throw tr("Target path isn't a channel");
} catch (error) {
events.fire_async("action_rename_file_result", {
oldPath: event.oldPath,
oldName: event.oldName,
status: "error",
error: tr("Invalid target path") + " (" + error + ")"
});
return;
}
(async () => {
const sourcePassword = sourcePath.channel.properties.channel_flag_password ? await sourcePath.channel.requestChannelPassword(PermissionType.B_FT_IGNORE_PASSWORD) : undefined;
const targetPassword = targetPath.channel.properties.channel_flag_password ? await targetPath.channel.requestChannelPassword(PermissionType.B_FT_IGNORE_PASSWORD) : undefined;
return await connection.serverConnection.send_command("ftrenamefile", {
cid: sourcePath.channelId,
cpw: sourcePassword,
tcid: targetPath.channelId,
tcpw: targetPassword,
oldname: sourcePath.path + event.oldName,
newname: targetPath.path + event.newName
})
})().then(result => {
if(result.id !== 0)
throw result;
events.fire("action_rename_file_result", {
oldPath: event.oldPath,
oldName: event.oldName,
status: "success",
newName: event.newName,
newPath: event.newPath
});
}).catch(error => {
let message;
if(error instanceof CommandResult) {
if(error.id === ErrorID.PERMISSION_ERROR) {
const permission = connection.permissions.resolveInfo(error.json["failed_permid"] as number);
events.fire_async("action_rename_file_result", {
oldPath: event.oldPath,
oldName: event.oldName,
status: "error",
error: tr("Failed on permission ") + (permission ? permission.name : "unknown")
});
return;
} else if(error.id === 781) { //Invalid password
events.fire_async("action_rename_file_result", {
oldPath: event.oldPath,
oldName: event.oldName,
status: "error",
error: tr("Invalid channel password")
});
return;
}
message = error.message + (error.extra_message ? " (" + error.extra_message + ")" : "");
} else if(typeof error === "string") {
message = error;
} else {
log.error(LogCategory.FILE_TRANSFER, tr("Failed to rename/move files: %o"), error);
message = tr("lookup the console");
}
events.fire_async("action_rename_file_result", {
oldPath: event.oldPath,
oldName: event.oldName,
status: "error",
error: message
});
});
});
/* currently selected files */
let currentPath = "/";
let currentPathInfo: PathInfo;
let selection: { name: string, type: FileType }[] = [];
events.on("action_navigate_to_result", result => {
if(result.status !== "success")
return;
currentPathInfo = result.pathInfo;
currentPath = result.path;
selection = [];
});
events.on("action_rename_file_result", result => {
if(result.status !== "success")
return;
if(result.oldPath !== currentPath)
return;
const index = selection.map(e => e.name).findIndex(e => e === result.oldName);
if(index !== -1)
selection[index].name = result.newName;
});
events.on("action_select_files", event => {
if(event.mode === "exclusive") {
selection = event.files.slice(0);
} else if(event.mode === "toggle") {
event.files.forEach(e => {
const index = selection.map(e => e.name).findIndex(b => b === e.name);
if(index === -1)
selection.push(e);
else
selection.splice(index);
});
}
});
/* the selection handler */
events.on("action_selection_context_menu", event => {
const entries = [] as MenuEntry[];
if(currentPathInfo.type === "root") {
entries.push({
type: MenuEntryType.ENTRY,
name: tr("Refresh file list"),
icon_class: "client-file_refresh"
});
} else {
const forceDelete = ppt.key_pressed(SpecialKey.SHIFT);
if(selection.length === 0) {
entries.push({
type: MenuEntryType.ENTRY,
name: tr("Upload"),
icon_class: "client-upload",
callback: () => events.fire("action_start_upload", { mode: "browse", path: currentPath })
});
} else if(selection.length === 1) {
const file = selection[0];
if(file.type === FileType.FILE) {
entries.push({
type: MenuEntryType.ENTRY,
name: tr("Download"),
icon_class: "client-download",
callback: () => events.fire("action_start_download", { files: [{ name: file.name, path: currentPath }] })
});
}
if(currentPathInfo.type === "channel") {
entries.push({
type: MenuEntryType.ENTRY,
name: tr("Rename"),
icon_class: "client-change_nickname",
callback: () => events.fire("action_start_rename", { name: file.name, path: currentPath })
});
}
entries.push({
type: MenuEntryType.ENTRY,
name: forceDelete ? tr("Force delete file") : tr("Delete file"),
icon_class: "client-delete",
callback: () => events.fire("action_delete_file", { mode: forceDelete ? "force" : "ask", files: "selection" })
});
entries.push(Entry.HR());
} else if(selection.length > 1) {
if(selection.findIndex(e => e.type === FileType.DIRECTORY) === -1) {
entries.push({
type: MenuEntryType.ENTRY,
name: tr("Download"),
icon_class: "client-download",
callback: () => events.fire("action_start_download", { files: selection.map(file => { return { name: file.name, path: currentPath }}) })
});
}
entries.push({
type: MenuEntryType.ENTRY,
name: forceDelete ? tr("Force delete files") : tr("Delete files"),
icon_class: "client-delete",
callback: () => events.fire("action_delete_file", { mode: forceDelete ? "force" : "ask", files: "selection" })
});
}
entries.push({
type: MenuEntryType.ENTRY,
name: tr("Refresh file list"),
icon_class: "client-file_refresh",
callback: () => events.fire("action_navigate_to", { path: currentPath })
});
entries.push(Entry.HR());
entries.push({
type: MenuEntryType.ENTRY,
name: tr("Create folder"),
icon_class: "client-add_folder",
callback: () => events.fire("action_start_create_directory", { defaultName: tr("New folder") })
});
}
spawn_context_menu(event.pageX, event.pageY, ...entries);
});
events.on("action_delete_file", event => {
const files = event.files === "selection" ? selection.map(e => { return { path: currentPath, name: e.name }}) : event.files;
if(event.mode === "ask") {
spawnYesNo(tr("Are you sure?"), tra("Do you really want to delete {0} {1}?", files.length, files.length === 1 ? tr("files") : tr("files")), result => {
if(result)
events.fire("action_delete_file", {
files: files,
mode: "force"
});
});
return;
}
try {
const fileInfos = files.map(e => { return { info: parsePath(e.path, connection), path: e.path, name: e.name }});
connection.serverConnection.send_command("ftdeletefile", fileInfos.map(e => { return {
path: e.info.path,
cid: e.info.channelId,
cpw: e.info.channel?.cached_password(),
name: e.name
}})).then(async result => {
throw result;
}).catch(result => {
let message;
if(result instanceof CommandResult) {
if(result.bulks.length !== fileInfos.length) {
events.fire_async("action_delete_file_result", {
results: fileInfos.map((e) => {
return {
error: result.bulks.length === 1 ? (result.message + (result.extra_message ? " (" + result.extra_message + ")" : "")) : tr("Response contained invalid bulk length"),
path: e.path,
name: e.name,
status: "error"
};
})
});
return;
}
let results = [];
result.getBulks().forEach((e, index) => {
if(e.id === ErrorID.PERMISSION_ERROR) {
const permission = connection.permissions.resolveInfo(e.json["failed_permid"] as number);
results.push({
path: fileInfos[index].path,
name: fileInfos[index].name,
status: "error",
error: tr("Failed on permission ") + (permission ? permission.name : "unknown")
});
return;
} else if(e.id === 781) { //Invalid password
results.push({
path: fileInfos[index].path,
name: fileInfos[index].name,
status: "error",
error: tr("Invalid channel password")
});
return;
} else if(e.id !== 0) {
results.push({
path: fileInfos[index].path,
name: fileInfos[index].name,
status: "error",
error: e.message + (e.extra_message ? " (" + e.extra_message + ")" : "")
});
return;
}
results.push({
path: fileInfos[index].path,
name: fileInfos[index].name,
status: "success"
});
return;
});
events.fire_async("action_delete_file_result", {
results: results
});
return;
} else if(typeof result === "string") {
message = result;
} else {
log.error(LogCategory.FILE_TRANSFER, tr("Failed to create directory: %o"), result);
message = tr("lookup the console");
}
events.fire_async("action_delete_file_result", {
results: files.map((e) => {
return {
error: message,
path: e.path,
name: e.name,
status: "error"
};
})
});
});
} catch (error) {
events.fire_async("action_delete_file_result", {
results: files.map((e) => {
return {
error: tr("Failed to parse path for one or more entries ") + " (" + error + ")",
path: e.path,
name: e.name,
status: "error"
};
})
});
}
});
events.on("action_create_directory", event => {
let path: PathInfo;
try {
path = parsePath(event.path, connection);
if(path.type !== "channel")
throw tr("Directories could only created for channels");
} catch (error) {
events.fire_async("action_create_directory_result", {
name: event.name,
path: event.path,
status: "error",
error: tr("Invalid path") + " (" + error + ")"
});
return;
}
//ftcreatedir cid=4 cpw dirname=\/TestDir return_code=1:17
connection.serverConnection.send_command("ftcreatedir", {
cid: path.channelId,
cpw: path.channel.cached_password(),
dirname: path.path + event.name
}).then(() => {
events.fire("action_create_directory_result", { path: event.path, name: event.name, status: "success" });
}).catch(error => {
let message;
if(error instanceof CommandResult) {
if(error.id === ErrorID.PERMISSION_ERROR) {
const permission = connection.permissions.resolveInfo(error.json["failed_permid"] as number);
events.fire_async("action_create_directory_result", {
name: event.name,
path: event.path,
status: "error",
error: tr("Failed on permission ") + (permission ? permission.name : "unknown")
});
return;
} else if(error.id === 781) { //Invalid password
events.fire_async("action_create_directory_result", {
name: event.name,
path: event.path,
status: "error",
error: tr("Invalid channel password")
});
return;
}
message = error.message + (error.extra_message ? " (" + error.extra_message + ")" : "");
} else if(typeof error === "string") {
message = error;
} else {
log.error(LogCategory.FILE_TRANSFER, tr("Failed to create directory: %o"), error);
message = tr("lookup the console");
}
events.fire_async("action_create_directory_result", {
name: event.name,
path: event.path,
status: "error",
error: message
});
});
});
events.on("action_start_download", event => {
event.files.forEach(file => {
try {
const fileName = file.name;
const info = parsePath(file.path, connection);
const transfer = connection.fileManager.initializeFileDownload({
channel: info.channelId,
path: info.type === "channel" ? info.path : "",
name: info.type === "channel" ? file.name : "/" + file.name,
channelPassword: info.channel?.cached_password(),
targetSupplier: async () => TransferProvider.provider().createDownloadTarget()
});
transfer.awaitFinished().then(() => {
if(transfer.transferState() === FileTransferState.ERRORED) {
createErrorModal(tr("Failed to download file"), traj("Failed to download {0}:{:br:}{1}", fileName, transfer.currentErrorMessage())).open();
}
});
} catch (error) {
log.error(LogCategory.FILE_TRANSFER, tr("Failed to parse path for file download: %s"), error);
}
});
});
events.on("action_start_upload", event => {
if(event.mode === "browse") {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
document.body.appendChild(input);
input.onchange = () => {
if((input.files?.length | 0) === 0)
return;
events.fire("action_start_upload", { mode: "files", path: event.path, files: [...input.files] });
};
input.onblur = () => input.remove();
setTimeout(() => {
input.focus({ preventScroll: true });
input.click();
});
return;
} else if(event.mode === "files") {
const pathInfo = parsePath(event.path, connection);
if(pathInfo.type !== "channel") {
createErrorModal(tr("Failed to upload file(s)"), tra("Failed to upload files:{:br:}File uplaod is only supported in channel directories")).open();
return;
}
for(const file of event.files) {
const fileName = file.name;
const transfer = connection.fileManager.initializeFileUpload({
channel: pathInfo.channelId,
channelPassword: pathInfo.channel?.cached_password(),
name: file.name,
path: pathInfo.path,
source: async () => TransferProvider.provider().createBrowserFileSource(file)
});
transfer.awaitFinished().then(() => {
if(transfer.transferState() === FileTransferState.ERRORED) {
createErrorModal(tr("Failed to upload file"), tra("Failed to upload {0}:{:br:}{1}", fileName, transfer.currentErrorMessage())).open();
}
});
}
}
});
/* transfer status listener */
{
const listenToTransfer = (transfer: FileTransfer) => {
/* We've currently only support for channel files */
if(transfer.properties.channel_id === 0)
return;
const progressListener = event => events.fire("notify_transfer_progress", {
id: transfer.clientTransferId,
progress: event.progress.file_current_offset / event.progress.file_total_size,
status: "transferring",
fileSize: event.progress.file_current_offset
});
transfer.events.on("notify_progress", progressListener);
transfer.events.on("notify_state_updated", () => {
switch (transfer.transferState()) {
case FileTransferState.INITIALIZING:
case FileTransferState.PENDING:
case FileTransferState.CONNECTING:
events.fire("notify_transfer_status", {id: transfer.clientTransferId, status: "pending"});
break;
case FileTransferState.RUNNING:
events.fire("notify_transfer_status", { id: transfer.clientTransferId, status: "transferring", fileSize: transfer.transferProperties().fileSize });
break;
case FileTransferState.FINISHED:
case FileTransferState.CANCELED:
events.fire("notify_transfer_status", { id: transfer.clientTransferId, status: "finished" });
break;
case FileTransferState.ERRORED:
events.fire("notify_transfer_status", { id: transfer.clientTransferId, status: "errored" });
break;
}
if(transfer.isFinished()) {
unregisterEvents();
return;
}
});
events.fire("notify_transfer_start", {
id: transfer.clientTransferId,
name: transfer.properties.name,
path: "/" + channelPathPrefix + transfer.properties.channel_id + transfer.properties.path,
mode: transfer instanceof FileUploadTransfer ? "upload" : "download"
});
const closeListener = () => unregisterEvents();
events.on("notify_modal_closed", closeListener);
const unregisterEvents = () => {
events.off("notify_modal_closed", closeListener);
transfer.events.off("notify_progress", progressListener);
};
};
const registeredListener = event => listenToTransfer(event.transfer);
connection.fileManager.events.on("notify_transfer_registered", registeredListener);
events.on("notify_modal_closed", () => connection.fileManager.events.off("notify_transfer_registered", registeredListener));
connection.fileManager.registeredTransfers().forEach(transfer => listenToTransfer(transfer));
}
}

View File

@ -0,0 +1,300 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
.container {
z-index: 1;
position: absolute;
bottom: .75em;
left: 0;
right: 0;
top: 0;
display: flex;
flex-direction: column;
justify-content: stretch;
pointer-events: none;
@include user-select(none);
.overlay {
position: absolute;
background-color: #19191b;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-direction: row;
z-index: 1;
opacity: 1;
@include transition($button_hover_animation_time ease-in-out);
&.hidden {
opacity: 0;
pointer-events: none;
}
&.noTransfers, &.querying, &.error {
justify-content: center;
a {
color: #595959;
align-self: center;
font-size: 1.2em;
}
}
&.extended {
justify-content: flex-start;
}
}
.expendedContainer {
height: 100%;
margin-top: auto;
background-color: #19191b;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: stretch;
position: relative;
padding: 1em;
pointer-events: all;
@include transition($button_hover_animation_time ease-in-out);
&.hidden {
padding: 0;
height: 0;
}
.overlay {
a {
font-size: 1.4em;
}
}
.header {
display: flex;
flex-direction: row;
justify-content: flex-start;
padding-bottom: .5em;
a {
font-weight: bold;
color: #cccccc;
font-size: 1.05em;
}
button {
align-self: center;
margin-left: auto;
font-size: .7em;
}
border-bottom: 1px solid #393939;
}
.list {
display: flex;
flex-direction: column;
justify-content: flex-start;
position: relative;
overflow-y: auto;
overflow-x: hidden;
flex-shrink: 1;
flex-grow: 1;
min-height: 2em;
/* for the scroll bar */
padding-right: .5em;
margin-right: -.5em;
@include chat-scrollbar-vertical();
.noTransfers, .queryError, .querying {
position: absolute;
top: 0;
left: 0;
right: 0;
font-size: 1.2em;
padding: 1em;
display: flex;
flex-direction: row;
justify-content: center;
a {
align-self: center;
color: #595959;
}
}
}
}
.bottomContainer {
flex-shrink: 0;
flex-grow: 0;
height: 2em;
padding-left: 1em;
padding-right: 1em;
display: flex;
flex-direction: row;
justify-content: stretch;
pointer-events: all;
.info {
flex-grow: 1;
flex-shrink: 1;
height: 1.5em;
min-width: 1.5em;
position: relative;
.runningTransfers {
padding-right: 1em;
display: flex;
flex-direction: column;
justify-content: center;
}
}
.expansionContainer {
display: flex;
flex-direction: column;
justify-content: center;
cursor: pointer;
height: 1.5em;
width: 1.5em;
svg {
align-self: center;
height: 1.4em;
fill: hsla(0, 0%, 21%, 1);
@include transform(rotate(-180deg));
@include transition($button_hover_animation_time ease-in-out);
}
&.expended {
svg {
@include transform(rotate(-90deg));
@include transition($button_hover_animation_time ease-in-out);
}
}
&:hover {
svg {
fill: hsla(0, 0%, 25%, 1);
}
}
}
}
.transferEntryContainer {
margin-top: .5em;
position: relative;
flex-shrink: 0;
flex-grow: 0;
height: 3.5em;
overflow: hidden;
opacity: 1;
@include transition($button_hover_animation_time ease-in-out);
&.hidden {
margin-top: 0;
height: 0;
opacity: 0;
}
}
.transferEntry {
position: absolute;
display: flex;
flex-direction: row;
justify-content: flex-start;
background-color: #19191b;
z-index: 1;
top: 0;
left: 0;
right: 0;
height: 3.5em;
.image {
align-self: center;
flex-grow: 0;
flex-shrink: 0;
width: 3em;
height: 3em;
margin-left: .5em;
margin-right: 1em;
}
.info {
width: 100%;
flex-shrink: 1;
flex-grow: 1;
min-width: 2em;
display: flex;
flex-direction: column;
justify-content: flex-start;
.name {
margin-top: .2em;
line-height: 1em;
}
.path {
margin-top: .1em;
line-height: 1em;
font-size: .75em;
}
.status {
margin-top: .3em;
flex-grow: 0;
flex-shrink: 0;
> div {
font-size: .7em;
}
}
}
}
}

View File

@ -0,0 +1,478 @@
import * as React from "react";
import {useEffect, useRef, useState} from "react";
import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events";
import {
TransferStatus
} from "tc-shared/ui/modal/transfer/ModalFileTransfer";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {HTMLRenderer} from "tc-shared/ui/react-elements/HTMLRenderer";
import {ProgressBar} from "tc-shared/ui/react-elements/ProgressBar";
import {
TransferProgress,
} from "tc-shared/file/Transfer";
import {tra} from "tc-shared/i18n/localize";
import {format_time, network} from "tc-shared/ui/frames/chat";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {Checkbox} from "tc-shared/ui/react-elements/Checkbox";
import {Button} from "tc-shared/ui/react-elements/Button";
const cssStyle = require("./TransferInfo.scss");
const iconArrow = require("./icon_double_arrow.svg");
const iconTransferUpload = require("./icon_transfer_upload.svg");
const iconTransferDownload = require("./icon_transfer_download.svg");
export interface TransferInfoEvents {
query_transfers: {},
query_transfer_result: {
status: "success" | "error" | "timeout";
error?: string;
transfers?: TransferInfoData[],
showFinished?: boolean
}
action_toggle_expansion: { visible: boolean },
action_toggle_finished_transfers: { visible: boolean },
action_remove_finished: {},
notify_transfer_registered: { transfer: TransferInfoData },
notify_transfer_status: {
id: number,
status: TransferStatus,
error?: string
},
notify_transfer_progress: {
id: number;
status: TransferStatus,
progress: TransferProgress
},
notify_modal_closed: {}
}
export interface TransferInfoData {
id: number;
direction: "upload" | "download";
status: TransferStatus;
name: string;
path: string;
progress: number;
error?: string;
timestampRegistered: number;
timestampBegin: number;
timestampEnd: number;
transferredBytes: number;
}
const ExpendState = (props: { extended: boolean, events: Registry<TransferInfoEvents>}) => {
const [expended, setExpended] = useState(props.extended);
props.events.reactUse("action_toggle_expansion", event => setExpended(event.visible));
return <div className={cssStyle.expansionContainer + (expended ? " " + cssStyle.expended : "")} onClick={() => props.events.fire("action_toggle_expansion", { visible: !expended })}>
<HTMLRenderer purify={false}>{iconArrow}</HTMLRenderer>
</div>;
};
const ToggleFinishedTransfersCheckbox = (props: { events: Registry<TransferInfoEvents> }) => {
const ref = useRef<Checkbox>(null);
const [state, setState] = useState({ disabled: true, checked: false });
props.events.reactUse("action_toggle_finished_transfers", event => {
setState({
checked: event.visible,
disabled: false
});
ref.current?.setState({
checked: event.visible,
disabled: false
});
});
props.events.reactUse("query_transfer_result", event => {
if(event.status !== "success")
return;
setState({
checked: event.showFinished,
disabled: false
});
ref.current?.setState({
checked: event.showFinished,
disabled: false
});
});
return (
<Checkbox
ref={ref}
initialValue={state.checked}
disabled={state.disabled}
onChange={state => props.events.fire("action_toggle_finished_transfers", { visible: state })}
label={<Translatable>Show finished transfers</Translatable>} />
);
};
@ReactEventHandler<RunningTransfersInfo>(e => e.props.events)
class RunningTransfersInfo extends React.Component<{ events: Registry<TransferInfoEvents> }, { state: "error" | "querying" | "normal" }> {
private runningTransfers: { transfer: TransferInfoData, progress: TransferProgress | undefined }[] = [];
constructor(props) {
super(props);
this.state = {
state: "querying"
};
}
private currentStatistic() {
const progress = this.runningTransfers.map(e => e.progress).filter(e => !!e);
return {
totalBytes: progress.map(e => e.file_total_size).reduce((a, b) => a + b, 0),
currentOffset: progress.map(e => e.file_current_offset).reduce((a, b) => a + b, 0),
speed: progress.map(e => e.network_current_speed).reduce((a, b) => a + b, 0)
}
}
render() {
if(this.state.state === "querying") {
return (
<div key={"querying"} className={cssStyle.overlay + " " + cssStyle.querying}>
<a><Translatable>loading</Translatable> <LoadingDots maxDots={3} /></a>
</div>
);
} else if(this.state.state === "error") {
return (
<div key={"query-error"} className={cssStyle.overlay + " " + cssStyle.error}>
<a><Translatable>query error</Translatable></a>
</div>
);
} else if(this.runningTransfers.length === 0) {
return (
<div key={"no-transfers"} className={cssStyle.overlay + " " + cssStyle.noTransfers}>
<a><Translatable>No running transfers</Translatable></a>
</div>
);
}
const stats = this.currentStatistic();
const totalBytes = network.format_bytes(stats.totalBytes, { unit: "B", time: "", exact: false });
const currentOffset = network.format_bytes(stats.currentOffset, { unit: "B", time: "", exact: false });
const speed = network.format_bytes(stats.speed, { unit: "B", time: "second", exact: false });
return (
<div key={"running-transfers"} className={cssStyle.overlay + " " + cssStyle.runningTransfers}>
<ProgressBar value={stats.currentOffset * 100 / stats.totalBytes} type={"normal"} text={tra("Transferred {0} out of {1} total bytes ({2})", currentOffset, totalBytes, speed)} />
</div>
);
}
@EventHandler<TransferInfoEvents>("query_transfers")
private handleQueryTransfers() {
this.setState({ state: "querying" });
}
@EventHandler<TransferInfoEvents>("query_transfer_result")
private handleQueryTransferResult(event: TransferInfoEvents["query_transfer_result"]) {
this.setState({
state: event.status !== "success" ? "error" : "normal"
});
this.runningTransfers = (event.transfers || []).filter(e => e.status !== "finished" && e.status !== "errored").map(e => {
return {
progress: undefined,
transfer: e
}
});
}
@EventHandler<TransferInfoEvents>("notify_transfer_registered")
private handleTransferRegistered(event: TransferInfoEvents["notify_transfer_registered"]) {
this.runningTransfers.push({ transfer: event.transfer, progress: undefined });
this.forceUpdate();
}
@EventHandler<TransferInfoEvents>("notify_transfer_status")
private handleTransferStatus(event: TransferInfoEvents["notify_transfer_status"]) {
const index = this.runningTransfers.findIndex(e => e.transfer.id === event.id);
if(index === -1) return;
if(event.status === "finished" || event.status === "errored")
this.runningTransfers.splice(index, 1);
this.forceUpdate();
}
@EventHandler<TransferInfoEvents>("notify_transfer_progress")
private handleTransferProgress(event: TransferInfoEvents["notify_transfer_progress"]) {
const index = this.runningTransfers.findIndex(e => e.transfer.id === event.id);
if(index === -1) return;
this.runningTransfers[index].progress = event.progress;
this.forceUpdate();
}
}
const BottomTransferInfo = (props: { events: Registry<TransferInfoEvents> }) => {
const [extendedInfo, setExtendedInfo] = useState(false);
props.events.reactUse("action_toggle_expansion", event => setExtendedInfo(event.visible));
return (
<div className={cssStyle.info}>
<RunningTransfersInfo events={props.events} />
<div className={cssStyle.overlay + (extendedInfo ? "" : " " + cssStyle.hidden) + " " + cssStyle.extended} >
<ToggleFinishedTransfersCheckbox events={props.events} />
</div>
</div>
)
};
const BottomBar = (props: { events: Registry<TransferInfoEvents> }) => (
<div className={cssStyle.bottomContainer}>
<BottomTransferInfo events={props.events} />
<ExpendState extended={false} events={props.events} />
</div>
);
const TransferEntry = (props: { transfer: TransferInfoData, events: Registry<TransferInfoEvents>, finishedShown: boolean }) => {
const [finishedShown, setFinishedShown] = useState(props.finishedShown);
const [transferState, setTransferState] = useState<TransferStatus>(props.transfer.status);
const [finishAnimationFinished, setFinishAnimationFinished] = useState(props.transfer.status === "finished" || props.transfer.status === "errored");
const progressBar = useRef<ProgressBar>(null);
const progressBarText = (status: TransferStatus, info?: TransferProgress) => {
switch (status) {
case "errored":
return props.transfer.error ? tr("file transfer failed: ") + props.transfer.error : tr("file transfer failed");
case "finished":
const neededTime = format_time(props.transfer.timestampEnd - props.transfer.timestampBegin, tr("less than a second"));
const totalBytes = network.format_bytes(props.transfer.transferredBytes, { unit: "B", time: "", exact: false });
const speed = network.format_bytes(props.transfer.transferredBytes * 1000 / Math.max(props.transfer.timestampEnd - props.transfer.timestampBegin, 1000), { unit: "B", time: "second", exact: false });
return tra("transferred {0} in {1} ({2})", totalBytes, format_time(props.transfer.timestampEnd - props.transfer.timestampBegin, neededTime), speed);
case "pending":
return tr("pending");
case "none":
return tr("invalid state!");
case "transferring": {
if(!info) {
return tr("awaiting info");
}
const currentBytes = network.format_bytes(info.file_current_offset, { unit: "B", time: "", exact: false });
const totalBytes = network.format_bytes(info.file_total_size, { unit: "B", time: "", exact: false });
const speed = network.format_bytes(info.network_current_speed, { unit: "B", time: "second", exact: false });
return tra("transferred {0} out of {1} ({2})", currentBytes, totalBytes, speed);
}
}
return "";
};
const progressBarMode = (status: TransferStatus) => {
switch (status) {
case "errored":
return "error";
case "finished":
return "success";
default:
return "normal";
}
};
props.events.reactUse("notify_transfer_status", event => {
if(event.id !== props.transfer.id)
return;
setTransferState(event.status);
if(!progressBar.current)
return;
const pbState = {
text: progressBarText(event.status),
type: progressBarMode(event.status)
} as any;
if(event.status === "errored" || event.status === "finished") {
pbState.value = 100;
} else if(event.status === "none" || event.status === "pending")
pbState.value = 0;
progressBar.current.setState(pbState);
});
props.events.reactUse("notify_transfer_progress", event => {
if(event.id !== props.transfer.id || !progressBar.current)
return;
const pb = progressBar.current;
pb.setState({
text: progressBarText(event.status, event.progress),
type: progressBarMode(event.status),
value: event.status === "errored" || event.status === "finished" ? 100 : event.status === "pending" || event.status === "none" ? 0 : (event.progress.file_current_offset / event.progress.file_total_size) * 100
});
});
props.events.reactUse("action_toggle_finished_transfers", event => setFinishedShown(event.visible));
useEffect(() => {
if(finishAnimationFinished)
return;
if(transferState !== "finished" && transferState !== "errored")
return;
const id = setTimeout(() => setFinishAnimationFinished(true), 1500);
return () => clearTimeout(id);
});
let hidden = transferState === "finished" || transferState === "errored" ? !finishedShown && finishAnimationFinished : false;
return <div className={cssStyle.transferEntryContainer + (hidden ? " " + cssStyle.hidden : "")}>
<div className={cssStyle.transferEntry}>
<div className={cssStyle.image}>
<HTMLRenderer purify={false}>{props.transfer.direction === "upload" ? iconTransferUpload : iconTransferDownload}</HTMLRenderer>
</div>
<div className={cssStyle.info}>
<a className={cssStyle.name}>{props.transfer.name}</a>
<a className={cssStyle.path}>{props.transfer.path}</a>
<div className={cssStyle.status}>
<ProgressBar ref={progressBar} value={props.transfer.progress * 100} type={progressBarMode(transferState)} text={progressBarText(transferState)} />
</div>
</div>
</div>
</div>
};
@ReactEventHandler<TransferList>(e => e.props.events)
class TransferList extends React.PureComponent<{ events: Registry<TransferInfoEvents> }, { state: "loading" | "error" | "normal", error?: string }> {
private transfers: TransferInfoData[] = [];
private showFinishedTransfers: boolean = true;
constructor(props) {
super(props);
this.state = {
state: "loading"
}
}
render() {
const entries = [];
if(this.state.state === "error") {
entries.push(<div key={"query-error"} className={cssStyle.queryError}><a><Translatable>Failed to query the file transfers:</Translatable><br/>{this.state.error}</a></div>);
} else if(this.state.state === "loading") {
entries.push(<div key={"loading"} className={cssStyle.querying}><a><Translatable>loading</Translatable> <LoadingDots maxDots={3}/></a></div>);
} else {
this.transfers.forEach(e => {
entries.push(<TransferEntry finishedShown={this.showFinishedTransfers} key={"transfer-" + e.id} transfer={e} events={this.props.events} />);
});
entries.push(<div key={"no-transfers"} className={cssStyle.noTransfers}><a><Translatable>No transfers</Translatable></a></div>);
}
return (
<div className={cssStyle.list}>{entries}</div>
);
}
componentDidMount(): void {
this.props.events.fire("query_transfers");
}
@EventHandler<TransferInfoEvents>("action_toggle_finished_transfers")
private handleToggleFinishedTransfers(event: TransferInfoEvents["action_toggle_finished_transfers"]) {
this.showFinishedTransfers = event.visible;
}
@EventHandler<TransferInfoEvents>("action_remove_finished")
private handleRemoveFinishedTransfers() {
this.transfers = this.transfers.filter(e => e.status !== "finished" && e.status !== "errored");
this.forceUpdate();
}
@EventHandler<TransferInfoEvents>("query_transfers")
private handleQueryTransfers() {
this.setState({ state: "loading" });
}
@EventHandler<TransferInfoEvents>("query_transfer_result")
private handleQueryTransferResult(event: TransferInfoEvents["query_transfer_result"]) {
this.setState({
state: event.status === "success" ? "normal" : "error",
error: event.status === "timeout" ? tr("Request timed out") : event.error || tr("unknown error")
});
if(event.status === "success")
this.showFinishedTransfers = event.showFinished;
this.transfers = event.transfers || [];
this.transfers.sort((a, b) => b.timestampRegistered - a.timestampRegistered);
}
@EventHandler<TransferInfoEvents>("notify_transfer_registered")
private handleTransferRegistered(event: TransferInfoEvents["notify_transfer_registered"]) {
this.transfers.splice(0, 0, event.transfer);
this.forceUpdate();
}
@EventHandler<TransferInfoEvents>("notify_transfer_status")
private handleTransferStatus(event: TransferInfoEvents["notify_transfer_status"]) {
const transfer = this.transfers.find(e => e.id === event.id);
if(!transfer) return;
switch (event.status) {
case "finished":
case "errored":
case "none":
transfer.timestampEnd = Date.now();
break;
case "transferring":
if(transfer.timestampBegin === 0)
transfer.timestampBegin = Date.now();
}
transfer.status = event.status;
transfer.error = event.error;
}
@EventHandler<TransferInfoEvents>("notify_transfer_progress")
private handleTransferProgress(event: TransferInfoEvents["notify_transfer_progress"]) {
const transfer = this.transfers.find(e => e.id === event.id);
if(!transfer) return;
transfer.progress = event.progress.file_current_offset / event.progress.file_total_size;
transfer.status = event.status;
transfer.transferredBytes = event.progress.file_bytes_transferred;
}
}
const ExtendedInfo = (props: { events: Registry<TransferInfoEvents> }) => {
const [expended, setExpended] = useState(false);
const [finishedShown, setFinishedShown] = useState(true);
props.events.reactUse("action_toggle_expansion", event => setExpended(event.visible));
props.events.reactUse("action_toggle_finished_transfers", event => setFinishedShown(event.visible));
props.events.reactUse("query_transfer_result", event => event.status === "success" && setFinishedShown(event.showFinished));
return <div className={cssStyle.expendedContainer + (expended ? "" : " " + cssStyle.hidden)} >
<div className={cssStyle.header}>
<a>{finishedShown ? <Translatable key={"file-transfers"}>File transfers</Translatable> : <Translatable key={"running-file-transfers"}>Running file transfers</Translatable>}</a>
<Button disabled={!finishedShown} color={"blue"} onClick={() => props.events.fire("action_remove_finished")}><Translatable>Remove finished</Translatable></Button>
</div>
<TransferList events={props.events} />
</div>;
};
export const TransferInfo = (props: { events: Registry<TransferInfoEvents> }) => (
<div className={cssStyle.container} >
<ExtendedInfo events={props.events} />
<BottomBar events={props.events} />
</div>
);

View File

@ -0,0 +1,142 @@
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {Registry} from "tc-shared/events";
import {
FileTransfer,
FileTransferDirection,
FileTransferState,
TransferProgress,
TransferProperties
} from "tc-shared/file/Transfer";
import {
avatarsPathPrefix,
channelPathPrefix,
iconPathPrefix,
TransferStatus
} from "tc-shared/ui/modal/transfer/ModalFileTransfer";
import {Settings, settings} from "tc-shared/settings";
import {TransferInfoData, TransferInfoEvents} from "tc-shared/ui/modal/transfer/TransferInfo";
export const initializeTransferInfoController = (connection: ConnectionHandler, events: Registry<TransferInfoEvents>) => {
const generateTransferPath = (properties: TransferProperties) => {
let path;
if(properties.channel_id !== 0) {
path = "/" + channelPathPrefix + properties.channel_id + properties.path;
} else if(properties.name.startsWith("/avatar_")) {
path = "/" + avatarsPathPrefix + "/";
} else {
path = "/" + iconPathPrefix + "/";
}
return path;
};
const getTransferStatus = (transfer: FileTransfer) : TransferStatus => {
switch (transfer.transferState()) {
case FileTransferState.INITIALIZING:
case FileTransferState.PENDING:
case FileTransferState.CONNECTING:
return "pending";
case FileTransferState.RUNNING:
return "transferring";
case FileTransferState.FINISHED:
case FileTransferState.CANCELED:
return "finished";
case FileTransferState.ERRORED:
return "errored";
}
};
const generateTransferInfo = (transfer: FileTransfer): TransferInfoData => {
return {
id: transfer.clientTransferId,
direction: transfer.direction === FileTransferDirection.UPLOAD ? "upload" : "download",
progress: 0,
name: transfer.properties.name,
path: generateTransferPath(transfer.properties),
status: getTransferStatus(transfer),
error: transfer.currentErrorMessage(),
timestampRegistered: transfer.timings.timestampScheduled,
timestampBegin: transfer.timings.timestampTransferBegin,
timestampEnd: transfer.timings.timestampEnd,
transferredBytes: transfer.lastProgressInfo() ? transfer.lastProgressInfo().file_current_offset - transfer.lastProgressInfo().file_start_offset : 0
};
};
events.on("action_toggle_finished_transfers", event => {
settings.changeGlobal(Settings.KEY_TRANSFERS_SHOW_FINISHED, event.visible);
});
events.on("action_remove_finished", () => {
connection.fileManager.finishedTransfers.splice(0, connection.fileManager.finishedTransfers.length);
});
events.on("query_transfers", () => {
const transfers: TransferInfoData[] = connection.fileManager.registeredTransfers().map(generateTransferInfo);
transfers.push(...connection.fileManager.finishedTransfers.map(e => {
return {
id: e.clientTransferId,
direction: e.direction === FileTransferDirection.UPLOAD ? "upload" : "download",
progress: 100,
name: e.properties.name,
path: generateTransferPath(e.properties),
status: e.state === FileTransferState.FINISHED ? "finished" : "errored",
error: e.transferErrorMessage,
timestampRegistered: e.timings.timestampScheduled,
timestampBegin: e.timings.timestampTransferBegin,
timestampEnd: e.timings.timestampEnd,
transferredBytes: e.bytesTransferred
} as TransferInfoData;
}));
events.fire_async("query_transfer_result", {
status: "success",
transfers: transfers,
showFinished: settings.global(Settings.KEY_TRANSFERS_SHOW_FINISHED)
});
});
/* the active transfer listener */
{
const listenToTransfer = (transfer: FileTransfer) => {
const fireProgress = (progress: TransferProgress) => events.fire("notify_transfer_progress", {
id: transfer.clientTransferId,
progress: progress,
status: "transferring",
});
const progressListener = (event: {progress: TransferProgress}) => fireProgress(event.progress);
transfer.events.on("notify_progress", progressListener);
transfer.events.on("notify_state_updated", () => {
const status = getTransferStatus(transfer);
if(transfer.lastProgressInfo()) fireProgress(transfer.lastProgressInfo()); /* fire the progress info at least once */
events.fire("notify_transfer_status", { id: transfer.clientTransferId, status: status, error: transfer.currentErrorMessage() });
if(transfer.isFinished()) {
unregisterEvents();
return;
}
});
events.fire("notify_transfer_registered", { transfer: generateTransferInfo(transfer) });
const closeListener = () => unregisterEvents();
events.on("notify_modal_closed", closeListener);
const unregisterEvents = () => {
events.off("notify_modal_closed", closeListener);
transfer.events.off("notify_progress", progressListener);
};
};
const registeredListener = event => listenToTransfer(event.transfer);
connection.fileManager.events.on("notify_transfer_registered", registeredListener);
events.on("notify_modal_closed", () => connection.fileManager.events.off("notify_transfer_registered", registeredListener));
connection.fileManager.registeredTransfers().forEach(transfer => listenToTransfer(transfer));
}
};

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="898.3px" height="898.3px" viewBox="0 0 898.3 898.3" style="enable-background:new 0 0 898.3 898.3;" xml:space="preserve"
>
<g>
<polygon points="120.2,882.5 553.6,449.2 120.2,15.8 0,136 313.2,449.2 0,762.3"/>
<polygon points="344.7,762.3 464.9,882.5 898.3,449.2 464.9,15.8 344.7,136 657.9,449.2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 584 B

View File

@ -0,0 +1,10 @@
<svg width="512" height="512" enable-background="new 0 0 24 24" version="1.1" viewBox="0 0 24 24" fill="#7289da" xmlns="http://www.w3.org/2000/svg">
<g transform="rotate(180 12 4.313)">
<path d="m14.25 3.75c-.192 0-.384-.073-.53-.22l-1.72-1.719-1.72 1.72c-.293.293-.768.293-1.061 0s-.293-.768 0-1.061l2.25-2.25c.293-.293.768-.293 1.061 0l2.25 2.25c.293.293.293.768 0 1.061-.146.146-.338.219-.53.219z"/>
</g>
<path d="m15.25 11h-6.5c-.965 0-1.75-.785-1.75-1.75v-1.5c0-.414.336-.75.75-.75s.75.336.75.75v1.5c0 .138.112.25.25.25h6.5c.138 0 .25-.112.25-.25v-1.5c0-.414.336-.75.75-.75s.75.336.75.75v1.5c0 .965-.785 1.75-1.75 1.75z"/>
<path d="m12 8c-.414 0-.75-.336-.75-.75v-6.25c0-.414.336-.75.75-.75s.75.336.75.75v6.25c0 .414-.336.75-.75.75z"/>
<path d="m22.25 21h-20.5c-.965 0-1.75-.785-1.75-1.75v-13.5c0-.965.785-1.75 1.75-1.75h3.5c.415 0 .75.336.75.75s-.335.75-.75.75h-3.5c-.138 0-.25.112-.25.25v13.5c0 .138.112.25.25.25h20.5c.138 0 .25-.112.25-.25v-13.5c0-.138-.112-.25-.25-.25h-3.5c-.414 0-.75-.336-.75-.75s.336-.75.75-.75h3.5c.965 0 1.75.785 1.75 1.75v13.5c0 .965-.785 1.75-1.75 1.75z"/>
<path d="m23.25 17.5h-22.5c-.414 0-.75-.336-.75-.75s.336-.75.75-.75h22.5c.414 0 .75.336.75.75s-.336.75-.75.75z"/>
<path d="m15.25 24h-6.5c-.303 0-.577-.183-.693-.463s-.052-.602.163-.817c1.041-1.041 1.265-2.553 1.267-2.567.054-.411.432-.704.841-.646.411.054.7.431.646.841-.008.059-.147 1.066-.744 2.152h3.523c-.591-1.08-.738-2.088-.746-2.147-.057-.41.229-.788.64-.846.414-.058.788.229.846.639.007.045.232 1.469 1.184 2.487.195.136.323.361.323.617 0 .414-.336.75-.75.75z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,21 @@
<svg enable-background="new 0 0 24 24" height="512" viewBox="0 0 24 24" width="512" fill="#7289da"
xmlns="http://www.w3.org/2000/svg">
<g>
<path d="m15.25 11h-6.5c-.965 0-1.75-.785-1.75-1.75v-1.5c0-.414.336-.75.75-.75s.75.336.75.75v1.5c0 .138.112.25.25.25h6.5c.138 0 .25-.112.25-.25v-1.5c0-.414.336-.75.75-.75s.75.336.75.75v1.5c0 .965-.785 1.75-1.75 1.75z"/>
</g>
<g>
<path d="m12 8c-.414 0-.75-.336-.75-.75v-6.25c0-.414.336-.75.75-.75s.75.336.75.75v6.25c0 .414-.336.75-.75.75z"/>
</g>
<g>
<path d="m14.25 3.75c-.192 0-.384-.073-.53-.22l-1.72-1.719-1.72 1.72c-.293.293-.768.293-1.061 0s-.293-.768 0-1.061l2.25-2.25c.293-.293.768-.293 1.061 0l2.25 2.25c.293.293.293.768 0 1.061-.146.146-.338.219-.53.219z"/>
</g>
<g>
<path d="m22.25 21h-20.5c-.965 0-1.75-.785-1.75-1.75v-13.5c0-.965.785-1.75 1.75-1.75h3.5c.415 0 .75.336.75.75s-.335.75-.75.75h-3.5c-.138 0-.25.112-.25.25v13.5c0 .138.112.25.25.25h20.5c.138 0 .25-.112.25-.25v-13.5c0-.138-.112-.25-.25-.25h-3.5c-.414 0-.75-.336-.75-.75s.336-.75.75-.75h3.5c.965 0 1.75.785 1.75 1.75v13.5c0 .965-.785 1.75-1.75 1.75z"/>
</g>
<g>
<path d="m23.25 17.5h-22.5c-.414 0-.75-.336-.75-.75s.336-.75.75-.75h22.5c.414 0 .75.336.75.75s-.336.75-.75.75z"/>
</g>
<g>
<path d="m15.25 24h-6.5c-.303 0-.577-.183-.693-.463s-.052-.602.163-.817c1.041-1.041 1.265-2.553 1.267-2.567.054-.411.432-.704.841-.646.411.054.7.431.646.841-.008.059-.147 1.066-.744 2.152h3.523c-.591-1.08-.738-2.088-.746-2.147-.057-.41.229-.788.64-.846.414-.058.788.229.846.639.007.045.232 1.469 1.184 2.487.195.136.323.361.323.617 0 .414-.336.75-.75.75z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -1,8 +1,9 @@
import * as React from "react";
import {ReactElement} from "react";
const cssStyle = require("./Checkbox.scss");
export interface CheckboxProperties {
label?: string;
label?: ReactElement | string;
disabled?: boolean;
onChange?: (value: boolean) => void;
initialValue?: boolean;
@ -11,7 +12,8 @@ export interface CheckboxProperties {
}
export interface CheckboxState {
checked: boolean;
checked?: boolean;
disabled?: boolean;
}
export class Checkbox extends React.Component<CheckboxProperties, CheckboxState> {
@ -19,17 +21,20 @@ export class Checkbox extends React.Component<CheckboxProperties, CheckboxState>
super(props);
this.state = {
checked: this.props.initialValue
checked: this.props.initialValue,
disabled: this.props.disabled
};
}
render() {
const disabledClass = this.props.disabled ? cssStyle.disabled : "";
const disabled = typeof this.state.disabled === "boolean" ? this.state.disabled : this.props.disabled;
const checked = typeof this.state.checked === "boolean" ? this.state.checked : this.props.initialValue;
const disabledClass = disabled ? cssStyle.disabled : "";
return (
<label className={cssStyle.labelCheckbox + " " + disabledClass}>
<div className={cssStyle.checkbox + " " + disabledClass}>
<input type={"checkbox"} checked={this.state.checked} disabled={this.props.disabled} onChange={() => this.onStateChange()} />
<input type={"checkbox"} checked={checked} disabled={disabled} onChange={() => this.onStateChange()} />
<div className={cssStyle.mark} />
</div>
{this.props.label ? <a>{this.props.label}</a> : undefined}

View File

@ -0,0 +1,60 @@
import * as React from "react";
import * as purify from "dompurify";
/*
export const HTMLRenderer = (props: { purify: boolean, children: string }) => {
const html = props.purify ? purify.sanitize(props.children) : props.children;
return <span dangerouslySetInnerHTML={{ __html: html }} />
};
*/
export class HTMLRenderer extends React.PureComponent<{ purify: boolean, children: string }, {}> {
private readonly reference = React.createRef<HTMLSpanElement>();
private readonly newNodes: Element[];
private originalNode: HTMLSpanElement;
constructor(props) {
super(props);
const html = this.props.purify ? purify.sanitize(this.props.children) : this.props.children;
const node = document.createElement("div");
node.innerHTML = html;
this.newNodes = [...node.children];
}
render() {
if(this.newNodes.length === 0)
return null;
return <span ref={this.reference} />;
}
componentDidMount(): void {
if(this.newNodes.length === 0)
return;
this.originalNode = this.reference.current;
this.originalNode.replaceWith(this.newNodes[0]);
this.newNodes.forEach((node, index, array) => {
if(index === 0) return;
node.after(array[index - 1]);
});
}
componentWillUnmount(): void {
if(this.newNodes.length === 0)
return;
this.newNodes.forEach((node, index) => {
if(index === 0) return;
node.remove();
});
this.newNodes[0].replaceWith(this.originalNode);
}
}

View File

@ -46,7 +46,7 @@ export class LocalIconRenderer extends React.Component<LoadedIconRenderer, {}> {
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>;
return <div key={"icon"} className={"icon-container"}><img style={{ maxWidth: "100%", maxHeight: "100%" }} 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")

View File

@ -0,0 +1,125 @@
@import "../../../css/static/mixin";
@import "../../../css/static/properties";
.container {
border-radius: .2em;
border: 1px solid #111112;
background-color: #121213;
display: flex;
flex-direction: row;
justify-content: stretch;
color: #b3b3b3;
&.size-normal {
height: 2em;
}
&.size-large {
height: 2.5em;
}
&.size-small {
height: 1.7em;
}
@include placeholder(&) {
color: #606060;
};
.prefix {
flex-grow: 0;
flex-shrink: 0;
margin: 0;
line-height: initial;
align-self: center;
padding: 0 .5em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 1;
@include transition($button_hover_animation_time ease-in-out);
}
&.is-invalid {
background-color: #180d0d;
border-color: #721c1c;
background-image: unset!important;
}
&:focus, &:focus-within {
background-color: #131b22;
border-color: #284262;
color: #e1e2e3;
.prefix {
width: 0;
padding-left: 0;
padding-right: 0;
opacity: 0;
}
}
input, select, .inputBox {
flex-grow: 1;
flex-shrink: 1;
background: transparent;
border: none;
outline: none;
margin: 0;
color: #b3b3b3;
min-width: 2em;
&.editable {
cursor: text;
}
}
.inputBox {
display: flex;
flex-direction: column;
justify-content: center;
}
.prefix + input {
padding-left: 0;
}
&:focus, &:focus-within {
.prefix + input {
padding-left: .5em;
}
}
&.disabled, &:disabled {
background-color: #1a1819;
}
&.noRightIcon {
input, select {
padding-right: .5em;
}
}
&.noLeftIcon {
input, select {
padding-left: .5em;
}
}
@include transition($button_hover_animation_time ease-in-out);
}

View File

@ -0,0 +1,105 @@
import * as React from "react";
import {ReactElement} from "react";
const cssStyle = require("./InputField.scss");
export interface BoxedInputFieldProperties {
prefix?: string;
placeholder?: string;
disabled?: boolean;
editable?: boolean;
defaultValue?: string;
rightIcon?: () => ReactElement;
leftIcon?: () => ReactElement;
inputBox?: () => ReactElement; /* if set the onChange and onInput will not work anymore! */
isInvalid?: boolean;
className?: string;
size?: "normal" | "large" | "small";
onFocus?: () => void;
onBlur?: () => void;
onChange?: (newValue: string) => void;
onInput?: (newValue: string) => void;
}
export interface BoxedInputFieldState {
disabled?: boolean;
defaultValue?: string;
isInvalid?: boolean;
value?: string;
}
export class BoxedInputField extends React.Component<BoxedInputFieldProperties, BoxedInputFieldState> {
private refInput = React.createRef<HTMLInputElement>();
private inputEdited = false;
constructor(props) {
super(props);
this.state = {};
}
render() {
return (
<div
draggable={false}
className={
cssStyle.container + " " +
cssStyle["size-" + (this.props.size || "normal")] +
(this.state.disabled || this.props.disabled ? cssStyle.disabled : "") + " " +
(this.state.isInvalid || this.props.isInvalid ? cssStyle.isInvalid : "") + " " +
(this.props.leftIcon ? "" : cssStyle.noLeftIcon) + " " +
(this.props.rightIcon ? "" : cssStyle.noRightIcon) + " " +
this.props.className
}
onFocus={this.props.onFocus}
onBlur={() => this.onInputBlur()}
>
{this.props.leftIcon ? this.props.leftIcon() : ""}
{this.props.prefix ? <a key={"prefix"} className={cssStyle.prefix}>{this.props.prefix}</a> : undefined}
{this.props.inputBox ?
<span key={"custom-input"} className={cssStyle.inputBox + " " + (this.props.editable ? cssStyle.editable : "")} onClick={this.props.onFocus}>{this.props.inputBox()}</span> :
<input key={"input"}
ref={this.refInput}
value={this.state.value}
defaultValue={this.state.defaultValue || this.props.defaultValue}
placeholder={this.props.placeholder}
readOnly={typeof this.props.editable === "boolean" ? this.props.editable : false}
disabled={this.state.disabled || this.props.disabled}
onInput={this.props.onInput && (event => this.props.onInput(event.currentTarget.value))}
onKeyDown={e => this.onKeyDown(e)}
/>}
{this.props.rightIcon ? this.props.rightIcon() : ""}
</div>
)
}
focusInput() {
this.refInput.current?.focus();
}
private onKeyDown(event: React.KeyboardEvent) {
this.inputEdited = true;
if(event.key === "Enter")
this.refInput.current?.blur();
}
private onInputBlur() {
if(this.props.onChange && this.inputEdited) {
this.inputEdited = false;
this.props.onChange(this.refInput.current.value);
}
if(this.props.onBlur)
this.props.onBlur();
}
}

View File

@ -0,0 +1,19 @@
import {useEffect, useState} from "react";
import * as React from "react";
export const LoadingDots = (props: { maxDots?: number, speed?: number }) => {
if(!props.maxDots || props.maxDots < 1)
props.maxDots = 3;
const [dots, setDots] = useState(0);
useEffect(() => {
const timeout = setTimeout(() => setDots(dots + 1), props.speed || 500);
return () => clearTimeout(timeout);
});
let result = ".";
for(let index = 0; index < dots % props.maxDots; index++)
result += ".";
return <div style={{ width: (props.maxDots / 3) + "em", display: "inline-block", textAlign: "left" }}>{result}</div>;
};

View File

@ -21,9 +21,9 @@ export enum ModalState {
DESTROYED
}
export class ModalController {
export class ModalController<InstanceType extends Modal = Modal> {
readonly events: Registry<ModalEvents>;
readonly modalInstance: Modal;
readonly modalInstance: InstanceType;
private initializedPromise: Promise<void>;
@ -31,7 +31,7 @@ export class ModalController {
private refModal: React.RefObject<ModalImpl>;
private modalState_: ModalState = ModalState.HIDDEN;
constructor(instance: Modal) {
constructor(instance: InstanceType) {
this.modalInstance = instance;
instance["__modal_controller"] = this;
@ -170,6 +170,6 @@ class ModalImpl extends React.PureComponent<{ controller: ModalController }, {
}
}
export function spawnReactModal<ModalClass extends Modal>(modalClass: new () => ModalClass) : ModalController {
return new ModalController(new modalClass());
export function spawnReactModal<ModalClass extends Modal, T>(modalClass: new (T) => ModalClass, properties?: T) : ModalController<ModalClass> {
return new ModalController(new modalClass(properties));
}

View File

@ -0,0 +1,53 @@
@import "../../../css/static/mixin";
@import "../../../css/static/properties";
.container {
position: relative;
display: flex;
flex-direction: row;
justify-content: center;
height: 1.4em;
border-radius: 0.2em;
overflow: hidden;
background-color: #242527;
-webkit-box-shadow: inset 0 0 2px 0 rgba(0, 0, 0, 0.75);
-moz-box-shadow: inset 0 0 2px 0 rgba(0, 0, 0, 0.75);
box-shadow: inset 0 0 2px 0 rgba(0, 0, 0, 0.75);
.filler {
position: absolute;
top: 0;
left: 0;
bottom: 0;
@include transition($button_hover_animation_time ease-in-out);
}
.text {
align-self: center;
z-index: 1;
}
&.type-normal {
.filler {
background-color: #4370a299;
}
}
&.type-error {
.filler {
background-color: #a1000099;
}
}
&.type-success {
.filler {
background-color: #2b854199;
}
}
}

View File

@ -0,0 +1,36 @@
import * as React from "react";
import {ReactElement} from "react";
const cssStyle = require("./ProgressBar.scss");
export interface ProgressBarState {
value?: number; /* [0;100] */
text?: ReactElement | string;
type?: "normal" | "error" | "success";
}
export interface ProgressBarProperties {
value: number; /* [0;100] */
text?: ReactElement | string;
type: "normal" | "error" | "success";
className?: string;
}
export class ProgressBar extends React.Component<ProgressBarProperties, ProgressBarState> {
constructor(props) {
super(props);
this.state = {};
}
render() {
return (
<div className={cssStyle.container + " " + cssStyle["type-" + (typeof this.state.type === "undefined" ? this.props.type : this.state.type)] + " " + (this.props.className || "")}>
<div className={cssStyle.filler} style={{width: (typeof this.state.value === "number" ? this.state.value : this.props.value) + "%"}} />
<div className={cssStyle.text}>
{typeof this.state.text !== "undefined" ? this.state.text : this.props.text}
</div>
</div>
)
}
}

View File

@ -0,0 +1,73 @@
.container {
position: relative;
display: flex;
flex-direction: column;
justify-content: stretch;
min-height: 5em;
.dynamicColumn {
flex-grow: 1;
flex-shrink: 1;
min-width: 2em;
}
.fixedColumn {
flex-grow: 0;
flex-shrink: 0;
}
.header {
position: absolute;
top: 0;
left: 0;
right: 0;
flex-shrink: 0;
flex-grow: 0;
display: flex;
flex-direction: row;
justify-content: stretch;
}
.body {
flex-shrink: 1;
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
min-height: 3em;
overflow-y: scroll;
overflow-x: hidden;
.headerSpacer {
flex-shrink: 0;
flex-grow: 0;
display: flex;
flex-direction: row;
justify-content: stretch;
visibility: hidden;
}
.row {
position: relative;
width: 100%;
display: flex;
flex-direction: row;
justify-content: stretch;
flex-shrink: 0;
flex-grow: 0;
}
}
}

View File

@ -0,0 +1,196 @@
import * as React from "react";
import {ReactElement} from "react";
const cssStyle = require("./Table.scss");
export interface TableColumn {
name: string;
header: () => ReactElement | ReactElement[];
width?: number;
fixedWidth?: string;
className?: string;
}
export interface TableRow<T = any> {
columns: {[key: string]: () => ReactElement | ReactElement[]};
className?: string;
userData?: T;
}
export interface TableProperties {
columns: TableColumn[];
rows: TableRow[];
className?: string;
headerClassName?: string;
bodyClassName?: string;
bodyOverlayOnly?: boolean;
bodyOverlay?: () => ReactElement;
hiddenColumns?: string[];
onHeaderContextMenu?: (event: React.MouseEvent) => void;
onBodyContextMenu?: (event: React.MouseEvent) => void;
renderRow?: (row: TableRow, columns: TableColumn[], uniqueId: string) => React.ReactElement<TableRowElement>;
}
export interface TableState {
hiddenColumns: string[];
}
export interface TableRowProperties {
columns: TableColumn[];
rowData: TableRow;
}
export interface TableRowState {
hidden?: boolean;
}
export class TableRowElement extends React.Component<TableRowProperties & React.HTMLProps<HTMLDivElement>, TableRowState> {
constructor(props) {
super(props);
this.state = {};
}
render() {
if(this.state.hidden)
return null;
let totalWidth = this.props.columns.map(e => e.width | 0).reduce((a, b) => a + b, 0);
if(totalWidth === 0)
totalWidth = 1;
const properties = Object.assign({}, this.props) as any;
delete properties.rowData;
delete properties.columns;
properties.className = (properties.className || "") + " " + cssStyle.row;
const children = Array.isArray(this.props.children) ? this.props.children : typeof this.props.children !== "undefined" ? [this.props.children] : [];
return React.createElement("div", properties, ...this.props.columns.map(column => {
const supplier = this.props.rowData.columns[column.name];
if(column.width) {
return (
<div key={"tr-" + column.name}
className={cssStyle.dynamicColumn + " " + (column.className || "")}
style={{width: (column.width * 100 / totalWidth) + "%"}}>
{supplier ? supplier() : undefined}
</div>
);
} else if(column.fixedWidth) {
return (
<div key={"th-" + column.name}
className={cssStyle.fixedColumn + " " + (column.className || "")}
style={{width: column.fixedWidth}}>
{supplier ? supplier() : undefined}
</div>
);
}
}), ...children);
}
}
export class Table extends React.Component<TableProperties, TableState> {
private rowIndex = 0;
private refHeader = React.createRef<HTMLDivElement>();
private refHiddenHeader = React.createRef<HTMLDivElement>();
private refBody = React.createRef<HTMLDivElement>();
private lastHeaderHeight = 20;
private lastScrollbarWidth = 20;
constructor(props) {
super(props);
this.state = {
hiddenColumns: this.props.hiddenColumns || []
};
}
render() {
const columns = this.props.columns.filter(e => this.state.hiddenColumns.findIndex(b => e.name === b) === -1);
let totalWidth = columns.map(e => e.width | 0).reduce((a, b) => a + b, 0);
if(totalWidth === 0)
totalWidth = 1;
const rowRenderer = this.props.renderRow || ((row, columns, uniqueId) => {
return <TableRowElement key={uniqueId} rowData={row} columns={columns} />;
});
let body;
if(this.props.bodyOverlayOnly) {
body = this.props.bodyOverlay ? this.props.bodyOverlay() : undefined;
} else {
body = this.props.rows.map((row: TableRow & { __rowIndex: number }) => {
if(typeof row.__rowIndex !== "number")
row.__rowIndex = ++this.rowIndex;
return rowRenderer(row, columns, "tr-" + row.__rowIndex);
});
if(this.props.bodyOverlay)
body.push(this.props.bodyOverlay());
}
return (
<div className={cssStyle.container + " " + (this.props.className || " ")}>
<div
ref={this.refHeader}
className={cssStyle.header + " " + (this.props.headerClassName || " ")}
style={{right: this.lastScrollbarWidth}}
onContextMenu={event => this.props.onHeaderContextMenu && this.props.onHeaderContextMenu(event)}
>
{columns.map(column => {
if(column.width) {
return (
<div key={"th-" + column.name}
className={cssStyle.dynamicColumn + " " + (column.className || "")}
style={{width: (column.width * 100 / totalWidth) + "%"}}
>
{column.header()}
</div>
);
} else if(column.fixedWidth) {
return (
<div key={"th-" + column.name}
className={cssStyle.fixedColumn + " " + (column.className || "")}
style={{width: column.fixedWidth}}
>
{column.header()}
</div>
);
}
})}
</div>
<div
className={cssStyle.body + " " + (this.props.bodyClassName || " ")}
ref={this.refBody}
onContextMenu={e => this.props.onBodyContextMenu && this.props.onBodyContextMenu(e)}
>
<div ref={this.refHiddenHeader} style={{height: this.lastHeaderHeight}} className={cssStyle.row} />
{body}
</div>
</div>
);
}
componentDidUpdate(prevProps: Readonly<TableProperties>, prevState: Readonly<TableState>, snapshot?: any): void {
if(!this.refHiddenHeader.current || !this.refHeader.current || !this.refBody.current)
return;
setTimeout(() => {
this.lastHeaderHeight = this.refHeader.current.clientHeight;
this.lastScrollbarWidth = this.refBody.current.parentElement.clientWidth - this.refBody.current.clientWidth;
this.refHiddenHeader.current.style.height = this.lastHeaderHeight + "px";
this.refHeader.current.style.right = this.lastScrollbarWidth + "px";
}, 10);
}
}

View File

@ -9,6 +9,7 @@ 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 {spawnFileTransferModal} from "tc-shared/ui/modal/transfer/ModalFileTransfer";
const channelStyle = require("./Channel.scss");
const viewStyle = require("./View.scss");
@ -247,6 +248,7 @@ export class ChannelEntryView extends TreeEntry<ChannelEntryViewProperties, {}>
onMouseUp={e => this.onMouseUp(e)}
onDoubleClick={() => this.onDoubleClick()}
onContextMenu={e => this.onContextMenu(e)}
onMouseDown={e => this.onMouseDown(e)}
>
<UnreadMarker entry={this.props.channel} />
{collapsed_indicator && <ChannelCollapsedIndicator key={"collapsed-indicator"} onToggle={() => this.onCollapsedToggle()} collapsed={this.props.channel.collapsed} />}
@ -279,6 +281,13 @@ export class ChannelEntryView extends TreeEntry<ChannelEntryViewProperties, {}>
channel.joinChannel();
}
private onMouseDown(event: React.MouseEvent) {
if(event.buttons !== 4)
return;
spawnFileTransferModal(this.props.channel.getChannelId());
}
private onContextMenu(event: React.MouseEvent) {
if(settings.static(Settings.KEY_DISABLE_CONTEXT_MENU))
return;

File diff suppressed because it is too large Load Diff

335
web/js/FileTransfer.ts Normal file
View File

@ -0,0 +1,335 @@
import {
BrowserFileTransferSource,
BufferTransferSource,
DownloadTransferTarget,
FileDownloadTransfer,
FileTransfer,
FileTransferState,
FileUploadTransfer,
ResponseTransferTarget,
TextTransferSource,
TransferProvider,
TransferSourceType,
TransferTargetType
} from "tc-shared/file/Transfer";
import * as log from "tc-shared/log";
import {LogCategory} from "tc-shared/log";
TransferProvider.setProvider(new class extends TransferProvider {
executeFileUpload(transfer: FileUploadTransfer) {
try {
if(!transfer.source) throw tr("transfer source is undefined");
let response: Promise<void>;
transfer.setTransferState(FileTransferState.CONNECTING);
if(transfer.source instanceof BrowserFileTransferSourceImpl) {
response = formDataUpload(transfer, transfer.source.getFile());
} else if(transfer.source instanceof BufferTransferSourceImpl) {
response = formDataUpload(transfer, transfer.source.getBuffer());
} else if(transfer.source instanceof TextTransferSourceImpl) {
response = formDataUpload(transfer, transfer.source.getArrayBuffer());
} else {
transfer.setFailed({
error: "io",
reason: "unsupported-target"
}, tr("invalid source type"));
return;
}
/* let the server notify us when the transfer has been finished */
response.catch(error => {
if(typeof error !== "string")
log.error(LogCategory.FILE_TRANSFER, tr("Failed to upload object via HTTPS connection: %o"), error);
transfer.setFailed({
error: "connection",
reason: "network-error",
extraMessage: typeof error === "string" ? error : tr("Lookup the console")
}, typeof error === "string" ? error : tr("Lookup the console"));
});
} catch (error) {
if(typeof error !== "string")
log.error(LogCategory.FILE_TRANSFER, tr("Failed to initialize transfer source: %o"), error);
transfer.setFailed({
error: "io",
reason: "failed-to-initialize-target",
extraMessage: typeof error === "string" ? error : tr("Lookup the console")
}, typeof error === "string" ? error : tr("Lookup the console"));
}
}
executeFileDownload(transfer: FileDownloadTransfer) {
transfer.targetSupplier(transfer).then(target => {
if(!target) throw tr("transfer target is undefined");
transfer.target = target;
let response: Promise<void>;
transfer.setTransferState(FileTransferState.CONNECTING);
if(target instanceof ResponseTransferTargetImpl) {
response = responseFileDownload(transfer, target);
} else if(target instanceof DownloadTransferTargetImpl) {
response = downloadFileDownload(transfer, target);
} else {
transfer.setFailed({
error: "io",
reason: "unsupported-target"
}, tr("invalid transfer target type"));
return;
}
response.then(() => {
if(!transfer.isFinished()) {
/* we still need to stream the body */
transfer.setTransferState(FileTransferState.RUNNING);
}
}).catch(error => {
if(typeof error !== "string")
log.error(LogCategory.FILE_TRANSFER, tr("Failed to download file to response object: %o"), error);
transfer.setFailed({
error: "connection",
reason: "network-error",
extraMessage: typeof error === "string" ? error : tr("Lookup the console")
}, typeof error === "string" ? error : tr("Lookup the console"));
});
}).catch(error => {
if(typeof error !== "string")
log.error(LogCategory.FILE_TRANSFER, tr("Failed to initialize transfer target: %o"), error);
transfer.setFailed({
error: "io",
reason: "failed-to-initialize-target",
extraMessage: typeof error === "string" ? error : tr("Lookup the console")
}, typeof error === "string" ? error : tr("Lookup the console"));
});
}
targetSupported(type: TransferTargetType) {
switch (type) {
case TransferTargetType.DOWNLOAD:
case TransferTargetType.RESPONSE:
return true;
default:
return false;
}
}
async createDownloadTarget(filename: string) {
return new DownloadTransferTargetImpl(filename);
}
async createResponseTarget() {
return new ResponseTransferTargetImpl();
}
sourceSupported(type: TransferSourceType) {
switch (type) {
case TransferSourceType.BROWSER_FILE:
case TransferSourceType.BUFFER:
case TransferSourceType.TEXT:
return true;
default:
return false;
}
}
async createBufferSource(buffer: ArrayBuffer): Promise<BufferTransferSource> {
return new BufferTransferSourceImpl(buffer);
}
async createBrowserFileSource(file: File): Promise<BrowserFileTransferSource> {
return new BrowserFileTransferSourceImpl(file);
}
async createTextSource(text: string): Promise<TextTransferSource> {
return new TextTransferSourceImpl(text);
}
});
function generateTransferURL(transfer: FileTransfer, fileName?: string) {
const properties = transfer.transferProperties();
const url = "https://" + properties.addresses[0].serverAddress + ":" + properties.addresses[0].serverPort + "/";
const parameters = {
"transfer-key": properties.transferKey
};
if(typeof fileName !== "undefined")
parameters["file-name"] = fileName;
const query = "?" + Object.keys(parameters).map(e => e + "=" + encodeURIComponent(parameters[e])).join("&");
return url + query;
}
async function performHTTPSTransfer(transfer: FileTransfer, body: FormData | undefined) : Promise<Response> {
try {
const response = await fetch(generateTransferURL(transfer), {
method: typeof body === "number" ? "GET" : "POST",
cache: "no-cache",
mode: "cors",
body: body,
headers: {
/* for legacy TeaSpeak servers (prior to 1.4.15) */
'transfer-key': transfer.transferProperties().transferKey,
'download-name': transfer.properties.name,
/* end legacy */
"Access-Control-Allow-Headers": "*",
"Access-Control-Expose-Headers": "*"
}
});
if(!response.ok) {
throw (response.type == 'opaque' || response.type == 'opaqueredirect' ? "invalid cross origin flag! May target isn't a TeaSpeak server?" : response.statusText || "response is not ok");
}
/* the transfer may not running anymore, because of a finished signal from the server (especially on file upload!) */
if(transfer.isRunning()) {
response.clone().blob().then(() => {
if(transfer.isRunning())
transfer.setTransferState(FileTransferState.FINISHED);
}).catch(error => {
if(typeof error !== "string")
log.error(LogCategory.FILE_TRANSFER, tr("Failed to transfer data throw a HTTPS request: %o"), error);
transfer.setFailed({
error: "io",
reason: "buffer-transfer-failed",
extraMessage: typeof error === "string" ? error : tr("lookup the console")
}, typeof error === "string" ? error : tr("lookup the console"));
});
}
return response;
} catch (error) {
if(error instanceof Error && error.message === "Failed to fetch")
throw "HTTPS download failed";
throw error;
}
}
async function responseFileDownload(transfer: FileDownloadTransfer, target: ResponseTransferTargetImpl) {
target.setResponse(await performHTTPSTransfer(transfer, undefined));
}
async function downloadFileDownload(transfer: FileDownloadTransfer, target: DownloadTransferTargetImpl) {
const url = generateTransferURL(transfer);
target.startDownloadURL(url);
}
export class ResponseTransferTargetImpl extends ResponseTransferTarget {
private response: Response;
constructor() {
super();
}
hasResponse() {
return typeof this.response !== "undefined";
}
getResponse() {
return this.response;
}
setResponse(response: Response) {
this.response = response;
}
}
class DownloadTransferTargetImpl extends DownloadTransferTarget {
readonly fileName: string | undefined;
constructor(fileName: string | undefined) {
super();
this.fileName = fileName;
}
startDownloadURL(url: string) {
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.target = "_blank";
if(this.fileName)
a.download = this.fileName;
document.body.appendChild(a);
a.click();
a.remove();
}
}
class BrowserFileTransferSourceImpl extends BrowserFileTransferSource {
private readonly file: File;
constructor(file: File) {
super();
this.file = file;
}
getFile(): File {
return this.file;
}
async fileSize(): Promise<number> {
return this.file.size;
}
}
class BufferTransferSourceImpl extends BufferTransferSource {
private readonly buffer: ArrayBuffer;
constructor(buffer: ArrayBuffer) {
super();
this.buffer = buffer;
}
getBuffer(): ArrayBuffer {
return this.buffer;
}
async fileSize(): Promise<number> {
return this.buffer.byteLength;
}
}
class TextTransferSourceImpl extends TextTransferSource {
private readonly text: string;
private buffer: ArrayBuffer;
constructor(text: string) {
super();
this.text = text;
}
getText(): string {
return this.text;
}
async fileSize(): Promise<number> {
return this.getArrayBuffer().byteLength;
}
getArrayBuffer() : ArrayBuffer {
if(this.buffer) return this.buffer;
const encoder = new TextEncoder();
this.buffer = encoder.encode(this.text);
return this.buffer;
}
}
async function formDataUpload(transfer: FileUploadTransfer, data: File | ArrayBuffer | string) {
const formData = new FormData();
if(data instanceof File) {
formData.append("file", data);
} else if(typeof(data) === "string") {
formData.append("file", new Blob([data], { type: "application/octet-stream" }));
} else {
const buffer = data as BufferSource;
formData.append("file", new Blob([buffer], { type: "application/octet-stream" }));
}
await performHTTPSTransfer(transfer, formData);
}

View File

@ -384,7 +384,7 @@ export class ServerConnection extends AbstractServerConnection {
this._ping.last_response = 'now' in performance ? performance.now() : Date.now();
this._ping.value = this._ping.last_response - this._ping.last_request;
this._ping.value_native = parseInt(json["ping_native"]) / 1000; /* we're getting it in microseconds and not milliseconds */
log.debug(LogCategory.NETWORKING, tr("Received new pong. Updating ping to: JS: %o Native: %o"), this._ping.value.toFixed(3), this._ping.value_native.toFixed(3));
//log.debug(LogCategory.NETWORKING, tr("Received new pong. Updating ping to: JS: %o Native: %o"), this._ping.value.toFixed(3), this._ping.value_native.toFixed(3));
}
} else {
log.warn(LogCategory.NETWORKING, tr("Unknown command type %o"), json["type"]);

View File

@ -1,4 +1,6 @@
const webrtc_adapter = require("webrtc-adapter");
/* typescript keep alive */ let _x = (webrtc_adapter || "").toString();
const tc = require("tc-shared/main");
export = tc;
export = tc;
require("./FileTransfer");

@ -1 +1 @@
Subproject commit adcb7bc21d0afa79c1975030b29dfeef76651839
Subproject commit 5c94ec3205c30171ffd01056f5b4622b7c0ab54c

View File

@ -97,7 +97,7 @@ export const config = async (target: "web" | "client") => { return {
module: {
rules: [
{
test: /\.s[ac]ss$/,
test: /\.(s[ac]|c)ss$/,
loader: [
'style-loader',
/*
@ -138,7 +138,8 @@ export const config = async (target: "web" | "client") => { return {
getCustomTransformers: (prog: ts.Program) => {
return {
before: [trtransformer(prog, {
optimized: true,
optimized: false,
verbose: true,
target_file: path.join(__dirname, "dist", "translations.json")
})]
};
@ -158,6 +159,10 @@ export const config = async (target: "web" | "client") => { return {
loader: [
"./webpack/WatLoader.js"
]
},
{
test: /\.svg$/,
loader: 'svg-inline-loader'
}
],
},