diff --git a/ChangeLog.md b/ChangeLog.md index 31ac01da..53c8d560 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -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) diff --git a/loader/app/loader/loader.ts b/loader/app/loader/loader.ts index ef6c3d98..64402407 100644 --- a/loader/app/loader/loader.ts +++ b/loader/app/loader/loader.ts @@ -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; diff --git a/package-lock.json b/package-lock.json index c0ea6589..dbe8dfc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 618bff10..510ab91f 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/shared/css/static/modal.scss b/shared/css/static/modal.scss index e54c522f..9911cf07 100644 --- a/shared/css/static/modal.scss +++ b/shared/css/static/modal.scss @@ -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; - } - */ } } diff --git a/shared/img/icon_file_text.svg b/shared/img/icon_file_text.svg new file mode 100644 index 00000000..9b8af95e --- /dev/null +++ b/shared/img/icon_file_text.svg @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/shared/img/icon_folder.svg b/shared/img/icon_folder.svg new file mode 100644 index 00000000..c20bcd50 --- /dev/null +++ b/shared/img/icon_folder.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/shared/img/icon_folder_empty.svg b/shared/img/icon_folder_empty.svg new file mode 100644 index 00000000..545782e9 --- /dev/null +++ b/shared/img/icon_folder_empty.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/shared/img/icon_folder_password.svg b/shared/img/icon_folder_password.svg new file mode 100644 index 00000000..3bd34ae3 --- /dev/null +++ b/shared/img/icon_folder_password.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index db8856e0..66e90c00 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -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); diff --git a/shared/js/connection/CommandHandler.ts b/shared/js/connection/CommandHandler.ts index 9c3d384c..c8dc004c 100644 --- a/shared/js/connection/CommandHandler.ts +++ b/shared/js/connection/CommandHandler.ts @@ -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) { diff --git a/shared/js/connection/ServerConnectionDeclaration.ts b/shared/js/connection/ServerConnectionDeclaration.ts index bde4cc03..a6060bfe 100644 --- a/shared/js/connection/ServerConnectionDeclaration.ts +++ b/shared/js/connection/ServerConnectionDeclaration.ts @@ -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 { diff --git a/shared/js/crypto/md5.ts b/shared/js/crypto/md5.ts new file mode 100644 index 00000000..b5100efc --- /dev/null +++ b/shared/js/crypto/md5.ts @@ -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); +} \ No newline at end of file diff --git a/shared/js/events.ts b/shared/js/events.ts index 050ffad0..571c23c8 100644 --- a/shared/js/events.ts +++ b/shared/js/events.ts @@ -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 { readonly type: T; @@ -91,6 +92,20 @@ export class Registry { } } + + /* special helper methods for react components */ + reactUse(event: T, handler: (event?: Events[T] & Event) => 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(events: T | T[], target: Registry) { 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 { 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 { 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)) { diff --git a/shared/js/file/Avatars.tsx b/shared/js/file/Avatars.tsx index 9015a24b..8cbee4cb 100644 --- a/shared/js/file/Avatars.tsx +++ b/shared/js/file/Avatars.tsx @@ -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 { - 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 { + 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; } -} \ No newline at end of file + + 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(); + }); +}; \ No newline at end of file diff --git a/shared/js/file/FileManager.tsx b/shared/js/file/FileManager.tsx index 84310e08..d3b6ab9f 100644 --- a/shared/js/file/FileManager.tsx +++ b/shared/js/file/FileManager.tsx @@ -1,195 +1,69 @@ import * as log from "tc-shared/log"; import {LogCategory} from "tc-shared/log"; -import {ChannelEntry} from "tc-shared/ui/channel"; import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {ServerCommand} from "tc-shared/connection/ConnectionBase"; -import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; +import {CommandResult, ErrorCode, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration"; import {AbstractCommandHandler} from "tc-shared/connection/AbstractCommandHandler"; import {IconManager} from "tc-shared/file/Icons"; import {AvatarManager} from "tc-shared/file/Avatars"; +import { + CancelReason, + FileDownloadTransfer, + FileTransfer, + FileTransferDirection, + FileTransferState, + FileUploadTransfer, + FinishedFileTransfer, + InitializedTransferProperties, + TransferProvider, + TransferSourceSupplier, + TransferTargetSupplier, +} from "tc-shared/file/Transfer"; +import {Registry} from "tc-shared/events"; +import {tra} from "tc-shared/i18n/localize"; -export class FileEntry { +export enum FileType { + DIRECTORY = 0, + FILE = 1 +} + +export interface FileInfo { name: string; + type: FileType; + datetime: number; - type: number; size: number; + + empty: boolean; } -export class FileListRequest { +type PendingFileList = { path: string; - entries: FileEntry[]; + channelId: number; - callback: (entries: FileEntry[]) => void; + currentFiles: FileInfo[]; + + resultPromise: Promise; + callbackResolve: (files: FileInfo[]) => void; + callbackReject: (error) => void; } -export interface TransferKey { - client_transfer_id: number; - server_transfer_id: number; +type PendingFileInfo = { + returnCode: string; - key: string; - - file_path: string; - file_name: string; - - peer: { - hosts: string[], - port: number; - }; - - total_size: number; + finished: boolean; + currentFiles: FileInfo[]; } -export interface UploadOptions { - name: string; - path: string; +class FileCommandHandler extends AbstractCommandHandler { + readonly manager: FileManager; + readonly pendingFileLists: PendingFileList[] = []; + readonly pendingFileInfos: PendingFileInfo[] = []; + private fileInfoCodeIndex = 0; - channel?: ChannelEntry; - channel_password?: string; - - size: number; - overwrite: boolean; -} - -export interface DownloadTransfer { - get_key() : DownloadKey; - - request_file() : Promise; -} - -export interface UploadTransfer { - get_key(): UploadKey; - - put_data(data: BlobPart | File) : Promise; -} - -export type DownloadKey = TransferKey; -export type UploadKey = TransferKey; - -export interface TransferProvider { - spawn_download_transfer(key: DownloadKey) : DownloadTransfer; - spawn_upload_transfer(key: UploadKey) : UploadTransfer; -} - -let transfer_provider_: TransferProvider = new class implements TransferProvider { - spawn_download_transfer(key: TransferKey): DownloadTransfer { - return new RequestFileDownload(key); - } - - spawn_upload_transfer(key: TransferKey): UploadTransfer { - return new RequestFileUpload(key); - } -}; - -export function transfer_provider() : TransferProvider { - return transfer_provider_; -} - -export function set_transfer_provider(provider: TransferProvider) { - transfer_provider_ = provider; -} - -export class RequestFileDownload implements DownloadTransfer { - readonly transfer_key: DownloadKey; - - constructor(key: DownloadKey) { - this.transfer_key = key; - } - - async request_file() : Promise { - return await this.try_fetch("https://" + this.transfer_key.peer.hosts[0] + ":" + this.transfer_key.peer.port); - } - - private async try_fetch(url: string) : Promise { - const response = await fetch(url, { - method: 'GET', - cache: "no-cache", - mode: 'cors', - headers: { - 'transfer-key': this.transfer_key.key, - 'download-name': this.transfer_key.file_name, - 'Access-Control-Allow-Headers': '*', - 'Access-Control-Expose-Headers': '*' - } - }); - if(!response.ok) { - debugger; - throw (response.type == 'opaque' || response.type == 'opaqueredirect' ? "invalid cross origin flag! May target isn't a TeaSpeak server?" : response.statusText || "response is not ok"); - } - return response; - } - - get_key(): DownloadKey { - return this.transfer_key; - } -} - -export class RequestFileUpload implements UploadTransfer { - readonly transfer_key: UploadKey; - constructor(key: DownloadKey) { - this.transfer_key = key; - } - - get_key(): UploadKey { - return this.transfer_key; - } - - async put_data(data: BlobPart | File) : Promise { - const form_data = new FormData(); - - if(data instanceof File) { - if(data.size != this.transfer_key.total_size) - throw "invalid size"; - - form_data.append("file", data); - } else if(typeof(data) === "string") { - if(data.length != this.transfer_key.total_size) - throw "invalid size"; - form_data.append("file", new Blob([data], { type: "application/octet-stream" })); - } else { - const buffer = data as BufferSource; - if(buffer.byteLength != this.transfer_key.total_size) - throw "invalid size"; - - form_data.append("file", new Blob([buffer], { type: "application/octet-stream" })); - } - - await this.try_put(form_data, "https://" + this.transfer_key.peer.hosts[0] + ":" + this.transfer_key.peer.port); - } - - private async try_put(data: FormData, url: string) : Promise { - const response = await fetch(url, { - method: 'POST', - cache: "no-cache", - mode: 'cors', - body: data, - headers: { - 'transfer-key': this.transfer_key.key, - '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"); - } -} - -export class FileManager extends AbstractCommandHandler { - handle: ConnectionHandler; - icons: IconManager; - avatars: AvatarManager; - - private listRequests: FileListRequest[] = []; - private pending_download_requests: DownloadKey[] = []; - private pending_upload_requests: UploadKey[] = []; - - private transfer_counter : number = 1; - - constructor(client: ConnectionHandler) { - super(client.serverConnection); - - this.handle = client; - this.icons = new IconManager(this); - this.avatars = new AvatarManager(this); + constructor(manager: FileManager) { + super(manager.connectionHandler.serverConnection); + this.manager = manager; this.connection.command_handler_boss().register_handler(this); } @@ -197,220 +71,418 @@ export class FileManager extends AbstractCommandHandler { destroy() { if(this.connection) { const hboss = this.connection.command_handler_boss(); - if(hboss) - hboss.unregister_handler(this); + if(hboss) hboss.unregister_handler(this); } + } - this.listRequests = undefined; - this.pending_download_requests = undefined; - this.pending_upload_requests = undefined; + registerFileList(path: string, channelId: number, callbackExecute: (resolve, reject) => void) : Promise { + const knownQuery = this.pendingFileLists.find(e => e.path === path && e.channelId === channelId); + if(knownQuery) return knownQuery.resultPromise; - this.icons && this.icons.destroy(); - this.icons = undefined; + const query = {} as PendingFileList; - this.avatars && this.avatars.destroy(); - this.avatars = undefined; + query.path = path; + query.channelId = channelId; + query.currentFiles = []; + + this.pendingFileLists.push(query); + + query.resultPromise = new Promise((resolve, reject) => { + const cleanup = () => { + this.pendingFileLists.remove(query); + }; + + query.callbackReject = error => { cleanup(); reject(error); }; + query.callbackResolve = result => { cleanup(); resolve(result); }; + callbackExecute(query.callbackResolve, query.callbackReject); + }); + + return query.resultPromise; + } + + registerFileInfo() : string { + const query = {} as PendingFileInfo; + + query.currentFiles = []; + query.finished = false; + query.returnCode = "finfo-" + ++this.fileInfoCodeIndex; + + this.pendingFileInfos.push(query); + return query.returnCode; + } + + finishFileInfo(returnCode: string) : FileInfo[] | "unknown-request" | "unfinished-request" { + const qIndex = this.pendingFileInfos.findIndex(e => e.returnCode === returnCode); + if(qIndex === -1) return "unknown-request"; + + const [ query ] = this.pendingFileInfos.splice(qIndex, 1); + if(!query.finished) return "unfinished-request"; + + return query.currentFiles; } handle_command(command: ServerCommand): boolean { switch (command.command) { - case "notifyfilelist": - this.notifyFileList(command.arguments); - return true; - case "notifyfilelistfinished": - this.notifyFileListFinished(command.arguments); - return true; case "notifystartdownload": - this.notifyStartDownload(command.arguments); + this.handleCommandNotifyStartDownload(command.arguments); return true; + case "notifystartupload": - this.notifyStartUpload(command.arguments); + this.handleCommandNotifyStartUpload(command.arguments); return true; + + case "notifyfilelist": + this.handleNotifyFileList(command.arguments); + return true; + + case "notifyfilelistfinished": + this.handleNotifyFileListFinished(command.arguments); + return true; + + case "notifyfileinfo": + this.handleNotifyFileInfo(command.arguments); + return true; + + case "notifyfiletransferstarted": + this.handleNotifyTransferStarted(command.arguments); + return true; + + case "notifyfileinfofinished": + this.handleNotifyFileInfoFinished(command.arguments); + return true; + + case "notifyfiletransferprogress": + this.handleNotifyFileTransferProgress(command.arguments); + return true; + + case "notifystatusfiletransfer": + this.handleNotifyStatusFileTransfer(command.arguments); + return true; + } return false; } + private handleCommandNotifyStartDownload(command: any[]) { + const data = command[0]; - /******************************** File list ********************************/ - //TODO multiple requests (same path) - requestFileList(path: string, channel?: ChannelEntry, password?: string) : Promise { - const _this = this; - return new Promise((accept, reject) => { - let req = new FileListRequest(); - req.path = path; - req.entries = []; - req.callback = accept; - _this.listRequests.push(req); + const transfer = this.manager.findTransfer(parseInt(data["clientftfid"])); + if(!transfer) { + log.warn(LogCategory.FILE_TRANSFER, tr("Received file transfer start notification for unknown transfer (%s)"), data["clientftfid"]); + return; + } - _this.handle.serverConnection.send_command("ftgetfilelist", {"path": path, "cid": (channel ? channel.channelId : "0"), "cpw": (password ? password : "")}).then(() => {}).catch(reason => { - _this.listRequests.remove(req); - if(reason instanceof CommandResult) { - if(reason.id == 0x0501) { - accept([]); //Empty result - return; - } + const properties = { + fileSize: parseInt(data["size"]), + seekOffset: parseInt(data["seekpos"]), + + protocol: parseInt(data["proto"]), + + addresses: (!data["ip"] ? "0.0.0.0" : data["ip"]).split(",").filter(e => !!e).map(e => { + return { + serverAddress: e, + serverPort: parseInt(data["port"]) + }; + }), + + serverTransferId: parseInt(data["serverftfid"]), + transferKey: data["ftkey"] + }; + this.fixIPAddresses(properties); + + transfer.lastStateUpdate = Date.now(); + transfer.setProperties(properties); + } + + private fixIPAddresses(properties: InitializedTransferProperties) { + for(const address of properties.addresses) + if(address.serverAddress === '0.0.0.0') + address.serverAddress = this.manager.connectionHandler.serverConnection.remote_address().host; + + } + + private handleCommandNotifyStartUpload(command: any[]) { + const data = command[0]; + + const transfer = this.manager.findTransfer(parseInt(data["clientftfid"])) as FileUploadTransfer; + if(!transfer) { + log.warn(LogCategory.FILE_TRANSFER, tr("Received file transfer start notification for unknown transfer (%s)"), data["clientftfid"]); + return; + } + + const properties = { + seekOffset: parseInt(data["seekpos"]), + fileSize: transfer.fileSize, + protocol: parseInt(data["proto"]), + + addresses: (!data["ip"] ? "0.0.0.0" : data["ip"]).split(",").filter(e => !!e).map(e => { + return { + serverAddress: e, + serverPort: parseInt(data["port"]) + }; + }), + + serverTransferId: parseInt(data["serverftfid"]), + transferKey: data["ftkey"] + }; + this.fixIPAddresses(properties); + + transfer.lastStateUpdate = Date.now(); + transfer.setProperties(properties); + } + + private handleNotifyTransferStarted(data) { + data = data[0]; + + const transfer = this.manager.findTransfer(parseInt(data["clientftfid"])) as FileUploadTransfer; + if(!transfer) { + log.warn(LogCategory.FILE_TRANSFER, tr("Received file transfer start notification for unknown transfer (%s)"), data["clientftfid"]); + return; + } + + /* the server is some knowledge ahead of us (usually happens when we use fetch) */ + if(transfer.transferState() === FileTransferState.CONNECTING) + transfer.setTransferState(FileTransferState.RUNNING); + } + + + private handleNotifyFileTransferProgress(data) { + data = data[0]; + + const transfer = this.manager.findTransfer(parseInt(data["clientftfid"])) as FileUploadTransfer; + if(!transfer) { + log.warn(LogCategory.FILE_TRANSFER, tr("Received file transfer progress notification for unknown transfer (%s)"), data["clientftfid"]); + return; + } + + transfer.lastStateUpdate = Date.now(); + transfer.updateProgress({ + timestamp: Date.now(), + + file_bytes_transferred: parseInt(data["file_bytes_transferred"]), + file_current_offset: parseInt(data["file_current_offset"]), + file_start_offset: parseInt(data["file_start_offset"]), + file_total_size: parseInt(data["file_total_size"]), + network_bytes_received: parseInt(data["network_bytes_received"]), + network_bytes_send: parseInt(data["network_bytes_send"]), + + network_current_speed: parseInt(data["network_current_speed"]), + network_average_speed: parseInt(data["network_average_speed"]) + }); + } + + private handleNotifyStatusFileTransfer(data) { + data = data[0]; + + const transfer = this.manager.findTransfer(parseInt(data["clientftfid"])) as FileUploadTransfer; + if(!transfer) { + log.warn(LogCategory.FILE_TRANSFER, tr("Received file transfer status notification for unknown transfer (%s)"), data["clientftfid"]); + return; + } + + transfer.lastStateUpdate = Date.now(); + const code = parseInt(data["status"]) as ErrorCode; + if(code !== ErrorCode.FILE_TRANSFER_COMPLETE) { + transfer.setFailed({ + error: "status", + + extraMessage: data["msg"], + status: code + }, data["msg"]); + } else { + /* We're not setting finished here. Even thou the server has finished the transfer, we might still have some work left. + * This only applies to downloads since when we're uploading and the server is happy everybody is happy + **/ + if(transfer.direction === FileTransferDirection.UPLOAD) + transfer.setTransferState(FileTransferState.FINISHED); + } + } + + private handleNotifyFileList(data: any[]) { + const query = this.pendingFileLists.find(e => e.path === data[0]["path"] && (e.channelId === parseInt(data[0]["cid"]) || e.channelId === undefined && !data[0]["cid"])); + if(!query) { + log.warn(LogCategory.FILE_TRANSFER, tr("Received file list for not request path: %s (channel %s)"), data[0]["path"], data[0]["cid"]); + return; + } + + for(const entry of data) { + query.currentFiles.push({ + datetime: parseInt(entry["datetime"]), + name: entry["name"], + size: parseInt(entry["size"]), + type: parseInt(entry["type"]), + empty: entry["empty"] === "1" + }); + } + } + + private handleNotifyFileListFinished(data) { + const query = this.pendingFileLists.find(e => e.path === data[0]["path"] && (e.channelId === parseInt(data[0]["cid"]) || e.channelId === undefined && !data[0]["cid"])); + if(!query) { + log.warn(LogCategory.FILE_TRANSFER, tr("Received file list finish for not request path: %s (channel %s)"), data[0]["path"], data[0]["cid"]); + return; + } + + query.callbackResolve(query.currentFiles); + } + + private handleNotifyFileInfo(data: any[]) { + const query = this.pendingFileInfos.find(e => e.returnCode === data[0]["return_code"]); + if(!query) { + log.warn(LogCategory.FILE_TRANSFER, tr("Received file info for unknown return code: %s"), data[0]["return_code"]); + return; + } + if(query.finished) { + log.warn(LogCategory.FILE_TRANSFER, tr("Received file info for already finished return code: %s"), data[0]["return_code"]); + return; + } + + for(const entry of data) { + query.currentFiles.push({ + datetime: parseInt(entry["datetime"]), + name: entry["name"], + size: parseInt(entry["size"]), + type: parseInt(entry["type"]), + empty: entry["empty"] === "1" + }); + } + } + + private handleNotifyFileInfoFinished(data) { + const query = this.pendingFileInfos.find(e => e.returnCode === data[0]["return_code"]); + if(!query) { + log.warn(LogCategory.FILE_TRANSFER, tr("Received file info for unknown return code: %s"), data[0]["return_code"]); + return; + } + + query.finished = true; + } +} + +export type InitializeUploadOptions = { + path: string; + name: string; + + channel?: number; + channelPassword?: string; + + source: TransferSourceSupplier; +}; + +export type InitializeDownloadOptions = { + path: string; + name: string; + + channel?: number; + channelPassword?: string; + + targetSupplier: TransferTargetSupplier; +}; + +export interface FileManagerEvents { + notify_transfer_registered: { + transfer: FileTransfer + } +} + +export class FileManager { + private static readonly MAX_CONCURRENT_TRANSFERS = 6; + + readonly connectionHandler: ConnectionHandler; + readonly icons: IconManager; + readonly avatars: AvatarManager; + readonly events : Registry; + readonly finishedTransfers: FinishedFileTransfer[] = []; + + private readonly commandHandler: FileCommandHandler; + private readonly registeredTransfers_: ({ transfer: FileTransfer, executeCallback: () => Promise, finishPromise: Promise })[] = []; + private clientTransferIdIndex = 0; + private scheduledTransferUpdate; + private transerUpdateIntervalId; + + constructor(connection) { + this.connectionHandler = connection; + this.commandHandler = new FileCommandHandler(this); + + this.events = new Registry(); + this.icons = new IconManager(this); + this.avatars = new AvatarManager(this); + + this.transerUpdateIntervalId = setInterval(() => this.scheduleTransferUpdate(), 1000); + } + + destroy() { + this.commandHandler.destroy(); + this.registeredTransfers_.forEach(e => e.transfer.requestCancel(CancelReason.SERVER_DISCONNECTED)); + /* all transfers should be unregistered now, or will be soonly */ + + this.icons.destroy(); + this.avatars.destroy(); + clearInterval(this.transerUpdateIntervalId); + } + + requestFileList(path: string, channelId?: number, channelPassword?: string) : Promise { + return this.commandHandler.registerFileList(path, channelId | 0, (resolve, reject) => { + this.connectionHandler.serverConnection.send_command("ftgetfilelist", { + path: path, + cid: channelId || "0", + cpw: channelPassword + }).then(() => { + reject(tr("Missing server file list response")); + }).catch(error => { + if(error instanceof CommandResult && error.id == ErrorID.EMPTY_RESULT) { + resolve([]); + return; } - reject(reason); + reject(error); }); }); } - private notifyFileList(json) { - let entry : FileListRequest = undefined; + requestFileInfo(files: { channelId?: number, channelPassword?: string, path: string }[]) : Promise<(FileInfo | CommandResult)[]> { + if(files.length === 0) + return Promise.resolve([]); - for(let e of this.listRequests) { - if(e.path == json[0]["path"]){ - entry = e; - break; + const returnCode = this.commandHandler.registerFileInfo(); + const infos = files.map(e => { + return { + cid: e.channelId | 0, + cpw: e.channelPassword, + name: e.path + }; + }); + infos[0]["return_code"] = returnCode; + + return this.connectionHandler.serverConnection.send_command("ftgetfileinfo", infos, { flagset: ["as-list"] }).then(cmdResult => { + const bulks = cmdResult.getBulks(); + if(bulks.length != files.length) + return Promise.reject(tr("response bulk miss match")); + + const infos = this.commandHandler.finishFileInfo(returnCode); + if(!Array.isArray(infos)) { + if(infos === "unfinished-request") + return Promise.reject(tr("the server failed to full fill the request")); + else + return Promise.reject(tr("request gone away while parsing response")); } - } - if(!entry) { - log.error(LogCategory.CLIENT, tr("Invalid file list entry. Path: %s"), json[0]["path"]); - return; - } - for(let e of (json as Array)) { - e.datetime = parseInt(e.datetime + ""); - e.size = parseInt(e.size + ""); - e.type = parseInt(e.type + ""); - entry.entries.push(e); - } - } + let result: (FileInfo | CommandResult)[] = []; + for(let index = 0; index < files.length; index++) { + if(bulks[index].id === 0) { + const info = infos.pop_front(); + if(!info) + return Promise.reject(tr("Missing info for bulk ") + index); - private notifyFileListFinished(json) { - let entry : FileListRequest = undefined; - - for(let e of this.listRequests) { - if(e.path == json[0]["path"]){ - entry = e; - this.listRequests.remove(e); - break; + result.push(info); + } else { + result.push(bulks[index]); + } } - } - - if(!entry) { - log.error(LogCategory.CLIENT, tr("Invalid file list entry finish. Path: "), json[0]["path"]); - return; - } - entry.callback(entry.entries); - } - - - /******************************** File download/upload ********************************/ - download_file(path: string, file: string, channel?: ChannelEntry, password?: string) : Promise { - const transfer_data: DownloadKey = { - file_name: file, - file_path: path, - client_transfer_id: this.transfer_counter++ - } as any; - - this.pending_download_requests.push(transfer_data); - return new Promise((resolve, reject) => { - transfer_data["_callback"] = resolve; - this.handle.serverConnection.send_command("ftinitdownload", { - "path": path, - "name": file, - "cid": (channel ? channel.channelId : "0"), - "cpw": (password ? password : ""), - "clientftfid": transfer_data.client_transfer_id, - "seekpos": 0, - "proto": 1 - }, {process_result: false}).catch(reason => { - this.pending_download_requests.remove(transfer_data); - reject(reason); - }) + return result; }); } - upload_file(options: UploadOptions) : Promise { - const transfer_data: UploadKey = { - file_path: options.path, - file_name: options.name, - client_transfer_id: this.transfer_counter++, - total_size: options.size - } as any; - - this.pending_upload_requests.push(transfer_data); - return new Promise((resolve, reject) => { - transfer_data["_callback"] = resolve; - this.handle.serverConnection.send_command("ftinitupload", { - "path": options.path, - "name": options.name, - "cid": (options.channel ? options.channel.channelId : "0"), - "cpw": options.channel_password || "", - "clientftfid": transfer_data.client_transfer_id, - "size": options.size, - "overwrite": options.overwrite, - "resume": false, - "proto": 1 - }).catch(reason => { - this.pending_upload_requests.remove(transfer_data); - reject(reason); - }) - }); - } - - private notifyStartDownload(json) { - json = json[0]; - - let clientftfid = parseInt(json["clientftfid"]); - let transfer: DownloadKey; - for(let e of this.pending_download_requests) - if(e.client_transfer_id == clientftfid) { - transfer = e; - break; - } - - transfer.server_transfer_id = parseInt(json["serverftfid"]); - transfer.key = json["ftkey"]; - transfer.total_size = json["size"]; - - transfer.peer = { - hosts: (json["ip"] || "").split(","), - port: parseInt(json["port"]) - }; - - if(transfer.peer.hosts.length == 0) - transfer.peer.hosts.push("0.0.0.0"); - - if(transfer.peer.hosts[0].length == 0 || transfer.peer.hosts[0] == '0.0.0.0') - transfer.peer.hosts[0] = this.handle.serverConnection.remote_address().host; - - (transfer["_callback"] as (val: DownloadKey) => void)(transfer); - this.pending_download_requests.remove(transfer); - } - - private notifyStartUpload(json) { - json = json[0]; - - let transfer: UploadKey; - let clientftfid = parseInt(json["clientftfid"]); - for(let e of this.pending_upload_requests) - if(e.client_transfer_id == clientftfid) { - transfer = e; - break; - } - - transfer.server_transfer_id = parseInt(json["serverftfid"]); - transfer.key = json["ftkey"]; - - transfer.peer = { - hosts: (json["ip"] || "").split(","), - port: parseInt(json["port"]) - }; - - if(transfer.peer.hosts.length == 0) - transfer.peer.hosts.push("0.0.0.0"); - - if(transfer.peer.hosts[0].length == 0 || transfer.peer.hosts[0] == '0.0.0.0') - transfer.peer.hosts[0] = this.handle.serverConnection.remote_address().host; - - (transfer["_callback"] as (val: UploadKey) => void)(transfer); - this.pending_upload_requests.remove(transfer); - } - - /** File management **/ - async delete_file(props: { + async deleteFile(props: { name: string, path?: string; cid?: number; @@ -419,15 +491,238 @@ export class FileManager extends AbstractCommandHandler { if(!props.name) throw "invalid name!"; - try { - await this.handle.serverConnection.send_command("ftdeletefile", { - cid: props.cid || 0, - cpw: props.cpw, - path: props.path || "", - name: props.name + await this.connectionHandler.serverConnection.send_command("ftdeletefile", { + cid: props.cid || 0, + cpw: props.cpw, + path: props.path || "", + name: props.name + }); + } + + registeredTransfers() : FileTransfer[] { + return this.registeredTransfers_.map(e => e.transfer); + } + + findTransfer(id: number) : FileTransfer; + findTransfer(channelId: number, path: string, name: string) : FileTransfer; + + findTransfer(channelIdOrId: number, path?: string, name?: string) : FileTransfer { + if(typeof path !== "string") + return this.registeredTransfers_.find(e => e.transfer.clientTransferId === channelIdOrId)?.transfer; + else + return this.registeredTransfers_.find(e => + e.transfer.properties.channel_id === channelIdOrId && + e.transfer.properties.name === name && + e.transfer.properties.path === path + )?.transfer; + } + + initializeFileDownload(options: InitializeDownloadOptions) : FileDownloadTransfer { + const transfer = new FileDownloadTransfer(FileTransferDirection.DOWNLOAD, ++this.clientTransferIdIndex, { + channel_id: options.channel | 0, + name: options.name, + path: options.path + }, options.targetSupplier); + + const initializeCallback = async () => { + try { + await this.connectionHandler.serverConnection.send_command("ftinitdownload", { + "path": options.path, + "name": options.name, + "cid": options.channel ? options.channel : "0", + "cpw": options.channelPassword, + "clientftfid": transfer.clientTransferId, + "seekpos": 0, + "proto": 1 + }, {process_result: false}); + + if(transfer.transferState() === FileTransferState.INITIALIZING) + throw tr("missing transfer start notify"); + + } catch (error) { + transfer.setFailed({ + error: "initialize", + commandResult: error + }, error instanceof CommandResult ? error.formattedMessage() : typeof error === "string" ? error : tr("Lookup the console")); + } + }; + + this.registerTransfer(transfer, initializeCallback); + return transfer; + } + + initializeFileUpload(options: InitializeUploadOptions) : FileUploadTransfer { + const transfer = new FileUploadTransfer(FileTransferDirection.DOWNLOAD, ++this.clientTransferIdIndex, { + channel_id: options.channel | 0, + name: options.name, + path: options.path + }, options.source); + + const initializeCallback = async () => { + try { + transfer.source = await transfer.sourceSupplier(transfer); + if(!transfer.source) + throw tr("Failed to create transfer source"); + + transfer.fileSize = await transfer.source.fileSize(); + await this.connectionHandler.serverConnection.send_command("ftinitupload", { + "path": options.path, + "name": options.name, + "cid": options.channel ? options.channel : "0", + "cpw": options.channelPassword, + "clientftfid": transfer.clientTransferId, + "size": transfer.fileSize, + "overwrite": true, + "resume": false, + "proto": 1 + }); + + if(transfer.transferState() === FileTransferState.INITIALIZING) + throw tr("missing transfer start notify"); + + } catch (error) { + transfer.setFailed({ + error: "initialize", + commandResult: error + }, error instanceof CommandResult ? error.formattedMessage() : typeof error === "string" ? error : tr("Lookup the console")); + } + }; + + this.registerTransfer(transfer, initializeCallback); + return transfer; + } + + private registerTransfer(transfer: FileTransfer, callbackInitialize: (transfer: FileTransfer) => Promise) { + transfer.lastStateUpdate = Date.now(); + this.registeredTransfers_.push({ + transfer: transfer, + executeCallback: async () => { + await callbackInitialize(transfer); /* noexcept */ + if(transfer.transferState() !== FileTransferState.CONNECTING) + return; + + try { + const provider = TransferProvider.provider(); + if(!provider) { + transfer.setFailed({ + error: "connection", + reason: "missing-provider" + }, tr("Missing transfer provider")); + return; + } + + if(transfer instanceof FileDownloadTransfer) + provider.executeFileDownload(transfer); + else if(transfer instanceof FileUploadTransfer) + provider.executeFileUpload(transfer); + else + throw tr("unknown transfer type"); + } catch (error) { + const message = typeof error === "string" ? error : error instanceof Error ? error.message : tr("Unknown error"); + transfer.setFailed({ + error: "connection", + reason: "provider-initialize-error", + extraMessage: message + }, message); + } + }, + finishPromise: new Promise(resolve => { + const unregisterTransfer = () => { + transfer.events.off("notify_state_updated", stateListener); + transfer.events.off("action_request_cancel", cancelListener); + + const index = this.registeredTransfers_.findIndex(e => e.transfer === transfer); + if(index === -1) { + log.error(LogCategory.FILE_TRANSFER, tr("Missing file transfer in file transfer list!")); + return; + } else { + this.registeredTransfers_.splice(index, 1); + this.scheduleTransferUpdate(); + } + + /* register transfer for the finished/completed transfers */ + const state = transfer.transferState(); + if(state === FileTransferState.FINISHED || state === FileTransferState.ERRORED || state === FileTransferState.CANCELED) { + this.finishedTransfers.push({ + state: state, + + clientTransferId: transfer.clientTransferId, + direction: transfer.direction, + properties: transfer.properties, + timings: Object.assign({}, transfer.timings), + + bytesTransferred: state === FileTransferState.FINISHED ? transfer.transferProperties().fileSize - transfer.transferProperties().seekOffset : 0, + + transferError: transfer.currentError(), + transferErrorMessage: transfer.currentErrorMessage(), + }); + } else { + log.warn(LogCategory.FILE_TRANSFER, tra("File transfer finished callback called with invalid transfer state ({0})", FileTransferState[state])); + } + }; + + const stateListener = () => { + if(transfer.isFinished()) { + unregisterTransfer(); + resolve(); + } + }; + + const cancelListener = () => { + unregisterTransfer(); + transfer.events.fire_async("notify_transfer_canceled", {}, resolve); + }; + + transfer.events.on("notify_state_updated", stateListener); + transfer.events.on("action_request_cancel", cancelListener); }) - } catch(error) { - throw error; + }); + + this.events.fire("notify_transfer_registered", { transfer: transfer }); + this.scheduleTransferUpdate(); + } + + private scheduleTransferUpdate() { + if(this.scheduledTransferUpdate) + return; + + this.scheduledTransferUpdate = setTimeout(() => { + this.scheduledTransferUpdate = undefined; + this.updateRegisteredTransfers(); + }, 0); + } + + private updateRegisteredTransfers() { + /* drop timeouted transfers */ + { + const timeout = Date.now() - 10 * 1000; + const timeouted = this.registeredTransfers_.filter(e => e.transfer.lastStateUpdate < timeout).filter(e => e.transfer.isRunning()); + timeouted.forEach(e => { + e.transfer.setFailed({ + error: "timeout" + }, tr("Timed out")); + }); + } + + /* check if we could start a new transfer */ + { + let pendingTransfers = this.registeredTransfers_.filter(e => e.transfer.isPending()); + let runningTransfers = this.registeredTransfers_.filter(e => e.transfer.isRunning()); + while(runningTransfers.length < FileManager.MAX_CONCURRENT_TRANSFERS && pendingTransfers.length > 0) { + const transfer = pendingTransfers.pop_front(); + runningTransfers.push(transfer); + + transfer.transfer.setTransferState(FileTransferState.INITIALIZING); + setTimeout(transfer.executeCallback, 0); + } + + if(runningTransfers.length !== 0) { + /* start a new transfer as soon the old has been finished */ + Promise.race([runningTransfers.map(e => e.finishPromise)]).then(() => { + this.scheduleTransferUpdate(); + }); + } } } -} \ No newline at end of file +} + diff --git a/shared/js/file/Icons.tsx b/shared/js/file/Icons.tsx index a818b50d..4c659770 100644 --- a/shared/js/file/Icons.tsx +++ b/shared/js/file/Icons.tsx @@ -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 { + iconList() : Promise { return this.handle.requestFileList("/icons"); } - create_icon_download(id: number) : Promise { - 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 { @@ -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 = {}; + } } \ No newline at end of file diff --git a/shared/js/file/Transfer.ts b/shared/js/file/Transfer.ts new file mode 100644 index 00000000..eb43a16b --- /dev/null +++ b/shared/js/file/Transfer.ts @@ -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; +} + + +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; + +/* 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; + +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; + 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(); + 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 { + 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 { throw tr("response target isn't supported"); } + async createDownloadTarget(filename?: string) : Promise { throw tr("download target isn't supported"); } + + async createBufferSource(buffer: ArrayBuffer) : Promise { throw tr("buffer source isn't supported"); } + async createTextSource(text: string) : Promise { throw tr("text source isn't supported"); }; + async createBrowserFileSource(file: File) : Promise { throw tr("browser file source isn't supported"); } +} \ No newline at end of file diff --git a/shared/js/i18n/localize.ts b/shared/js/i18n/localize.ts index 48602847..51de8d74 100644 --- a/shared/js/i18n/localize.ts +++ b/shared/js/i18n/localize.ts @@ -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 { diff --git a/shared/js/log.ts b/shared/js/log.ts index e05fa71c..e254027a 100644 --- a/shared/js/log.ts +++ b/shared/js/log.ts @@ -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([ - [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([ @@ -64,7 +66,8 @@ export let enabled_mapping = new Map([ [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() diff --git a/shared/js/main.tsx b/shared/js/main.tsx index 84cebe3a..4b673b3b 100644 --- a/shared/js/main.tsx +++ b/shared/js/main.tsx @@ -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 = { diff --git a/shared/js/profiles/identities/teaspeak-forum.ts b/shared/js/profiles/identities/teaspeak-forum.ts index 3ea4ff81..972e58d4 100644 --- a/shared/js/profiles/identities/teaspeak-forum.ts +++ b/shared/js/profiles/identities/teaspeak-forum.ts @@ -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); } -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/shared/js/proto.ts b/shared/js/proto.ts index 4621ff41..c59f34af 100644 --- a/shared/js/proto.ts +++ b/shared/js/proto.ts @@ -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(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(){ diff --git a/shared/js/settings.ts b/shared/js/settings.ts index faaaf7be..1afb5002 100644 --- a/shared/js/settings.ts +++ b/shared/js/settings.ts @@ -354,6 +354,12 @@ export class Settings extends StaticSettings { default_value: "tea-web" }; + static readonly KEY_TRANSFERS_SHOW_FINISHED: SettingsKey = { + 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 = name => { return { key: 'invite_link_setting_' + name diff --git a/shared/js/ui/channel.ts b/shared/js/ui/channel.ts index a0a6d9bb..34750da5 100644 --- a/shared/js/ui/channel.ts +++ b/shared/js/ui/channel.ts @@ -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 { //HTML DOM elements private _destroyed = false; - private _cachedPassword: string; + private cachedPasswordHash: string; private _cached_channel_description: string = undefined; private _cached_channel_description_promise: Promise = undefined; private _cached_channel_description_promise_resolve: any = undefined; @@ -390,6 +391,12 @@ export class ChannelEntry extends ChannelTreeEntry { 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 { } 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 { if(this.subscribe_mode == ChannelSubscribeMode.SUBSCRIBED) diff --git a/shared/js/ui/client.ts b/shared/js/ui/client.ts index ef4141cb..aaf7da26 100644 --- a/shared/js/ui/client.ts +++ b/shared/js/ui/client.ts @@ -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"; diff --git a/shared/js/ui/frames/chat.ts b/shared/js/ui/frames/chat.ts index a4e6ba7c..615f1c4c 100644 --- a/shared/js/ui/frames/chat.ts +++ b/shared/js/ui/frames/chat.ts @@ -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, { diff --git a/shared/js/ui/modal/ModalChangeVolumeNew.tsx b/shared/js/ui/modal/ModalChangeVolumeNew.tsx index 5cbe4d24..9f58db5d 100644 --- a/shared/js/ui/modal/ModalChangeVolumeNew.tsx +++ b/shared/js/ui/modal/ModalChangeVolumeNew.tsx @@ -39,7 +39,7 @@ interface VolumeChangeModalState { } @ReactEventHandler(e => e.props.events) -class VolumeChangeModal extends React.Component<{ clientName: string, remote: boolean, events: Registry }, VolumeChangeModalState> { +class VolumeChangeModal extends React.Component<{ clientName: string, maxVolume?: number, remote: boolean, events: Registry }, VolumeChangeModalState> { private readonly refSlider = React.createRef(); 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 ; + return ; } title(): string { diff --git a/shared/js/ui/modal/ModalIconSelect.ts b/shared/js/ui/modal/ModalIconSelect.ts index 9f518b87..98bcf33a 100644 --- a/shared/js/ui/modal/ModalIconSelect.ts +++ b/shared/js/ui/modal/ModalIconSelect.ts @@ -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(); diff --git a/shared/js/ui/modal/transfer/FileBrowser.tsx b/shared/js/ui/modal/transfer/FileBrowser.tsx new file mode 100644 index 00000000..190d6e91 --- /dev/null +++ b/shared/js/ui/modal/transfer/FileBrowser.tsx @@ -0,0 +1,1049 @@ +import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events"; +import {useEffect, useRef, useState} from "react"; +import {FileType} from "tc-shared/file/FileManager"; +import * as ppt from "tc-backend/ppt"; +import {SpecialKey} from "tc-shared/PPTListener"; +import {createErrorModal} from "tc-shared/ui/elements/Modal"; +import {tra} from "tc-shared/i18n/localize"; +import {network} from "tc-shared/ui/frames/chat"; +import {Table, TableColumn, TableRow, TableRowElement} from "tc-shared/ui/react-elements/Table"; +import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase"; +import {Translatable} from "tc-shared/ui/react-elements/i18n"; +import * as Moment from "moment"; +import {MenuEntryType, spawn_context_menu} from "tc-shared/ui/elements/ContextMenu"; +import {BoxedInputField} from "tc-shared/ui/react-elements/InputField"; +import { + FileBrowserEvents, + FileTransferUrlMediaType, + ListedFileInfo, + TransferStatus +} from "tc-shared/ui/modal/transfer/ModalFileTransfer"; +import * as log from "tc-shared/log"; +import {LogCategory} from "tc-shared/log"; +import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; +import React = require("react"); + +const cssStyle = require("./ModalFileTransfer.scss"); + +interface NavigationBarProperties { + currentPath: string; + events: Registry; +} + +interface NavigationBarState { + currentPath: string; + + state: "editing" | "navigating" | "normal"; +} + +const ArrowRight = () =>
; + +const NavigationEntry = (props: { events: Registry, path: string, name: string }) => { + const [dragHovered, setDragHovered] = useState(false); + + useEffect(() => { + if(!dragHovered) + return; + + const dragListener = () => setDragHovered(false); + props.events.on("notify_drag_ended", dragListener); + return () => props.events.off("notify_drag_ended", dragListener); + }); + + return ( + 9 ? cssStyle.pathShrink : "") + " " + (dragHovered ? cssStyle.hovered : "")} + title={props.name} + onClick={() => props.events.fire("action_navigate_to", { path: props.path })} + onDragOver={event => { + const types = event.dataTransfer.types; + if(types.length !== 1) + return; + + if(types[0] === FileTransferUrlMediaType) { + /* TODO: Detect if its remote move or internal move */ + event.dataTransfer.effectAllowed = "move"; + } else if(types[0] === "Files") { + event.dataTransfer.effectAllowed = "copy"; + } else { + return; + } + + event.preventDefault(); + setDragHovered(true); + }} + onDragLeave={() => setDragHovered(false)} + onDrop={event => { + const types = event.dataTransfer.types; + if(types.length !== 1) + return; + + /* TODO: Fix this code duplicate! */ + if(types[0] === FileTransferUrlMediaType) { + /* TODO: If cross move upload! */ + const fileUrls = event.dataTransfer.getData(FileTransferUrlMediaType).split("&").map(e => decodeURIComponent(e)); + for(const fileUrl of fileUrls) { + const name = fileUrl.split("/").last(); + const oldPath = fileUrl.split("/").slice(0, -1).join("/") + "/"; + + props.events.fire("action_rename_file", { + newPath: props.path + "/", + oldPath: oldPath, + oldName: name, + newName: name + }); + } + } else if(types[0] === "Files") { + props.events.fire("action_start_upload", { path: props.path, mode: "files", files: [...event.dataTransfer.files] }); + } else { + log.warn(LogCategory.FILE_TRANSFER, tr("Received an unknown drop media type (%o)"), types); + event.preventDefault(); + return; + } + event.preventDefault(); + }} + >{props.name} + ); +}; + +@ReactEventHandler(e => e.props.events) +export class NavigationBar extends ReactComponentBase { + private refRendered = React.createRef(); + private refInput = React.createRef(); + private ignoreBlur = false; + private lastSucceededPath = ""; + + protected defaultState(): NavigationBarState { + return { + currentPath: this.props.currentPath, + state: "normal", + } + } + + render() { + let input; + let path = this.state.currentPath; + if(!path.endsWith("/")) + path += "/"; + + if(this.state.state === "editing") { + input = ( + +
+
+
+ } + + rightIcon={() => +
+
+
+ } + + onChange={path => this.onPathEntered(path)} + onBlur={() => this.onInputPathBluer()} + /> + ); + } else if(this.state.state === "navigating" || this.state.state === "normal") { + input = ( + +
this.onPathClicked(event, -1)}> +
+
+ } + + rightIcon={() => +
this.onButtonRefreshClicked()} > +
+
+ } + + inputBox={() => +
+ {this.state.currentPath.split("/").filter(e => !!e).map((e, index, arr) => [ + , + + ])} +
+ } + + editable={this.state.state === "normal"} + onFocus={() => this.onRenderedPathClicked()} + /> + ); + } + + return
{input}
; + } + + componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any): void { + setTimeout(() => { + if(this.refRendered.current) + this.refRendered.current.scrollLeft = 999999; + }, 10); + } + + private onPathClicked(event: React.MouseEvent, index: number) { + let path; + if(index === -1) + path = "/"; + else + path = "/" + this.state.currentPath.split("/").filter(e => !!e).slice(0, index + 1).join("/") + "/"; + this.props.events.fire("action_navigate_to", { path: path }); + + event.stopPropagation(); + } + + private onRenderedPathClicked() { + if(this.state.state !== "normal") + return; + + this.setState({ + state: "editing" + }, () => this.refInput.current?.focusInput()); + } + + private onInputPathBluer() { + if(this.state.state !== "editing" || this.ignoreBlur) + return; + + this.setState({ + state: "normal" + }); + } + + private onPathEntered(newPath: string) { + if(newPath === this.state.currentPath) + return; + + this.ignoreBlur = true; + this.props.events.fire("action_navigate_to", { path: newPath }); + this.setState({ + currentPath: newPath + }); + } + + private onButtonRefreshClicked() { + if(this.state.state !== "normal") + return; + + this.props.events.fire("action_navigate_to", { path: this.state.currentPath }); + } + + @EventHandler("action_navigate_to") + private handleNavigateBegin() { + this.setState({ + state: "navigating" + }, () => this.ignoreBlur = false); + } + + @EventHandler("action_navigate_to_result") + private handleNavigateResult(event: FileBrowserEvents["action_navigate_to_result"]) { + if(event.status === "success") + this.lastSucceededPath = event.path; + + this.setState({ + state: "normal", + currentPath: this.lastSucceededPath + }); + + if(event.status !== "success") { + if(event.status === "timeout") { + createErrorModal(tr("Failed to enter path"), tra("Failed to enter given path.{:br:}Action resulted in a timeout.")).open(); + } else { + createErrorModal(tr("Failed to enter path"), tra("Failed to enter given path:{:br:}{0}", event.error)).open(); + } + } + } +} + +interface FileListTableProperties { + currentPath: string; + events: Registry; +} + +interface FileListTableState { + state: "querying" | "normal" | "error" | "query-timeout" | "no-permissions" | "invalid-password"; + errorMessage?: string; +} + +const FileName = (props: { path: string, events: Registry, file: ListedFileInfo }) => { + const [editing, setEditing] = useState(props.file.mode === "create"); + const [fileName, setFileName] = useState(props.file.name); + const refInput = useRef(); + + let icon; + if(props.file.type === FileType.FILE) { + icon = {tr("File")}; + } else { + switch (props.file.mode) { + case "normal": + icon = {tr("Directory; + break; + + case "create": + case "creating": + case "empty": + icon = {tr("Empty; + break; + + case "password": + icon = {tr("Directory; + break; + + default: + throw tr("Invalid directory state"); + } + } + + let name; + if(editing && props.file.mode !== "creating" && props.file.mode !== "uploading") { + name = { + let name = event.target.value; + setEditing(false); + + if(props.file.mode === "create") { + name = name || props.file.name; + + props.events.fire("action_create_directory", { + path: props.path, + name: name + }); + setFileName(name); + props.file.name = name; + props.file.mode = "creating"; + } else { + if(name.length > 0 && name !== props.file.name) { + props.events.fire("action_rename_file", { oldName: props.file.name, newName: name, oldPath: props.path, newPath: props.path }); + setFileName(name); + } + } + }} + + onKeyPress={event => { + if(event.key === "Enter") { + event.currentTarget.blur(); + return; + } else if(event.key === "/") { + event.preventDefault(); + return; + } + }} + + onPaste={event => { + const input = event.currentTarget; + setTimeout(() => { + input.value = input.value.replace("/", ""); + }); + }} + + draggable={false} + />; + } else { + name = { + if(props.file.virtual || props.file.mode === "creating" || props.file.mode === "uploading") + return; + + if(!ppt.key_pressed(SpecialKey.SHIFT)) + return; + + event.stopPropagation(); + props.events.fire("action_select_files", { mode: "exclusive", files: [{ name: props.file.name, type: props.file.type }]}); + props.events.fire("action_start_rename", { + path: props.path, + name: props.file.name + }); + }}>{fileName}; + } + + props.events.reactUse("action_start_rename", event => setEditing(event.name === props.file.name && event.path === props.path)); + props.events.reactUse("action_rename_file_result", event => { + if(event.oldPath !== props.path || event.oldName !== props.file.name) + return; + + if(event.status === "no-changes") + return; + + if(event.status === "success") { + props.file.name = event.newName; + } else { + setFileName(props.file.name); + if(event.status === "timeout") { + createErrorModal(tr("Failed to rename file"), tra("Failed to rename file.{:br:}Action resulted in a timeout.")).open(); + } else { + createErrorModal(tr("Failed to rename file"), tra("Failed to rename file:{:br:}{0}", event.error)).open(); + } + } + }); + useEffect(() => { + refInput.current?.focus(); + }); + + return <>{icon} {name}; +}; + +const FileSize = (props: { path: string, events: Registry, file: ListedFileInfo }) => { + const [ size, setSize ] = useState(-1); + + props.events.reactUse("notify_transfer_status", event => { + if(event.id !== props.file.transfer?.id) + return; + + if(props.file.transfer?.direction !== "upload") + return; + + switch (event.status) { + case "pending": + setSize(0); + break; + + case "finished": + case "none": + setSize(-1); + break; + } + }); + + props.events.reactUse("notify_transfer_progress", event => { + if(event.id !== props.file.transfer?.id) + return; + + if(props.file.transfer?.direction !== "upload") + return; + + setSize(event.fileSize); + }); + + if(size < 0 && props.file.size < 0) + return unknown; + return {network.format_bytes(size >= 0 ? size : props.file.size, { unit: "B", time: "", exact: false })}; +}; + +const FileTransferIndicator = (props: { file: ListedFileInfo, events: Registry }) => { + const [transferStatus, setTransferStatus] = useState(props.file.transfer?.status || "none"); + const [transferProgress, setTransferProgress] = useState(props.file.transfer?.percent | 0); + + props.events.reactUse("notify_transfer_start", event => { + if(event.path !== props.file.path || event.name !== props.file.name) + return; + + console.error(props.file.transfer); + setTransferStatus("pending"); + }); + + props.events.reactUse("notify_transfer_status", event => { + if(event.id !== props.file.transfer?.id) + return; + + setTransferStatus(event.status); + if(event.status === "finished" || event.status === "errored") + setTransferProgress(100); + }); + + props.events.reactUse("notify_transfer_progress", event => { + if(event.id !== props.file.transfer?.id) + return; + + console.error("Progress: " + event.progress); + setTransferProgress(event.progress); + setTransferStatus(event.status); + }); + + /* reset the status after two seconds */ + useEffect(() => { + if(transferStatus !== "finished" && transferStatus !== "errored") + return; + + const id = setTimeout(() => { + setTransferStatus("none"); + }, 3 * 1000); + return () => clearTimeout(id); + }); + + if(!props.file.transfer) + return null; + + let color; + switch (transferStatus) { + case "pending": + case "transferring": + color = cssStyle.blue; + break; + + case "errored": + color = cssStyle.red; + break; + + case "finished": + color = cssStyle.green; + break; + + case "none": + color = cssStyle.hidden; + break; + } + return ( +
+
+
+ ); +}; + +const FileListEntry = (props: { row: TableRow, columns: TableColumn[], events: Registry }) => { + const file = props.row.userData; + const [hidden, setHidden] = useState(false); + const [selected, setSelected] = useState(false); + const [dropHovered, setDropHovered] = useState(false); + + const onDoubleClicked = () => { + if(file.type === FileType.DIRECTORY) { + if(file.mode === "creating" || file.mode === "create") + return; + + props.events.fire("action_navigate_to", { + path: file.path + file.name + "/" + }); + } else { + if(file.mode === "uploading" || file.virtual) + return; + + props.events.fire("action_start_download", { + files: [{ + path: file.path, + name: file.name + }] + }); + } + }; + + props.events.reactUse("action_select_files", event => { + const contains = event.files.findIndex(e => e.name === file.name && e.type === file.type) !== -1; + if(event.mode === "toggle" && contains) + setSelected(!selected); + else if(event.mode === "exclusive") { + setSelected(contains); + } + }); + + props.events.reactUse("notify_drag_ended", () => setDropHovered(false), dropHovered); + + props.events.reactUse("action_delete_file_result", event => { + event.results.forEach(e => { + if(e.status !== "success") + return; + + if(e.path !== file.path || e.name !== file.name) + return; + + setHidden(true); + }); + }, !hidden); + + if(hidden) + return null; + + return ( + props.events.fire("action_select_files", { files: [{ name: file.name, type: file.type }], mode: ppt.key_pressed(SpecialKey.SHIFT) ? "toggle" : "exclusive" })} + onContextMenu={e => { + if(!selected) { + if(!(e.target instanceof HTMLDivElement)) { + /* explicitly clicked on one file */ + props.events.fire("action_select_files", { files: [{ name: file.name, type: file.type }], mode: ppt.key_pressed(SpecialKey.SHIFT) ? "toggle" : "exclusive" }); + } else { + props.events.fire("action_select_files", { files: [], mode: "exclusive" }); + } + } + + props.events.fire("action_selection_context_menu", { + pageX: e.pageX, + pageY: e.pageY + }); + e.preventDefault(); + }} + draggable={!props.row.userData.virtual} + onDragStart={event => { + if(!selected) { + setSelected(true); + props.events.fire("action_select_files", { files: [{ name: file.name, type: file.type }], mode: "exclusive" }); + } + props.events.fire("notify_drag_started", { event: event.nativeEvent }); + }} + + onDragOver={event => { + const types = event.dataTransfer.types; + if(types.length !== 1) + return; + + if(props.row.userData.type !== FileType.DIRECTORY) { + event.stopPropagation(); + return; + } + + if(types[0] === FileTransferUrlMediaType) { + /* TODO: Detect if its remote move or internal move */ + event.dataTransfer.effectAllowed = "move"; + } else if(types[0] === "Files") { + event.dataTransfer.effectAllowed = "copy"; + } else { + return; + } + + event.preventDefault(); + setDropHovered(true); + }} + onDrop={event => { + const types = event.dataTransfer.types; + if(types.length !== 1) + return; + + if(props.row.userData.type !== FileType.DIRECTORY) { + event.stopPropagation(); + return; + } + + if(types[0] === FileTransferUrlMediaType) { + /* TODO: If cross move upload! */ + console.error(event.dataTransfer.getData(FileTransferUrlMediaType)); + const fileUrls = event.dataTransfer.getData(FileTransferUrlMediaType).split("&").map(e => decodeURIComponent(e)); + for(const fileUrl of fileUrls) { + const name = fileUrl.split("/").last(); + const oldPath = fileUrl.split("/").slice(0, -1).join("/") + "/"; + + props.events.fire("action_rename_file", { + newPath: props.row.userData.path + props.row.userData.name + "/", + oldPath: oldPath, + oldName: name, + newName: name + }); + } + } else if(types[0] === "Files") { + props.events.fire("action_start_upload", { path: props.row.userData.path + props.row.userData.name, mode: "files", files: [...event.dataTransfer.files] }); + } else { + log.warn(LogCategory.FILE_TRANSFER, tr("Received an unknown drop media type (%o)"), types); + event.preventDefault(); + return; + } + event.preventDefault(); + }} + + onDragLeave={() => setDropHovered(false)} + onDragEnd={() => props.events.fire("notify_drag_ended")} + > + + + ); +}; + +@ReactEventHandler(e => e.props.events) +export class FileBrowser extends ReactComponentBase { + private refTable = React.createRef(); + private currentPath: string; + private fileList: ListedFileInfo[]; + private selection: { name: string, type: FileType }[] = []; + + protected defaultState(): FileListTableState { + return { + state: "querying" + }; + } + + render() { + let rows: TableRow[] = []; + + let overlay, overlayOnly; + if(this.state.state === "querying") { + overlayOnly = true; + overlay = () => ( +
+ loading +
+ ); + } else if(this.state.state === "error") { + overlayOnly = true; + overlay = () => ( + + ); + } else if(this.state.state === "query-timeout") { + overlayOnly = true; + overlay = () => ( + + ); + } else if(this.state.state === "no-permissions") { + overlayOnly = true; + overlay = () => ( + + ); + } else if(this.state.state === "invalid-password") { + overlayOnly = true; + overlay = () => ( + + ); + } else if(this.state.state === "normal") { + if(this.fileList.length === 0) { + overlayOnly = true; + overlay = () => ( + + ); + } else { + const directories = this.fileList.filter(e => e.type === FileType.DIRECTORY); + const files = this.fileList.filter(e => e.type === FileType.FILE); + + for(const directory of directories.sort((a, b) => a.name > b.name ? 1 : -1)) { + rows.push({ + columns: { + "name": () => , + "type": () => Directory, + "change-date": () => directory.datetime ? {Moment(directory.datetime).format("DD/MM/YYYY HH:mm")} : undefined + }, + className: cssStyle.directoryEntry, + userData: directory + }) + } + + for(const file of files.sort((a, b) => a.name > b.name ? 1 : -1)) { + rows.push({ + columns: { + "name": () => , + "size": () => , + "type": () => File, + "change-date": () => file.datetime ? {Moment(file.datetime).format("DD/MM/YYYY HH:mm")} : undefined + }, + className: cssStyle.directoryEntry, + userData: file + }) + } + } + } + + return ( +
[ + Name,
+ ], width: 80, className: cssStyle.columnName }, + { name: "type", header: () => [ + Type,
+ ], fixedWidth: "8em", className: cssStyle.columnType }, + { name: "size", header: () => [ + Size,
+ ], fixedWidth: "8em", className: cssStyle.columnSize }, + { name: "change-date", header: () => [ + Last changed,
+ ], fixedWidth: "8em", className: cssStyle.columnChanged }, + ]} + rows={rows} + + bodyOverlayOnly={overlayOnly} + bodyOverlay={overlay} + + hiddenColumns={["type"]} + + onHeaderContextMenu={e => this.onHeaderContextMenu(e)} + onBodyContextMenu={e => this.onBodyContextMenu(e)} + + renderRow={(row: TableRow, columns, uniqueId) => } + /> + ); + } + + componentDidMount(): void { + this.selection = []; + this.currentPath = this.props.currentPath; + this.props.events.fire("query_files", { + path: this.currentPath + }); + } + + private onHeaderContextMenu(event: React.MouseEvent) { + event.preventDefault(); + + const table = this.refTable.current; + spawn_context_menu(event.pageX, event.pageY, { + type: MenuEntryType.CHECKBOX, + name: tr("Size"), + checkbox_checked: table.state.hiddenColumns.findIndex(e => e === "size") === -1, + callback: () => { + table.state.hiddenColumns.toggle("size"); + table.forceUpdate(); + } + }, { + type: MenuEntryType.CHECKBOX, + name: tr("Type"), + checkbox_checked: table.state.hiddenColumns.findIndex(e => e === "type") === -1, + callback: () => { + table.state.hiddenColumns.toggle("type"); + table.forceUpdate(); + } + }, { + type: MenuEntryType.CHECKBOX, + name: tr("Last changed"), + checkbox_checked: table.state.hiddenColumns.findIndex(e => e === "change-date") === -1, + callback: () => { + table.state.hiddenColumns.toggle("change-date"); + table.forceUpdate(); + } + }) + } + + private onBodyContextMenu(event: React.MouseEvent) { + if(event.isDefaultPrevented()) return; + event.preventDefault(); + + this.props.events.fire("action_select_files", { mode: "exclusive", files: [] }); + this.props.events.fire("action_selection_context_menu", { pageY: event.pageY, pageX: event.pageX }); + } + + @EventHandler("action_navigate_to_result") + private handleNavigationResult(event: FileBrowserEvents["action_navigate_to_result"]) { + if(event.status !== "success") + return; + + this.currentPath = event.path; + this.selection = []; + this.props.events.fire("query_files", { + path: event.path + }); + } + + @EventHandler("query_files") + private handleQueryFiles(event: FileBrowserEvents["query_files"]) { + if(event.path !== this.currentPath) + return; + + this.setState({ + state: "querying" + }); + } + + @EventHandler("query_files_result") + private handleQueryFilesResult(event: FileBrowserEvents["query_files_result"]) { + if(event.status === "timeout") { + this.setState({ + state: "query-timeout" + }); + } else if(event.status === "error") { + this.setState({ + state: "error", + errorMessage: event.error || tr("unknown query error") + }); + } else if(event.status === "success") { + this.fileList = event.files; + this.setState({ + state: "normal" + }); + } else if(event.status === "no-permissions") { + this.setState({ + state: "no-permissions", + errorMessage: event.error || tr("unknown") + }); + } else if(event.status === "invalid-password") { + this.setState({ + state: "invalid-password" + }); + } else { + this.setState({ + state: "error", + errorMessage: tr("invalid query result state") + }); + } + } + + @EventHandler("action_delete_file_result") + private handleActionDeleteResult(event: FileBrowserEvents["action_delete_file_result"]) { + event.results.forEach(e => { + const index = this.fileList.findIndex(e1 => e1.name === e.name && e1.path === e.path); + if(index === -1) + return; + + if(e.status === "success") + this.fileList.splice(index, 1); + }); + + event.results.forEach(e => { + if(e.status === "success") + return; + + createErrorModal(tr("Failed to delete entry"), tra("Failed to delete \"{0}\":{:br:}{1}", e.name, e.error || tr("Unknown error"))).open(); + }); + } + + @EventHandler("action_start_create_directory") + private handleActionFileCreateBegin(event: FileBrowserEvents["action_start_create_directory"]) { + let index = 0; + while(this.fileList.find(e => e.name === (event.defaultName + (index > 0 ? " (" + index + ")" : "")))) + index++; + + const name = event.defaultName + (index > 0 ? " (" + index + ")" : ""); + this.fileList.push({ + name: name, + path: this.currentPath, + type: FileType.DIRECTORY, + size: 0, + datetime: Date.now(), + virtual: false, + mode: "create" + }); + + /* fire_async because our children have to render first in order to have the row selected! */ + this.forceUpdate(() => this.props.events.fire_async("action_select_files", { files: [{ name: name, type: FileType.DIRECTORY }] , mode: "exclusive" })); + } + + @EventHandler("action_create_directory_result") + private handleActionFileCreateResult(event: FileBrowserEvents["action_create_directory_result"]) { + let fileIndex = this.fileList.slice().reverse().findIndex(e => e.path === event.path && e.name === event.name); + if(fileIndex === -1) + return; + fileIndex = this.fileList.length - fileIndex - 1; + + const file = this.fileList[fileIndex]; + if(event.status === "success") { + if(file.mode === "creating") + file.mode = "empty"; + return; + } else if(file.mode !== "creating") + return; + + this.fileList.splice(fileIndex, 1); + this.forceUpdate(); + + if(event.status === "timeout") { + createErrorModal(tr("Failed to create directory"), tra("Failed to create directory.{:br:}Action resulted in a timeout.")).open(); + } else { + createErrorModal(tr("Failed to create directory"), tra("Failed to create directory:{:br:}{0}", event.error)).open(); + } + } + + @EventHandler("action_select_files") + private handleActionSelectFiles(event: FileBrowserEvents["action_select_files"]) { + if(event.mode === "exclusive") { + this.selection = event.files.slice(0); + } else if(event.mode === "toggle") { + event.files.forEach(e => { + const index = this.selection.map(e => e.name).findIndex(b => b === e.name); + if(index === -1) + this.selection.push(e); + else + this.selection.splice(index); + }); + } + } + + @EventHandler("notify_drag_started") + private handleNotifyDragStarted(event: FileBrowserEvents["notify_drag_started"]) { + if(this.selection.length === 0) { + event.event.preventDefault(); + return; + } else { + const url = this.selection.map(e => encodeURIComponent(this.currentPath + e.name)).join("&"); + event.event.dataTransfer.setData(FileTransferUrlMediaType, url); + } + } + + @EventHandler("action_rename_file_result") + private handleFileRenameResult(event: FileBrowserEvents["action_rename_file_result"]) { + if(event.oldPath !== this.currentPath && event.newPath !== this.currentPath) + return; + + if(event.status !== "success") + return; + + if(event.oldPath === event.newPath) { + const index = this.selection.findIndex(e => e.name === event.oldName); + if(index !== -1) + this.selection[index].name = event.newName; + } else { + /* re query files, because list has changed */ + this.props.events.fire("query_files", { path: this.currentPath }); + } + } + + @EventHandler("notify_transfer_start") + private handleTransferStart(event: FileBrowserEvents["notify_transfer_start"]) { + if(event.path !== this.currentPath) + return; + + let entry = this.fileList.find(e => e.name === event.name); + if(!entry) { + if(event.mode !== "upload") { + log.warn(LogCategory.FILE_TRANSFER, tr("Having file download start notification for current path, but target file is unknown (%s%s)"), event.path, event.name); + return; + } + + entry = { + name: event.name, + path: event.path, + + type: FileType.FILE, + mode: "uploading", + virtual: true, + datetime: Date.now(), + size: -1 + }; + this.fileList.push(entry); + this.forceUpdate(); + } + + entry.transfer = { + status: "pending", + direction: event.mode, + id: event.id, + percent: 0 + }; + } + + @EventHandler("notify_transfer_status") + private handleTransferStatus(event: FileBrowserEvents["notify_transfer_status"]) { + const index = this.fileList.findIndex(e => e.transfer?.id === event.id); + if(index === -1) + return; + + let element = this.fileList[index]; + if(event.status === "errored") { + if(element.mode === "uploading") { + /* re query files, because we don't know what the server did with the errored upload */ + this.props.events.fire("query_files", { path: this.currentPath }); + return; + } + } else { + element.transfer.status = event.status; + if(element.mode === "uploading") { + /* upload finished, the element rerenders already with the correct values */ + element.size = event.fileSize; + element.mode = "normal"; + } + } + } +} + diff --git a/shared/js/ui/modal/transfer/ModalFileTransfer.scss b/shared/js/ui/modal/transfer/ModalFileTransfer.scss new file mode 100644 index 00000000..26713d17 --- /dev/null +++ b/shared/js/ui/modal/transfer/ModalFileTransfer.scss @@ -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; + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/transfer/ModalFileTransfer.tsx b/shared/js/ui/modal/transfer/ModalFileTransfer.tsx new file mode 100644 index 00000000..364ad5f4 --- /dev/null +++ b/shared/js/ui/modal/transfer/ModalFileTransfer.tsx @@ -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(); + readonly transferInfoEvents = new Registry(); + + 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
+ + + +
+ } +} + +export function spawnFileTransferModal(channel: number) { + const modal = spawnReactModal(FileTransferModal, channel); + modal.show(); +} \ No newline at end of file diff --git a/shared/js/ui/modal/transfer/RemoteFileBrowserController.ts b/shared/js/ui/modal/transfer/RemoteFileBrowserController.ts new file mode 100644 index 00000000..0b3532a8 --- /dev/null +++ b/shared/js/ui/modal/transfer/RemoteFileBrowserController.ts @@ -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) { + 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; + 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)); + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/transfer/TransferInfo.scss b/shared/js/ui/modal/transfer/TransferInfo.scss new file mode 100644 index 00000000..a474a35b --- /dev/null +++ b/shared/js/ui/modal/transfer/TransferInfo.scss @@ -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; + } + } + } + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/transfer/TransferInfo.tsx b/shared/js/ui/modal/transfer/TransferInfo.tsx new file mode 100644 index 00000000..575a84c9 --- /dev/null +++ b/shared/js/ui/modal/transfer/TransferInfo.tsx @@ -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}) => { + const [expended, setExpended] = useState(props.extended); + + props.events.reactUse("action_toggle_expansion", event => setExpended(event.visible)); + return
props.events.fire("action_toggle_expansion", { visible: !expended })}> + {iconArrow} +
; +}; + +const ToggleFinishedTransfersCheckbox = (props: { events: Registry }) => { + const ref = useRef(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 ( + props.events.fire("action_toggle_finished_transfers", { visible: state })} + label={Show finished transfers} /> + ); +}; + +@ReactEventHandler(e => e.props.events) +class RunningTransfersInfo extends React.Component<{ events: Registry }, { 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 ( + + ); + } else if(this.state.state === "error") { + return ( + + ); + } else if(this.runningTransfers.length === 0) { + return ( + + ); + } + + 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 ( +
+ +
+ ); + } + + @EventHandler("query_transfers") + private handleQueryTransfers() { + this.setState({ state: "querying" }); + } + + @EventHandler("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("notify_transfer_registered") + private handleTransferRegistered(event: TransferInfoEvents["notify_transfer_registered"]) { + this.runningTransfers.push({ transfer: event.transfer, progress: undefined }); + this.forceUpdate(); + } + + @EventHandler("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("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 }) => { + const [extendedInfo, setExtendedInfo] = useState(false); + + props.events.reactUse("action_toggle_expansion", event => setExtendedInfo(event.visible)); + + return ( +
+ +
+ +
+
+ ) +}; + +const BottomBar = (props: { events: Registry }) => ( +
+ + +
+); + +const TransferEntry = (props: { transfer: TransferInfoData, events: Registry, finishedShown: boolean }) => { + const [finishedShown, setFinishedShown] = useState(props.finishedShown); + const [transferState, setTransferState] = useState(props.transfer.status); + const [finishAnimationFinished, setFinishAnimationFinished] = useState(props.transfer.status === "finished" || props.transfer.status === "errored"); + + const progressBar = useRef(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
+
+
+ {props.transfer.direction === "upload" ? iconTransferUpload : iconTransferDownload} +
+ +
+
+}; + +@ReactEventHandler(e => e.props.events) +class TransferList extends React.PureComponent<{ events: Registry }, { 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(); + } else if(this.state.state === "loading") { + entries.push(); + } else { + this.transfers.forEach(e => { + entries.push(); + }); + entries.push(); + } + + return ( +
{entries}
+ ); + } + + componentDidMount(): void { + this.props.events.fire("query_transfers"); + } + + @EventHandler("action_toggle_finished_transfers") + private handleToggleFinishedTransfers(event: TransferInfoEvents["action_toggle_finished_transfers"]) { + this.showFinishedTransfers = event.visible; + } + + + @EventHandler("action_remove_finished") + private handleRemoveFinishedTransfers() { + this.transfers = this.transfers.filter(e => e.status !== "finished" && e.status !== "errored"); + this.forceUpdate(); + } + + @EventHandler("query_transfers") + private handleQueryTransfers() { + this.setState({ state: "loading" }); + } + + @EventHandler("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("notify_transfer_registered") + private handleTransferRegistered(event: TransferInfoEvents["notify_transfer_registered"]) { + this.transfers.splice(0, 0, event.transfer); + this.forceUpdate(); + } + + @EventHandler("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("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 }) => { + 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
+
+ {finishedShown ? File transfers : Running file transfers} + +
+ +
; +}; + +export const TransferInfo = (props: { events: Registry }) => ( +
+ + +
+); \ No newline at end of file diff --git a/shared/js/ui/modal/transfer/TransferInfoController.ts b/shared/js/ui/modal/transfer/TransferInfoController.ts new file mode 100644 index 00000000..07e45087 --- /dev/null +++ b/shared/js/ui/modal/transfer/TransferInfoController.ts @@ -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) => { + 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)); + } +}; \ No newline at end of file diff --git a/shared/js/ui/modal/transfer/icon_double_arrow.svg b/shared/js/ui/modal/transfer/icon_double_arrow.svg new file mode 100644 index 00000000..944c8e28 --- /dev/null +++ b/shared/js/ui/modal/transfer/icon_double_arrow.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/shared/js/ui/modal/transfer/icon_transfer_download.svg b/shared/js/ui/modal/transfer/icon_transfer_download.svg new file mode 100644 index 00000000..718cd2bd --- /dev/null +++ b/shared/js/ui/modal/transfer/icon_transfer_download.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/shared/js/ui/modal/transfer/icon_transfer_upload.svg b/shared/js/ui/modal/transfer/icon_transfer_upload.svg new file mode 100644 index 00000000..4e0db3d6 --- /dev/null +++ b/shared/js/ui/modal/transfer/icon_transfer_upload.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/shared/js/ui/react-elements/Checkbox.tsx b/shared/js/ui/react-elements/Checkbox.tsx index 8b92e0b5..4a3bdbb3 100644 --- a/shared/js/ui/react-elements/Checkbox.tsx +++ b/shared/js/ui/react-elements/Checkbox.tsx @@ -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 { @@ -19,17 +21,20 @@ export class Checkbox extends React.Component 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 (