Reworked the file transfer system, added a channel file browser and fixed some minor bugs
13
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)
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
<svg version="1.1" fill="#7289da" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="438.533px" height="438.533px" viewBox="0 0 438.533 438.533" style="enable-background:new 0 0 438.533 438.533;"
|
||||
xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<path d="M396.283,130.188c-3.806-9.135-8.371-16.365-13.703-21.695l-89.078-89.081c-5.332-5.325-12.56-9.895-21.697-13.704
|
||||
C262.672,1.903,254.297,0,246.687,0H63.953C56.341,0,49.869,2.663,44.54,7.993c-5.33,5.327-7.994,11.799-7.994,19.414v383.719
|
||||
c0,7.617,2.664,14.089,7.994,19.417c5.33,5.325,11.801,7.991,19.414,7.991h310.633c7.611,0,14.079-2.666,19.407-7.991
|
||||
c5.328-5.332,7.994-11.8,7.994-19.417V155.313C401.991,147.699,400.088,139.323,396.283,130.188z M255.816,38.826
|
||||
c5.517,1.903,9.418,3.999,11.704,6.28l89.366,89.366c2.279,2.286,4.374,6.186,6.276,11.706H255.816V38.826z M365.449,401.991
|
||||
H73.089V36.545h146.178v118.771c0,7.614,2.662,14.084,7.992,19.414c5.332,5.327,11.8,7.994,19.417,7.994h118.773V401.991z"/>
|
||||
<path d="M319.77,292.355h-201c-2.663,0-4.853,0.855-6.567,2.566c-1.709,1.711-2.568,3.901-2.568,6.563v18.274
|
||||
c0,2.67,0.856,4.859,2.568,6.57c1.715,1.711,3.905,2.567,6.567,2.567h201c2.663,0,4.854-0.856,6.564-2.567s2.566-3.9,2.566-6.57
|
||||
v-18.274c0-2.662-0.855-4.853-2.566-6.563C324.619,293.214,322.429,292.355,319.77,292.355z"/>
|
||||
<path d="M112.202,221.831c-1.709,1.712-2.568,3.901-2.568,6.571v18.271c0,2.666,0.856,4.856,2.568,6.567
|
||||
c1.715,1.711,3.905,2.566,6.567,2.566h201c2.663,0,4.854-0.855,6.564-2.566s2.566-3.901,2.566-6.567v-18.271
|
||||
c0-2.663-0.855-4.854-2.566-6.571c-1.715-1.709-3.905-2.564-6.564-2.564h-201C116.107,219.267,113.917,220.122,112.202,221.831z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
After Width: | Height: | Size: 18 KiB |
|
@ -0,0 +1,7 @@
|
|||
<svg version="1.1" fill="#7289da" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="16px" height="16px" viewBox="0 0 16 16" style="enable-background:new 0 0 16 16;"
|
||||
xml:space="preserve">
|
||||
<g>
|
||||
<path fill="#7289da" d="M13.801 3.864h-5.693v-0.752c0-0.593-0.481-1.074-1.074-1.074h-4.834c-0.593 0-1.074 0.481-1.074 1.074v9.775c0 0.593 0.481 1.074 1.074 1.074h11.602c0.593 0 1.074-0.481 1.074-1.074v-7.949c-0-0.593-0.481-1.074-1.074-1.074z"></path>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 518 B |
|
@ -0,0 +1,5 @@
|
|||
<svg fill="#7289da" style="enable-background:new 0 0 16 16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m13.801 3.864h-5.693v-.752c0-.593-.481-1.074-1.074-1.074h-4.834c-.593 0-1.074.481-1.074 1.074v9.775c0 .593.481 1.074 1.074 1.074h11.602c.593 0 1.074-.481 1.074-1.074v-7.949c-0-.593-.481-1.074-1.074-1.074z" fill="#7289da"/>
|
||||
<path d="m6.751 7.5653c0-.352.123-.651.368-.896s.54-.369.885-.369.638.123.882.369c.243.246.364.545.364.896v.952h.832v-.952c0-.383-.093-.735-.28-1.058s-.439-.579-.757-.768c-.319-.189-.667-.284-1.044-.284s-.725.095-1.044.284-.571.445-.758.768-.28.676-.28 1.058v.952h.833v-.952z" fill="#f2f2f2"/>
|
||||
<path d="m5.409 8.4193h5.185c.076.017.143.054.2.112.08.081.12.181.12.3v2.953c0 .118-.04.219-.12.3s-.178.122-.296.122h-4.997c-.113 0-.21-.04-.293-.122s-.124-.182-.124-.3v-2.953c0-.119.041-.218.124-.3.059-.058.125-.095.2-.112zm3.047 1.149c0-.169-.137-.307-.307-.307h-.298c-.169 0-.307.138-.307.307v1.586c0 .169.138.307.307.307h.298c.169 0 .307-.137.307-.307z" fill="#f2f2f2"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.0 KiB |
|
@ -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
|
||||
const transfer = this.fileManager.initializeFileUpload({
|
||||
name: "/avatar",
|
||||
path: "",
|
||||
|
||||
channel: 0,
|
||||
channelPassword: undefined,
|
||||
|
||||
source: async () => await TransferProvider.provider().createBufferSource(data)
|
||||
});
|
||||
} 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"]);
|
||||
|
||||
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 {
|
||||
message = formatMessage(tr("Failed to initialize avatar upload.{:br:}Error: {0}"), error.extra_message || error.message);
|
||||
}
|
||||
}
|
||||
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();
|
||||
createErrorModal(tr("Failed to upload avatar"), tr("Avatar upload finished with an unknown finished state.")).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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import {ClientEvents, MusicClientEntry, SongInfo} from "tc-shared/ui/client";
|
||||
import {guid} from "tc-shared/crypto/uid";
|
||||
import * as React from "react";
|
||||
import {useEffect} from "react";
|
||||
|
||||
export interface Event<Events, T = keyof Events> {
|
||||
readonly type: T;
|
||||
|
@ -91,6 +92,20 @@ export class Registry<Events> {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/* special helper methods for react components */
|
||||
reactUse<T extends keyof Events>(event: T, handler: (event?: Events[T] & Event<Events, T>) => void, condition?: boolean) {
|
||||
if(typeof condition === "boolean" && !condition) {
|
||||
useEffect(() => {});
|
||||
return;
|
||||
}
|
||||
const handlers = this.handler[event as any] || (this.handler[event as any] = []);
|
||||
useEffect(() => {
|
||||
handlers.push(handler);
|
||||
return () => handlers.remove(handler);
|
||||
});
|
||||
}
|
||||
|
||||
connect<EOther, T extends keyof Events & keyof EOther>(events: T | T[], target: Registry<EOther>) {
|
||||
for(const event of Array.isArray(events) ? events : [events])
|
||||
(this.connections[event as string] || (this.connections[event as string] = [])).push(target as any);
|
||||
|
@ -178,8 +193,10 @@ export class Registry<Events> {
|
|||
this.on(event, ev_handler);
|
||||
}
|
||||
}
|
||||
if(Object.keys(registered_events).length === 0)
|
||||
throw "no events found in event handler";
|
||||
if(Object.keys(registered_events).length === 0) {
|
||||
console.warn(tr("no events found in event handler"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.event_handler_objects.push({
|
||||
handlers: registered_events,
|
||||
|
@ -189,7 +206,8 @@ export class Registry<Events> {
|
|||
|
||||
unregister_handler(handler: any) {
|
||||
const data = this.event_handler_objects.find(e => e.object === handler);
|
||||
if(!data) throw "unknown event handler";
|
||||
if(!data) return;
|
||||
|
||||
this.event_handler_objects.remove(data);
|
||||
|
||||
for(const key of Object.keys(data.handlers)) {
|
||||
|
|
|
@ -2,11 +2,18 @@ import * as log from "tc-shared/log";
|
|||
import {LogCategory} from "tc-shared/log";
|
||||
import {ClientEntry} from "tc-shared/ui/client";
|
||||
import * as hex from "tc-shared/crypto/hex";
|
||||
import {
|
||||
DownloadKey,
|
||||
FileManager, transfer_provider
|
||||
} from "tc-shared/file/FileManager";
|
||||
import {image_type, ImageCache, ImageType, media_image_type} from "tc-shared/file/ImageCache";
|
||||
import {FileManager} from "tc-shared/file/FileManager";
|
||||
import {
|
||||
FileDownloadTransfer,
|
||||
FileTransferState,
|
||||
ResponseTransferTarget, TransferProvider,
|
||||
TransferTargetType
|
||||
} from "tc-shared/file/Transfer";
|
||||
import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
|
||||
import {tra} from "tc-shared/i18n/localize";
|
||||
import {server_connections} from "tc-shared/ui/frames/connection_handlers";
|
||||
import {icon_cache_loader} from "tc-shared/file/Icons";
|
||||
|
||||
export class Avatar {
|
||||
client_avatar_id: string; /* the base64 uid thing from a-m */
|
||||
|
@ -47,11 +54,11 @@ export class AvatarManager {
|
|||
}
|
||||
|
||||
async resolved_cached?(client_avatar_id: string, avatar_version?: string) : Promise<Avatar> {
|
||||
let avatar: Avatar = this._cached_avatars[avatar_version];
|
||||
if(avatar) {
|
||||
if(typeof(avatar_version) !== "string" || avatar.avatar_id == avatar_version)
|
||||
return avatar;
|
||||
avatar = undefined;
|
||||
let cachedAvatar: Avatar = this._cached_avatars[avatar_version];
|
||||
if(cachedAvatar) {
|
||||
if(typeof(avatar_version) !== "string" || cachedAvatar.avatar_id == avatar_version)
|
||||
return cachedAvatar;
|
||||
delete this._cached_avatars[avatar_version];
|
||||
}
|
||||
|
||||
if(!AvatarManager.cache.setupped())
|
||||
|
@ -74,37 +81,66 @@ export class AvatarManager {
|
|||
};
|
||||
}
|
||||
|
||||
create_avatar_download(client_avatar_id: string) : Promise<DownloadKey> {
|
||||
create_avatar_download(client_avatar_id: string) : FileDownloadTransfer {
|
||||
log.debug(LogCategory.GENERAL, "Requesting download for avatar %s", client_avatar_id);
|
||||
return this.handle.download_file("", "/avatar_" + client_avatar_id);
|
||||
|
||||
return this.handle.initializeFileDownload({
|
||||
path: "",
|
||||
name: "/avatar_" + client_avatar_id,
|
||||
targetSupplier: async () => await TransferProvider.provider().createResponseTarget()
|
||||
});
|
||||
}
|
||||
|
||||
private async _load_avatar(client_avatar_id: string, avatar_version: string) {
|
||||
try {
|
||||
let download_key: DownloadKey;
|
||||
let transfer = this.create_avatar_download(client_avatar_id);
|
||||
|
||||
try {
|
||||
download_key = await this.create_avatar_download(client_avatar_id);
|
||||
await transfer.awaitFinished();
|
||||
|
||||
if(transfer.transferState() === FileTransferState.CANCELED) {
|
||||
throw tr("download canceled");
|
||||
} else if(transfer.transferState() === FileTransferState.ERRORED) {
|
||||
throw transfer.currentError();
|
||||
} else if(transfer.transferState() === FileTransferState.FINISHED) {
|
||||
|
||||
} else {
|
||||
throw tr("Unknown transfer finished state");
|
||||
}
|
||||
} catch(error) {
|
||||
log.error(LogCategory.GENERAL, tr("Could not request download for avatar %s: %o"), client_avatar_id, error);
|
||||
throw "failed to request avatar download";
|
||||
if(typeof error === "object" && 'error' in error && error.error === "initialize") {
|
||||
const commandResult = error.commandResult;
|
||||
if(commandResult instanceof CommandResult) {
|
||||
if(commandResult.id === ErrorID.FILE_NOT_FOUND)
|
||||
throw tr("Avatar could not be found");
|
||||
else if(commandResult.id === ErrorID.PERMISSION_ERROR)
|
||||
throw tr("No permissions to download avatar");
|
||||
else
|
||||
throw commandResult.message + (commandResult.extra_message ? " (" + commandResult.extra_message + ")" : "");
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
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 type = image_type(response.headers.get('X-media-bytes'));
|
||||
/* 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");
|
||||
|
||||
const type = image_type(response.getResponse().headers.get('X-media-bytes'));
|
||||
const media = media_image_type(type);
|
||||
|
||||
await AvatarManager.cache.put_cache('avatar_' + client_avatar_id, response.clone(), "image/" + media, {
|
||||
await AvatarManager.cache.put_cache('avatar_' + client_avatar_id, response.getResponse().clone(), "image/" + media, {
|
||||
"X-avatar-version": avatar_version
|
||||
});
|
||||
const url = await this._response_url(response.clone(), type);
|
||||
const url = await this._response_url(response.getResponse().clone(), type);
|
||||
|
||||
return this._cached_avatars[client_avatar_id] = {
|
||||
client_avatar_id: client_avatar_id,
|
||||
|
@ -249,9 +285,9 @@ export class AvatarManager {
|
|||
generate_chat_tag(client: { id?: number; database_id?: number; }, client_unique_id: string, callback_loaded?: (successfully: boolean, error?: any) => any) : JQuery {
|
||||
let client_handle;
|
||||
if(typeof(client.id) == "number")
|
||||
client_handle = this.handle.handle.channelTree.findClient(client.id);
|
||||
client_handle = this.handle.connectionHandler.channelTree.findClient(client.id);
|
||||
if(!client_handle && typeof(client.id) == "number") {
|
||||
client_handle = this.handle.handle.channelTree.find_client_by_dbid(client.database_id);
|
||||
client_handle = this.handle.connectionHandler.channelTree.find_client_by_dbid(client.database_id);
|
||||
}
|
||||
|
||||
if(client_handle && client_handle.clientUid() !== client_unique_id)
|
||||
|
@ -314,4 +350,14 @@ export class AvatarManager {
|
|||
|
||||
return container;
|
||||
}
|
||||
|
||||
flush_cache() {
|
||||
this._cached_avatars = undefined;
|
||||
this._loading_promises = undefined;
|
||||
}
|
||||
}
|
||||
(window as any).flush_avatar_cache = async () => {
|
||||
server_connections.all_connections().forEach(e => {
|
||||
e.fileManager.avatars.flush_cache();
|
||||
});
|
||||
};
|
|
@ -3,12 +3,14 @@ import {LogCategory} from "tc-shared/log";
|
|||
import {Registry} from "tc-shared/events";
|
||||
import {format_time} from "tc-shared/ui/frames/chat";
|
||||
import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
|
||||
import {
|
||||
DownloadKey,
|
||||
FileEntry,
|
||||
FileManager, transfer_provider
|
||||
} from "tc-shared/file/FileManager";
|
||||
import {image_type, ImageCache, ImageType, media_image_type} from "tc-shared/file/ImageCache";
|
||||
import {FileInfo, FileManager} from "tc-shared/file/FileManager";
|
||||
import {
|
||||
FileDownloadTransfer,
|
||||
FileTransferState, ResponseTransferTarget, TransferProvider,
|
||||
TransferTargetType
|
||||
} from "tc-shared/file/Transfer";
|
||||
import {server_connections} from "tc-shared/ui/frames/connection_handlers";
|
||||
|
||||
const icon_cache: ImageCache = new ImageCache("icons");
|
||||
export interface IconManagerEvents {
|
||||
|
@ -215,6 +217,15 @@ window.addEventListener("beforeunload", () => {
|
|||
icon_cache_loader.clear_memory_cache();
|
||||
});
|
||||
|
||||
(window as any).flush_icon_cache = async () => {
|
||||
icon_cache_loader.clear_memory_cache();
|
||||
await icon_cache_loader.clear_cache();
|
||||
|
||||
server_connections.all_connections().forEach(e => {
|
||||
e.fileManager.icons.flush_cache();
|
||||
});
|
||||
};
|
||||
|
||||
type IconManagerLoadingData = {
|
||||
result: "success" | "error" | "unset";
|
||||
next_retry?: number;
|
||||
|
@ -238,17 +249,21 @@ export class IconManager {
|
|||
if(id <= 1000)
|
||||
throw "invalid id!";
|
||||
|
||||
await this.handle.delete_file({
|
||||
await this.handle.deleteFile({
|
||||
name: '/icon_' + id
|
||||
});
|
||||
}
|
||||
|
||||
iconList() : Promise<FileEntry[]> {
|
||||
iconList() : Promise<FileInfo[]> {
|
||||
return this.handle.requestFileList("/icons");
|
||||
}
|
||||
|
||||
create_icon_download(id: number) : Promise<DownloadKey> {
|
||||
return this.handle.download_file("", "/icon_" + id);
|
||||
createIconDownload(id: number) : FileDownloadTransfer {
|
||||
return this.handle.initializeFileDownload({
|
||||
path: "",
|
||||
name: "/icon_" + id,
|
||||
targetSupplier: async () => await TransferProvider.provider().createResponseTarget()
|
||||
});
|
||||
}
|
||||
|
||||
private async server_icon_loader(icon: LocalIcon) : Promise<Response> {
|
||||
|
@ -262,9 +277,20 @@ export class IconManager {
|
|||
}
|
||||
|
||||
try {
|
||||
let download_key: DownloadKey;
|
||||
let transfer = this.createIconDownload(icon.icon_id);
|
||||
|
||||
try {
|
||||
download_key = await this.create_icon_download(icon.icon_id);
|
||||
await transfer.awaitFinished();
|
||||
|
||||
if(transfer.transferState() === FileTransferState.CANCELED) {
|
||||
throw tr("download canceled");
|
||||
} else if(transfer.transferState() === FileTransferState.ERRORED) {
|
||||
throw transfer.currentError();
|
||||
} else if(transfer.transferState() === FileTransferState.FINISHED) {
|
||||
|
||||
} else {
|
||||
throw tr("Unknown transfer finished state");
|
||||
}
|
||||
} catch(error) {
|
||||
if(error instanceof CommandResult) {
|
||||
if(error.id === ErrorID.FILE_NOT_FOUND)
|
||||
|
@ -275,20 +301,21 @@ export class IconManager {
|
|||
throw error.extra_message || error.message;
|
||||
}
|
||||
log.error(LogCategory.CLIENT, tr("Could not request download for icon %d: %o"), icon.icon_id, error);
|
||||
if(error === transfer.currentError())
|
||||
throw transfer.currentErrorMessage();
|
||||
throw typeof error === "string" ? error : tr("Failed to initialize icon download");
|
||||
}
|
||||
|
||||
const downloader = transfer_provider().spawn_download_transfer(download_key);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await downloader.request_file();
|
||||
} catch(error) {
|
||||
log.error(LogCategory.CLIENT, tr("Could not download icon %d: %o"), icon.icon_id, error);
|
||||
throw "failed to download icon";
|
||||
}
|
||||
/* could only be tested here, because before we don't know which target we have */
|
||||
if(transfer.target.type !== TransferTargetType.RESPONSE)
|
||||
throw "unsupported transfer target";
|
||||
|
||||
const response = transfer.target as ResponseTransferTarget;
|
||||
if(!response.hasResponse())
|
||||
throw tr("Transfer has no response");
|
||||
|
||||
loading_data.result = "success";
|
||||
return response;
|
||||
return response.getResponse();
|
||||
} catch (error) {
|
||||
loading_data.result = "error";
|
||||
loading_data.error = error as string;
|
||||
|
@ -365,7 +392,7 @@ export class IconManager {
|
|||
}
|
||||
|
||||
load_icon(id: number) : LocalIcon {
|
||||
const server_uid = this.handle.handle.channelTree.server.properties.virtualserver_unique_identifier;
|
||||
const server_uid = this.handle.connectionHandler.channelTree.server.properties.virtualserver_unique_identifier;
|
||||
let icon = icon_cache_loader.load_icon(id, server_uid, this.server_icon_loader.bind(this));
|
||||
if(icon.status !== "loading" && icon.status !== "loaded") {
|
||||
this.server_icon_loader(icon).then(response => {
|
||||
|
@ -376,4 +403,8 @@ export class IconManager {
|
|||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
flush_cache() {
|
||||
this.loading_timestamps = {};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,417 @@
|
|||
import {Registry} from "tc-shared/events";
|
||||
import {CommandResult, ErrorCode} from "tc-shared/connection/ServerConnectionDeclaration";
|
||||
|
||||
/* Transfer source types */
|
||||
export enum TransferSourceType {
|
||||
BROWSER_FILE,
|
||||
BUFFER,
|
||||
TEXT
|
||||
}
|
||||
|
||||
export abstract class TransferSource {
|
||||
readonly type: TransferSourceType;
|
||||
|
||||
protected constructor(type: TransferSourceType) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
abstract fileSize() : Promise<number>;
|
||||
}
|
||||
|
||||
|
||||
export abstract class BrowserFileTransferSource extends TransferSource {
|
||||
protected constructor() {
|
||||
super(TransferSourceType.BROWSER_FILE);
|
||||
}
|
||||
|
||||
abstract getFile() : File;
|
||||
}
|
||||
|
||||
export abstract class BufferTransferSource extends TransferSource {
|
||||
protected constructor() {
|
||||
super(TransferSourceType.BUFFER);
|
||||
}
|
||||
|
||||
abstract getBuffer() : ArrayBuffer;
|
||||
}
|
||||
|
||||
export abstract class TextTransferSource extends TransferSource {
|
||||
protected constructor() {
|
||||
super(TransferSourceType.TEXT);
|
||||
}
|
||||
|
||||
abstract getText() : string;
|
||||
}
|
||||
export type TransferSourceSupplier = (transfer: FileUploadTransfer) => Promise<TransferSource>;
|
||||
|
||||
/* Transfer target types */
|
||||
export enum TransferTargetType {
|
||||
RESPONSE,
|
||||
DOWNLOAD
|
||||
}
|
||||
|
||||
export abstract class TransferTarget {
|
||||
readonly type: TransferTargetType;
|
||||
|
||||
protected constructor(type: TransferTargetType) {
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class DownloadTransferTarget extends TransferTarget {
|
||||
protected constructor() {
|
||||
super(TransferTargetType.DOWNLOAD);
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class ResponseTransferTarget extends TransferTarget {
|
||||
protected constructor() {
|
||||
super(TransferTargetType.RESPONSE);
|
||||
}
|
||||
|
||||
abstract hasResponse() : boolean;
|
||||
abstract getResponse() : Response;
|
||||
}
|
||||
export type TransferTargetSupplier = (transfer: FileDownloadTransfer) => Promise<TransferTarget>;
|
||||
|
||||
export enum FileTransferState {
|
||||
PENDING, /* bending because other transfers already going on */
|
||||
INITIALIZING,
|
||||
CONNECTING,
|
||||
RUNNING,
|
||||
|
||||
FINISHED,
|
||||
ERRORED,
|
||||
CANCELED
|
||||
}
|
||||
|
||||
export enum CancelReason {
|
||||
USER_ACTION,
|
||||
SERVER_DISCONNECTED
|
||||
}
|
||||
|
||||
export enum FileTransferDirection {
|
||||
UPLOAD,
|
||||
DOWNLOAD
|
||||
}
|
||||
|
||||
export interface FileTransferEvents {
|
||||
"notify_state_updated": { oldState: FileTransferState, newState: FileTransferState },
|
||||
"notify_progress": { progress: TransferProgress },
|
||||
|
||||
"action_request_cancel": { reason: CancelReason },
|
||||
"notify_transfer_canceled": {}
|
||||
}
|
||||
|
||||
export interface TransferProperties {
|
||||
channel_id: number | 0;
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface InitializedTransferProperties {
|
||||
serverTransferId: number;
|
||||
transferKey: string;
|
||||
|
||||
addresses: {
|
||||
serverAddress: string;
|
||||
serverPort: number;
|
||||
}[];
|
||||
|
||||
protocol: number; /* should be constant 1 */
|
||||
|
||||
seekOffset: number;
|
||||
fileSize?: number;
|
||||
}
|
||||
|
||||
|
||||
export interface TransferInitializeError {
|
||||
error: "initialize";
|
||||
|
||||
commandResult: string | CommandResult;
|
||||
}
|
||||
|
||||
export interface TransferConnectError {
|
||||
error: "connection";
|
||||
|
||||
reason: "missing-provider" | "provider-initialize-error" | "network-error";
|
||||
extraMessage?: string;
|
||||
}
|
||||
|
||||
export interface TransferIOError {
|
||||
error: "io";
|
||||
|
||||
reason: "unsupported-target" | "failed-to-initialize-target" | "buffer-transfer-failed";
|
||||
extraMessage?: string;
|
||||
}
|
||||
|
||||
export interface TransferErrorStatus {
|
||||
error: "status";
|
||||
|
||||
status: ErrorCode;
|
||||
extraMessage: string;
|
||||
}
|
||||
|
||||
export interface TransferErrorTimeout {
|
||||
error: "timeout";
|
||||
}
|
||||
|
||||
export type TransferErrorType = TransferInitializeError | TransferConnectError | TransferIOError | TransferErrorStatus | TransferErrorTimeout;
|
||||
|
||||
export interface TransferProgress {
|
||||
timestamp: number;
|
||||
|
||||
file_bytes_transferred: number;
|
||||
file_current_offset: number;
|
||||
file_start_offset: number;
|
||||
file_total_size: number;
|
||||
network_bytes_received: number;
|
||||
network_bytes_send: number;
|
||||
|
||||
|
||||
network_current_speed: number;
|
||||
network_average_speed: number;
|
||||
}
|
||||
|
||||
export interface TransferTimings {
|
||||
timestampScheduled: number;
|
||||
timestampExecuted: number;
|
||||
timestampTransferBegin: number;
|
||||
timestampEnd: number;
|
||||
}
|
||||
|
||||
export interface FinishedFileTransfer {
|
||||
readonly clientTransferId: number;
|
||||
readonly timings: TransferTimings;
|
||||
|
||||
readonly properties: TransferProperties;
|
||||
readonly direction: FileTransferDirection;
|
||||
|
||||
readonly state: FileTransferState.CANCELED | FileTransferState.FINISHED | FileTransferState.ERRORED;
|
||||
|
||||
/* only set if state is ERRORED */
|
||||
readonly transferError?: TransferErrorType;
|
||||
readonly transferErrorMessage?: string;
|
||||
|
||||
readonly bytesTransferred: number;
|
||||
}
|
||||
|
||||
export class FileTransfer {
|
||||
readonly events: Registry<FileTransferEvents>;
|
||||
readonly clientTransferId: number;
|
||||
readonly direction: FileTransferDirection;
|
||||
readonly properties: TransferProperties;
|
||||
readonly timings: TransferTimings;
|
||||
|
||||
lastStateUpdate: number;
|
||||
private cancelReason: CancelReason;
|
||||
private transferProperties_: InitializedTransferProperties;
|
||||
private transferError_: TransferErrorType;
|
||||
private transferErrorMessage_: string;
|
||||
private transferState_: FileTransferState;
|
||||
private progress_: TransferProgress;
|
||||
|
||||
protected constructor(direction, clientTransferId, properties) {
|
||||
this.direction = direction;
|
||||
this.clientTransferId = clientTransferId;
|
||||
this.properties = properties;
|
||||
this.timings = {
|
||||
timestampExecuted: 0,
|
||||
timestampTransferBegin: 0,
|
||||
timestampEnd: 0,
|
||||
timestampScheduled: Date.now()
|
||||
};
|
||||
this.setTransferState(FileTransferState.PENDING);
|
||||
|
||||
this.events = new Registry<FileTransferEvents>();
|
||||
this.events.on("notify_transfer_canceled", event => {
|
||||
this.setTransferState(FileTransferState.CANCELED);
|
||||
});
|
||||
}
|
||||
|
||||
isRunning() {
|
||||
return this.transferState_ === FileTransferState.CONNECTING || this.transferState_ === FileTransferState.RUNNING || this.transferState_ === FileTransferState.INITIALIZING;
|
||||
}
|
||||
|
||||
isPending() {
|
||||
return this.transferState_ === FileTransferState.PENDING;
|
||||
}
|
||||
|
||||
isFinished() {
|
||||
return this.transferState() === FileTransferState.FINISHED || this.transferState() === FileTransferState.ERRORED || this.transferState() === FileTransferState.CANCELED;
|
||||
}
|
||||
|
||||
transferState() {
|
||||
return this.transferState_;
|
||||
}
|
||||
|
||||
transferProperties() : InitializedTransferProperties | undefined {
|
||||
return this.transferProperties_;
|
||||
}
|
||||
|
||||
currentError() : TransferErrorType | undefined {
|
||||
return this.transferError_;
|
||||
}
|
||||
|
||||
currentErrorMessage() : string | undefined {
|
||||
return this.transferErrorMessage_;
|
||||
}
|
||||
|
||||
lastProgressInfo() : TransferProgress | undefined {
|
||||
return this.progress_;
|
||||
}
|
||||
|
||||
setFailed(error: TransferErrorType, asMessage: string) {
|
||||
if(this.isFinished())
|
||||
throw tr("invalid transfer state");
|
||||
|
||||
if(typeof asMessage !== "string")
|
||||
debugger;
|
||||
|
||||
this.transferErrorMessage_ = asMessage;
|
||||
this.transferError_ = error;
|
||||
this.setTransferState(FileTransferState.ERRORED);
|
||||
}
|
||||
|
||||
setProperties(properties: InitializedTransferProperties) {
|
||||
if(this.transferState() !== FileTransferState.INITIALIZING)
|
||||
throw tr("invalid transfer state");
|
||||
|
||||
this.transferProperties_ = properties;
|
||||
this.setTransferState(FileTransferState.CONNECTING);
|
||||
}
|
||||
|
||||
requestCancel(reason: CancelReason) {
|
||||
if(this.isFinished())
|
||||
throw tr("invalid transfer state");
|
||||
|
||||
this.cancelReason = reason;
|
||||
this.events.fire("action_request_cancel");
|
||||
}
|
||||
|
||||
setTransferState(newState: FileTransferState) {
|
||||
if(this.transferState_ === newState)
|
||||
return;
|
||||
|
||||
const newIsFinishedState = newState === FileTransferState.CANCELED || newState === FileTransferState.ERRORED || newState === FileTransferState.FINISHED;
|
||||
try {
|
||||
switch (this.transferState_) {
|
||||
case undefined:
|
||||
if(newState !== FileTransferState.PENDING)
|
||||
throw void 0;
|
||||
this.timings.timestampScheduled = Date.now();
|
||||
break;
|
||||
case FileTransferState.PENDING:
|
||||
if(newState !== FileTransferState.INITIALIZING && !newIsFinishedState)
|
||||
throw void 0;
|
||||
break;
|
||||
case FileTransferState.INITIALIZING:
|
||||
if(newState !== FileTransferState.CONNECTING && !newIsFinishedState)
|
||||
throw void 0;
|
||||
break;
|
||||
case FileTransferState.CONNECTING:
|
||||
if(newState !== FileTransferState.RUNNING && !newIsFinishedState)
|
||||
throw void 0;
|
||||
break;
|
||||
case FileTransferState.RUNNING:
|
||||
if(!newIsFinishedState)
|
||||
throw void 0;
|
||||
break;
|
||||
case FileTransferState.FINISHED:
|
||||
case FileTransferState.CANCELED:
|
||||
case FileTransferState.ERRORED:
|
||||
if(this.isFinished())
|
||||
throw void 0;
|
||||
this.timings.timestampEnd = Date.now();
|
||||
break;
|
||||
}
|
||||
|
||||
switch (newState) {
|
||||
case FileTransferState.INITIALIZING:
|
||||
this.timings.timestampExecuted = Date.now();
|
||||
break;
|
||||
|
||||
case FileTransferState.RUNNING:
|
||||
this.timings.timestampTransferBegin = Date.now();
|
||||
break;
|
||||
|
||||
case FileTransferState.FINISHED:
|
||||
case FileTransferState.CANCELED:
|
||||
case FileTransferState.ERRORED:
|
||||
this.timings.timestampEnd = Date.now();
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
throw "invalid transfer state transform from " + this.transferState_ + " to " + newState;
|
||||
return;
|
||||
}
|
||||
|
||||
const oldState = this.transferState_;
|
||||
this.transferState_ = newState;
|
||||
this.events?.fire("notify_state_updated", { oldState: oldState, newState: newState });
|
||||
}
|
||||
|
||||
updateProgress(progress: TransferProgress) {
|
||||
this.progress_ = progress;
|
||||
this.events.fire_async("notify_progress", { progress: progress });
|
||||
}
|
||||
|
||||
awaitFinished() : Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
if(this.isFinished()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const listenerStatus = () => {
|
||||
if(this.isFinished()) {
|
||||
this.events.off("notify_state_updated", listenerStatus);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
this.events.on("notify_state_updated", listenerStatus);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class FileDownloadTransfer extends FileTransfer {
|
||||
public readonly targetSupplier: TransferTargetSupplier;
|
||||
public target: TransferTarget;
|
||||
|
||||
constructor(direction, clientTransferId, properties: TransferProperties, targetSupplier) {
|
||||
super(direction, clientTransferId, properties);
|
||||
this.targetSupplier = targetSupplier;
|
||||
}
|
||||
}
|
||||
|
||||
export class FileUploadTransfer extends FileTransfer {
|
||||
public readonly sourceSupplier: TransferSourceSupplier;
|
||||
public source: TransferSource;
|
||||
public fileSize: number;
|
||||
|
||||
constructor(direction, clientTransferId, properties: TransferProperties, sourceSupplier) {
|
||||
super(direction, clientTransferId, properties);
|
||||
this.sourceSupplier = sourceSupplier;
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class TransferProvider {
|
||||
private static instance_;
|
||||
public static provider() : TransferProvider { return this.instance_; }
|
||||
public static setProvider(provider: TransferProvider) {
|
||||
this.instance_ = provider;
|
||||
}
|
||||
|
||||
abstract executeFileDownload(transfer: FileDownloadTransfer);
|
||||
abstract executeFileUpload(transfer: FileUploadTransfer);
|
||||
|
||||
abstract targetSupported(type: TransferTargetType);
|
||||
abstract sourceSupported(type: TransferSourceType);
|
||||
|
||||
async createResponseTarget() : Promise<ResponseTransferTarget> { throw tr("response target isn't supported"); }
|
||||
async createDownloadTarget(filename?: string) : Promise<DownloadTransferTarget> { throw tr("download target isn't supported"); }
|
||||
|
||||
async createBufferSource(buffer: ArrayBuffer) : Promise<BufferTransferSource> { throw tr("buffer source isn't supported"); }
|
||||
async createTextSource(text: string) : Promise<TextTransferSource> { throw tr("text source isn't supported"); };
|
||||
async createBrowserFileSource(file: File) : Promise<BrowserFileTransferSource> { throw tr("browser file source isn't supported"); }
|
||||
}
|
|
@ -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);
|
||||
for(const element of args) {
|
||||
if(typeof element !== "string" && typeof element !== "number" && typeof element !== "boolean")
|
||||
return formatMessage(message, ...args);
|
||||
}
|
||||
if(message.indexOf("{:") !== -1)
|
||||
return formatMessage(message, ...args);
|
||||
return formatMessageString(message, ...args);
|
||||
}
|
||||
|
||||
export function traj(message: string, ...args: any[]) : JQuery[] {
|
||||
return tra(message, ...args, {});
|
||||
}
|
||||
|
||||
async function load_translation_file(url: string, path: string) : Promise<TranslationFile> {
|
||||
|
|
|
@ -18,7 +18,8 @@ export enum LogCategory {
|
|||
IPC,
|
||||
IDENTITIES,
|
||||
STATISTICS,
|
||||
DNS
|
||||
DNS,
|
||||
FILE_TRANSFER
|
||||
}
|
||||
|
||||
export enum LogType {
|
||||
|
@ -45,7 +46,8 @@ let category_mapping = new Map<number, string>([
|
|||
[LogCategory.IDENTITIES, "Identities "],
|
||||
[LogCategory.IPC, "IPC "],
|
||||
[LogCategory.STATISTICS, "Statistics "],
|
||||
[LogCategory.DNS, "DNS "]
|
||||
[LogCategory.DNS, "DNS "],
|
||||
[LogCategory.FILE_TRANSFER, "FILE_TRANSFER"]
|
||||
]);
|
||||
|
||||
export let enabled_mapping = new Map<number, boolean>([
|
||||
|
@ -64,7 +66,8 @@ export let enabled_mapping = new Map<number, boolean>([
|
|||
[LogCategory.IDENTITIES, true],
|
||||
[LogCategory.IPC, true],
|
||||
[LogCategory.STATISTICS, true],
|
||||
[LogCategory.DNS, true]
|
||||
[LogCategory.DNS, true],
|
||||
[LogCategory.FILE_TRANSFER, true]
|
||||
]);
|
||||
|
||||
//Values will be overridden by initialize()
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
|
@ -6,6 +6,7 @@ declare global {
|
|||
last?(): T;
|
||||
|
||||
pop_front(): T | undefined;
|
||||
toggle(entry: T) : boolean;
|
||||
}
|
||||
|
||||
interface JSON {
|
||||
|
@ -172,6 +173,18 @@ if (!Array.prototype.pop_front) {
|
|||
}
|
||||
}
|
||||
|
||||
if (!Array.prototype.toggle) {
|
||||
Array.prototype.toggle = function<T>(element: T): boolean {
|
||||
const index = this.findIndex(e => e === element);
|
||||
if(index === -1) {
|
||||
this.push(element);
|
||||
return true;
|
||||
} else {
|
||||
this.splice(index, 1);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.prototype.last){
|
||||
Array.prototype.last = function(){
|
||||
|
|
|
@ -354,6 +354,12 @@ export class Settings extends StaticSettings {
|
|||
default_value: "tea-web"
|
||||
};
|
||||
|
||||
static readonly KEY_TRANSFERS_SHOW_FINISHED: SettingsKey<boolean> = {
|
||||
key: 'transfers_show_finished',
|
||||
default_value: true,
|
||||
description: "Show finished file transfers in the file transfer list"
|
||||
};
|
||||
|
||||
static readonly FN_INVITE_LINK_SETTING: (name: string) => SettingsKey<string> = name => {
|
||||
return {
|
||||
key: 'invite_link_setting_' + name
|
||||
|
|
|
@ -20,6 +20,7 @@ import {Registry} from "tc-shared/events";
|
|||
import {ChannelTreeEntry, ChannelTreeEntryEvents} from "tc-shared/ui/TreeEntry";
|
||||
import { ChannelEntryView as ChannelEntryView } from "./tree/Channel";
|
||||
import {MenuEntryType} from "tc-shared/ui/elements/ContextMenu";
|
||||
import {spawnFileTransferModal} from "tc-shared/ui/modal/transfer/ModalFileTransfer";
|
||||
|
||||
export enum ChannelType {
|
||||
PERMANENT,
|
||||
|
@ -170,7 +171,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
|||
//HTML DOM elements
|
||||
private _destroyed = false;
|
||||
|
||||
private _cachedPassword: string;
|
||||
private cachedPasswordHash: string;
|
||||
private _cached_channel_description: string = undefined;
|
||||
private _cached_channel_description_promise: Promise<string> = undefined;
|
||||
private _cached_channel_description_promise_resolve: any = undefined;
|
||||
|
@ -390,6 +391,12 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
|||
icon_class: "client-channel_switch",
|
||||
name: bold(tr("Switch to channel")),
|
||||
callback: () => this.joinChannel()
|
||||
},{
|
||||
type: contextmenu.MenuEntryType.ENTRY,
|
||||
icon_class: "client-filetransfer",
|
||||
name: bold(tr("Open channel file browser")),
|
||||
callback: () => spawnFileTransferModal(this.getChannelId()),
|
||||
visible: false /* FIXME: Enable this */
|
||||
}, {
|
||||
type: contextmenu.MenuEntryType.ENTRY,
|
||||
icon_class: "client-channel_switch",
|
||||
|
@ -623,32 +630,47 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
|||
}
|
||||
|
||||
joinChannel() {
|
||||
if(this.properties.channel_flag_password == true &&
|
||||
!this._cachedPassword &&
|
||||
!this.channelTree.client.permissions.neededPermission(PermissionType.B_CHANNEL_JOIN_IGNORE_PASSWORD).granted(1)) {
|
||||
createInputModal(tr("Channel password"), tr("Channel password:"), () => true, text => {
|
||||
if(typeof(text) !== "string") return;
|
||||
|
||||
hashPassword(text).then(result => {
|
||||
this._cachedPassword = result;
|
||||
this.events.fire("notify_cached_password_updated", { reason: "password-entered", new_hash: result });
|
||||
if(this.properties.channel_flag_password === true && !this.cachedPasswordHash) {
|
||||
this.requestChannelPassword(PermissionType.B_CHANNEL_JOIN_IGNORE_PASSWORD).then(password => {
|
||||
this.joinChannel();
|
||||
});
|
||||
}).open();
|
||||
} else if(this.channelTree.client.getClient().currentChannel() != this)
|
||||
this.channelTree.client.getServerConnection().command_helper.joinChannel(this, this._cachedPassword).then(() => {
|
||||
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._cachedPassword = undefined;
|
||||
this.events.fire("notify_cached_password_updated", { reason: "password-miss-match" });
|
||||
this.invalidateCachedPassword();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cached_password() { return this._cachedPassword; }
|
||||
async requestChannelPassword(ignorePermission: PermissionType) : Promise<{ hash: string } | undefined> {
|
||||
if(this.cachedPasswordHash)
|
||||
return { hash: this.cachedPasswordHash };
|
||||
|
||||
if(this.channelTree.client.permissions.neededPermission(ignorePermission).granted(1))
|
||||
return { hash: "having ignore permission" };
|
||||
|
||||
const password = await new Promise(resolve => createInputModal(tr("Channel password"), tr("Channel password:"), () => true, resolve).open())
|
||||
if(typeof(password) !== "string" || !password)
|
||||
return;
|
||||
|
||||
const hash = await hashPassword(password);
|
||||
this.cachedPasswordHash = hash;
|
||||
this.events.fire("notify_cached_password_updated", { reason: "password-entered", new_hash: hash });
|
||||
return { hash: this.cachedPasswordHash };
|
||||
}
|
||||
|
||||
invalidateCachedPassword() {
|
||||
this.cachedPasswordHash = undefined;
|
||||
this.events.fire("notify_cached_password_updated", { reason: "password-miss-match" });
|
||||
}
|
||||
|
||||
cached_password() { return this.cachedPasswordHash; }
|
||||
|
||||
async subscribe() : Promise<void> {
|
||||
if(this.subscribe_mode == ChannelSubscribeMode.SUBSCRIBED)
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -39,7 +39,7 @@ interface VolumeChangeModalState {
|
|||
}
|
||||
|
||||
@ReactEventHandler(e => e.props.events)
|
||||
class VolumeChangeModal extends React.Component<{ clientName: string, remote: boolean, events: Registry<VolumeChangeEvents> }, VolumeChangeModalState> {
|
||||
class VolumeChangeModal extends React.Component<{ clientName: string, maxVolume?: number, remote: boolean, events: Registry<VolumeChangeEvents> }, VolumeChangeModalState> {
|
||||
private readonly refSlider = React.createRef<Slider>();
|
||||
|
||||
private originalValue: number;
|
||||
|
@ -275,7 +275,7 @@ export function spawnMusicBotVolumeChange(client: MusicClientEntry, maxValue: nu
|
|||
|
||||
const modal = spawnReactModal(class extends Modal {
|
||||
renderBody() {
|
||||
return <VolumeChangeModal remote={true} clientName={client.clientNickName()} events={events} />;
|
||||
return <VolumeChangeModal remote={true} clientName={client.clientNickName()} maxVolume={maxValue} events={events} />;
|
||||
}
|
||||
|
||||
title(): string {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
console.error(tr("Failed to initialize upload: %o"), error);
|
||||
bar.set_error(tr("failed to initialize upload"));
|
||||
icon.upload_state = "error";
|
||||
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;
|
||||
|
||||
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))));
|
||||
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;
|
||||
}
|
||||
});
|
||||
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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,226 @@
|
|||
import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal";
|
||||
import * as React from "react";
|
||||
import {FileType} from "tc-shared/file/FileManager";
|
||||
import {Registry} from "tc-shared/events";
|
||||
import {server_connections} from "tc-shared/ui/frames/connection_handlers";
|
||||
import {FileBrowser, NavigationBar} from "tc-shared/ui/modal/transfer/FileBrowser";
|
||||
import {
|
||||
TransferInfo,
|
||||
TransferInfoEvents
|
||||
} from "tc-shared/ui/modal/transfer/TransferInfo";
|
||||
import {initializeRemoteFileBrowserController} from "tc-shared/ui/modal/transfer/RemoteFileBrowserController";
|
||||
import {ChannelEntry} from "tc-shared/ui/channel";
|
||||
import {initializeTransferInfoController} from "tc-shared/ui/modal/transfer/TransferInfoController";
|
||||
|
||||
const cssStyle = require("./ModalFileTransfer.scss");
|
||||
export const channelPathPrefix = tr("Channel") + " ";
|
||||
export const iconPathPrefix = tr("Icons");
|
||||
export const avatarsPathPrefix = tr("Avatars");
|
||||
export const FileTransferUrlMediaType = "application/x-teaspeak-ft-urls";
|
||||
|
||||
export type TransferStatus = "pending" | "transferring" | "finished" | "errored" | "none";
|
||||
export type FileMode = "password" | "empty" | "create" | "creating" | "normal" | "uploading";
|
||||
|
||||
export type ListedFileInfo = {
|
||||
path: string;
|
||||
name: string;
|
||||
type: FileType;
|
||||
|
||||
datetime: number;
|
||||
size: number;
|
||||
|
||||
virtual: boolean;
|
||||
mode: FileMode;
|
||||
|
||||
transfer?: {
|
||||
id: number;
|
||||
direction: "upload" | "download";
|
||||
status: TransferStatus;
|
||||
percent: number;
|
||||
} | undefined
|
||||
};
|
||||
|
||||
export type PathInfo = {
|
||||
channelId: number;
|
||||
channel: ChannelEntry;
|
||||
|
||||
path: string;
|
||||
type: "icon" | "avatar" | "channel" | "root";
|
||||
}
|
||||
|
||||
export interface FileBrowserEvents {
|
||||
query_files: { path: string },
|
||||
query_files_result: {
|
||||
path: string,
|
||||
status: "success" | "timeout" | "error" | "no-permissions" | "invalid-password",
|
||||
|
||||
error?: string,
|
||||
files?: ListedFileInfo[]
|
||||
},
|
||||
|
||||
action_navigate_to: {
|
||||
path: string
|
||||
},
|
||||
action_navigate_to_result: {
|
||||
path: string,
|
||||
status: "success" | "timeout" | "error";
|
||||
error?: string;
|
||||
pathInfo?: PathInfo
|
||||
}
|
||||
|
||||
action_delete_file: {
|
||||
files: {
|
||||
path: string,
|
||||
name: string
|
||||
}[] | "selection";
|
||||
mode: "force" | "ask";
|
||||
},
|
||||
action_delete_file_result: {
|
||||
results: {
|
||||
path: string,
|
||||
name: string,
|
||||
status: "success" | "timeout" | "error";
|
||||
error?: string;
|
||||
}[],
|
||||
},
|
||||
|
||||
action_start_create_directory: {
|
||||
defaultName: string
|
||||
},
|
||||
action_create_directory: {
|
||||
path: string,
|
||||
name: string
|
||||
},
|
||||
action_create_directory_result: {
|
||||
path: string,
|
||||
name: string,
|
||||
status: "success" | "timeout" | "error";
|
||||
|
||||
error?: string;
|
||||
},
|
||||
|
||||
action_rename_file: {
|
||||
oldPath: string,
|
||||
oldName: string,
|
||||
|
||||
newPath: string;
|
||||
newName: string
|
||||
},
|
||||
action_rename_file_result: {
|
||||
oldPath: string,
|
||||
oldName: string,
|
||||
status: "success" | "timeout" | "error" | "no-changes";
|
||||
|
||||
newPath?: string,
|
||||
newName?: string,
|
||||
error?: string;
|
||||
},
|
||||
|
||||
action_start_rename: {
|
||||
path: string;
|
||||
name: string;
|
||||
},
|
||||
|
||||
action_select_files: {
|
||||
files: {
|
||||
name: string,
|
||||
type: FileType
|
||||
}[]
|
||||
mode: "exclusive" | "toggle"
|
||||
},
|
||||
action_selection_context_menu: {
|
||||
pageX: number,
|
||||
pageY: number
|
||||
},
|
||||
|
||||
action_start_download: {
|
||||
files: {
|
||||
path: string,
|
||||
name: string
|
||||
}[]
|
||||
},
|
||||
action_start_upload: {
|
||||
path: string;
|
||||
mode: "files" | "browse";
|
||||
|
||||
files?: File[];
|
||||
},
|
||||
|
||||
notify_transfer_start: {
|
||||
path: string;
|
||||
name: string;
|
||||
|
||||
id: number;
|
||||
mode: "upload" | "download";
|
||||
},
|
||||
|
||||
notify_transfer_status: {
|
||||
id: number;
|
||||
status: TransferStatus;
|
||||
fileSize?: number;
|
||||
},
|
||||
notify_transfer_progress: {
|
||||
id: number;
|
||||
progress: number;
|
||||
fileSize: number;
|
||||
status: TransferStatus
|
||||
}
|
||||
|
||||
|
||||
|
||||
notify_modal_closed: {},
|
||||
notify_drag_ended: {},
|
||||
|
||||
/* Attention: Only use in sync mode! */
|
||||
notify_drag_started: {
|
||||
event: DragEvent
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class FileTransferModal extends Modal {
|
||||
readonly remoteBrowseEvents = new Registry<FileBrowserEvents>();
|
||||
readonly transferInfoEvents = new Registry<TransferInfoEvents>();
|
||||
|
||||
private readonly defaultChannelId;
|
||||
|
||||
constructor(defaultChannelId: number) {
|
||||
super();
|
||||
|
||||
this.defaultChannelId = defaultChannelId;
|
||||
|
||||
this.remoteBrowseEvents.enable_debug("remote-file-browser");
|
||||
this.transferInfoEvents.enable_debug("transfer-info");
|
||||
|
||||
initializeRemoteFileBrowserController(server_connections.active_connection(), this.remoteBrowseEvents);
|
||||
initializeTransferInfoController(server_connections.active_connection(), this.transferInfoEvents);
|
||||
}
|
||||
|
||||
protected onInitialize() {
|
||||
const path = this.defaultChannelId ? "/" + channelPathPrefix + this.defaultChannelId + "/" : "/";
|
||||
this.remoteBrowseEvents.fire("action_navigate_to", { path: path });
|
||||
}
|
||||
|
||||
protected onDestroy() {
|
||||
this.remoteBrowseEvents.fire("notify_modal_closed");
|
||||
this.transferInfoEvents.fire("notify_modal_closed");
|
||||
}
|
||||
|
||||
title(): string {
|
||||
return "File Browser";
|
||||
}
|
||||
|
||||
renderBody() {
|
||||
const path = this.defaultChannelId ? "/" + channelPathPrefix + this.defaultChannelId + "/" : "/";
|
||||
return <div className={cssStyle.container} style={{width: "600px"}}>
|
||||
<NavigationBar events={this.remoteBrowseEvents} currentPath={path} />
|
||||
<FileBrowser events={this.remoteBrowseEvents} currentPath={path} />
|
||||
<TransferInfo events={this.transferInfoEvents} />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
export function spawnFileTransferModal(channel: number) {
|
||||
const modal = spawnReactModal(FileTransferModal, channel);
|
||||
modal.show();
|
||||
}
|
|
@ -0,0 +1,780 @@
|
|||
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
||||
import {Registry} from "tc-shared/events";
|
||||
import {FileType} from "tc-shared/file/FileManager";
|
||||
import {CommandResult, ErrorCode, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
|
||||
import PermissionType from "tc-shared/permission/PermissionType";
|
||||
import * as log from "tc-shared/log";
|
||||
import {LogCategory} from "tc-shared/log";
|
||||
import {Entry, MenuEntry, MenuEntryType, spawn_context_menu} from "tc-shared/ui/elements/ContextMenu";
|
||||
import * as ppt from "tc-backend/ppt";
|
||||
import {SpecialKey} from "tc-shared/PPTListener";
|
||||
import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
|
||||
import {tra, traj} from "tc-shared/i18n/localize";
|
||||
import {FileTransfer, FileTransferState, FileUploadTransfer, TransferProvider} from "tc-shared/file/Transfer";
|
||||
import {createErrorModal} from "tc-shared/ui/elements/Modal";
|
||||
import {
|
||||
avatarsPathPrefix,
|
||||
channelPathPrefix,
|
||||
FileBrowserEvents,
|
||||
iconPathPrefix, ListedFileInfo, PathInfo
|
||||
} from "tc-shared/ui/modal/transfer/ModalFileTransfer";
|
||||
|
||||
function parsePath(path: string, connection: ConnectionHandler) : PathInfo {
|
||||
if(path === "/" || !path) {
|
||||
return {
|
||||
channel: undefined,
|
||||
channelId: 0,
|
||||
path: "/",
|
||||
type: "root"
|
||||
};
|
||||
} else if(path.startsWith("/" + channelPathPrefix)) {
|
||||
const pathParts = path.split("/");
|
||||
|
||||
const channelId = parseInt(pathParts[1].substr(channelPathPrefix.length));
|
||||
if(isNaN(channelId)) {
|
||||
throw tr("Invalid channel id (ID is NaN)");
|
||||
}
|
||||
|
||||
const channel = connection.channelTree.findChannel(channelId);
|
||||
if(!channel) {
|
||||
throw tr("Channel not visible anymore");
|
||||
}
|
||||
|
||||
return {
|
||||
type: "channel",
|
||||
path: "/" + pathParts.slice(2).join("/"),
|
||||
channelId: channelId,
|
||||
channel: channel
|
||||
};
|
||||
} else if(path == "/" + iconPathPrefix + "/") {
|
||||
return {
|
||||
type: "icon",
|
||||
path: "/icons/",
|
||||
channelId: 0,
|
||||
channel: undefined
|
||||
};
|
||||
} else if(path == "/" + avatarsPathPrefix + "/") {
|
||||
return {
|
||||
type: "avatar",
|
||||
path: "/",
|
||||
channelId: 0,
|
||||
channel: undefined
|
||||
};
|
||||
} else {
|
||||
throw tr("Unknown path");
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeRemoteFileBrowserController(connection: ConnectionHandler, events: Registry<FileBrowserEvents>) {
|
||||
events.on("action_navigate_to", event => {
|
||||
try {
|
||||
const info = parsePath(event.path, connection);
|
||||
|
||||
events.fire_async("action_navigate_to_result", {
|
||||
path: event.path || "/",
|
||||
status: "success",
|
||||
pathInfo: info
|
||||
});
|
||||
} catch (error) {
|
||||
events.fire_async("action_navigate_to_result", {
|
||||
path: event.path,
|
||||
status: "error",
|
||||
error: error
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
events.on("query_files", event => {
|
||||
let path: PathInfo;
|
||||
try {
|
||||
path = parsePath(event.path, connection);
|
||||
} catch (error) {
|
||||
events.fire_async("query_files_result", {
|
||||
path: event.path,
|
||||
status: "error",
|
||||
error: error
|
||||
});
|
||||
return;
|
||||
}
|
||||
let request: Promise<ListedFileInfo[]>;
|
||||
if(path.type === "root") {
|
||||
request = (async () => {
|
||||
const result: ListedFileInfo[] = [];
|
||||
|
||||
result.push({
|
||||
type: FileType.DIRECTORY,
|
||||
name: iconPathPrefix,
|
||||
size: 0,
|
||||
datetime: 0,
|
||||
mode: "normal",
|
||||
virtual: true,
|
||||
path: "/"
|
||||
});
|
||||
|
||||
result.push({
|
||||
type: FileType.DIRECTORY,
|
||||
name: avatarsPathPrefix,
|
||||
size: 0,
|
||||
datetime: 0,
|
||||
mode: "normal",
|
||||
virtual: true,
|
||||
path: "/"
|
||||
});
|
||||
|
||||
const requestArray = connection.channelTree.channels.map(e => {
|
||||
return {
|
||||
request: {
|
||||
path: "/",
|
||||
channelId: e.channelId
|
||||
},
|
||||
name: channelPathPrefix + e.getChannelId(),
|
||||
channel: e
|
||||
}
|
||||
});
|
||||
const channelInfos = await connection.fileManager.requestFileInfo(requestArray.map(e => e.request));
|
||||
for(let index = 0; index < requestArray.length; index++) {
|
||||
const response = channelInfos[index];
|
||||
|
||||
if(response instanceof CommandResult) {
|
||||
/* some kind of error occured (maybe password set, or non existing) */
|
||||
result.push({
|
||||
type: FileType.DIRECTORY,
|
||||
name: requestArray[index].name,
|
||||
size: 0,
|
||||
datetime: 0,
|
||||
mode: requestArray[index].channel.properties.channel_flag_password ? "password" : "empty",
|
||||
virtual: true,
|
||||
path: "/"
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
type: FileType.DIRECTORY,
|
||||
name: requestArray[index].name,
|
||||
size: 0,
|
||||
datetime: 0,
|
||||
mode: response.empty ? "empty" : "normal",
|
||||
virtual: true,
|
||||
path: "/"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
})();
|
||||
} else if(path.type === "channel") {
|
||||
request = (async () => {
|
||||
const hash = path.channel.properties.channel_flag_password ? await path.channel.requestChannelPassword(PermissionType.B_FT_IGNORE_PASSWORD) : undefined;
|
||||
return connection.fileManager.requestFileList(path.path, path.channelId, hash?.hash).then(result => result.map(e => {
|
||||
const transfer = connection.fileManager.findTransfer(path.channelId, path.path, e.name);
|
||||
return {
|
||||
datetime: e.datetime,
|
||||
name: e.name,
|
||||
size: e.size,
|
||||
type: e.type,
|
||||
path: event.path,
|
||||
mode: e.empty ? "empty" : "normal",
|
||||
virtual: false,
|
||||
transfer: !transfer ? undefined : {
|
||||
id: transfer.clientTransferId,
|
||||
percent: transfer.isRunning() && transfer.lastProgressInfo() ? transfer.lastProgressInfo().file_current_offset / transfer.lastProgressInfo().file_total_size : 0,
|
||||
status: transfer.isPending() ? "pending" : transfer.isRunning() ? "transferring" : "finished"
|
||||
}
|
||||
} as ListedFileInfo;
|
||||
})).catch(async error => {
|
||||
/* patch for the case that the channel directory hasn't been created yet */
|
||||
if(error instanceof CommandResult) {
|
||||
if(error.id === ErrorCode.FILE_NOT_FOUND && path.path === "/") {
|
||||
return [];
|
||||
} else if(error.id === 781) { //Invalid password
|
||||
path.channel.invalidateCachedPassword();
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
})();
|
||||
} else if(path.type === "icon" || path.type === "avatar") {
|
||||
request = connection.fileManager.requestFileList(path.path, 0).then(result => result.map(e => {
|
||||
return {
|
||||
datetime: e.datetime,
|
||||
name: e.name,
|
||||
size: e.size,
|
||||
type: e.type,
|
||||
mode: e.empty ? "empty" : "normal",
|
||||
path: event.path
|
||||
} as ListedFileInfo;
|
||||
}));
|
||||
} else {
|
||||
events.fire_async("query_files_result", {
|
||||
path: event.path,
|
||||
status: "error",
|
||||
error: tr("Unknown parsed path type")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
request.then(files => {
|
||||
events.fire_async("query_files_result", {
|
||||
path: event.path,
|
||||
status: "success",
|
||||
files: files.map(e => { e.datetime *= 1000; return e; })
|
||||
});
|
||||
}).catch(error => {
|
||||
let message;
|
||||
if(error instanceof CommandResult) {
|
||||
if(error.id === ErrorID.PERMISSION_ERROR) {
|
||||
const permission = connection.permissions.resolveInfo(error.json["failed_permid"] as number);
|
||||
events.fire_async("query_files_result", {
|
||||
path: event.path,
|
||||
status: "no-permissions",
|
||||
error: permission ? permission.name : "unknown"
|
||||
});
|
||||
return;
|
||||
} else if(error.id === 781) { //Invalid password
|
||||
events.fire_async("query_files_result", {
|
||||
path: event.path,
|
||||
status: "invalid-password"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
message = error.message + (error.extra_message ? " (" + error.extra_message + ")" : "");
|
||||
} else if(typeof error === "string") {
|
||||
message = error;
|
||||
} else {
|
||||
log.error(LogCategory.FILE_TRANSFER, tr("Failed to query channel directory files: %o"), error);
|
||||
message = tr("lookup the console");
|
||||
}
|
||||
|
||||
events.fire_async("query_files_result", {
|
||||
path: event.path,
|
||||
status: "error",
|
||||
error: message
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
events.on("action_rename_file", event => {
|
||||
if(event.newPath === event.oldPath && event.newName === event.oldName) {
|
||||
events.fire_async("action_rename_file_result", {
|
||||
oldPath: event.oldPath,
|
||||
oldName: event.oldName,
|
||||
|
||||
newPath: event.newPath,
|
||||
newName: event.newName,
|
||||
|
||||
status: "no-changes"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let sourcePath: PathInfo, targetPath: PathInfo;
|
||||
try {
|
||||
sourcePath = parsePath(event.oldPath, connection);
|
||||
if(sourcePath.type !== "channel")
|
||||
throw tr("Icon/avatars could not be renamed");
|
||||
} catch (error) {
|
||||
events.fire_async("action_rename_file_result", {
|
||||
oldPath: event.oldPath,
|
||||
oldName: event.oldName,
|
||||
status: "error",
|
||||
error: tr("Invalid source path") + " (" + error + ")"
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
targetPath = parsePath(event.newPath, connection);
|
||||
if(sourcePath.type !== "channel")
|
||||
throw tr("Target path isn't a channel");
|
||||
} catch (error) {
|
||||
events.fire_async("action_rename_file_result", {
|
||||
oldPath: event.oldPath,
|
||||
oldName: event.oldName,
|
||||
status: "error",
|
||||
error: tr("Invalid target path") + " (" + error + ")"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const sourcePassword = sourcePath.channel.properties.channel_flag_password ? await sourcePath.channel.requestChannelPassword(PermissionType.B_FT_IGNORE_PASSWORD) : undefined;
|
||||
const targetPassword = targetPath.channel.properties.channel_flag_password ? await targetPath.channel.requestChannelPassword(PermissionType.B_FT_IGNORE_PASSWORD) : undefined;
|
||||
return await connection.serverConnection.send_command("ftrenamefile", {
|
||||
cid: sourcePath.channelId,
|
||||
cpw: sourcePassword,
|
||||
tcid: targetPath.channelId,
|
||||
tcpw: targetPassword,
|
||||
oldname: sourcePath.path + event.oldName,
|
||||
newname: targetPath.path + event.newName
|
||||
})
|
||||
})().then(result => {
|
||||
if(result.id !== 0)
|
||||
throw result;
|
||||
|
||||
events.fire("action_rename_file_result", {
|
||||
oldPath: event.oldPath,
|
||||
oldName: event.oldName,
|
||||
status: "success",
|
||||
|
||||
newName: event.newName,
|
||||
newPath: event.newPath
|
||||
});
|
||||
}).catch(error => {
|
||||
let message;
|
||||
if(error instanceof CommandResult) {
|
||||
if(error.id === ErrorID.PERMISSION_ERROR) {
|
||||
const permission = connection.permissions.resolveInfo(error.json["failed_permid"] as number);
|
||||
events.fire_async("action_rename_file_result", {
|
||||
oldPath: event.oldPath,
|
||||
oldName: event.oldName,
|
||||
status: "error",
|
||||
error: tr("Failed on permission ") + (permission ? permission.name : "unknown")
|
||||
});
|
||||
return;
|
||||
} else if(error.id === 781) { //Invalid password
|
||||
events.fire_async("action_rename_file_result", {
|
||||
oldPath: event.oldPath,
|
||||
oldName: event.oldName,
|
||||
status: "error",
|
||||
error: tr("Invalid channel password")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
message = error.message + (error.extra_message ? " (" + error.extra_message + ")" : "");
|
||||
} else if(typeof error === "string") {
|
||||
message = error;
|
||||
} else {
|
||||
log.error(LogCategory.FILE_TRANSFER, tr("Failed to rename/move files: %o"), error);
|
||||
message = tr("lookup the console");
|
||||
}
|
||||
events.fire_async("action_rename_file_result", {
|
||||
oldPath: event.oldPath,
|
||||
oldName: event.oldName,
|
||||
status: "error",
|
||||
error: message
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* currently selected files */
|
||||
let currentPath = "/";
|
||||
let currentPathInfo: PathInfo;
|
||||
let selection: { name: string, type: FileType }[] = [];
|
||||
events.on("action_navigate_to_result", result => {
|
||||
if(result.status !== "success")
|
||||
return;
|
||||
|
||||
currentPathInfo = result.pathInfo;
|
||||
currentPath = result.path;
|
||||
selection = [];
|
||||
});
|
||||
|
||||
events.on("action_rename_file_result", result => {
|
||||
if(result.status !== "success")
|
||||
return;
|
||||
if(result.oldPath !== currentPath)
|
||||
return;
|
||||
|
||||
const index = selection.map(e => e.name).findIndex(e => e === result.oldName);
|
||||
if(index !== -1)
|
||||
selection[index].name = result.newName;
|
||||
});
|
||||
|
||||
events.on("action_select_files", event => {
|
||||
if(event.mode === "exclusive") {
|
||||
selection = event.files.slice(0);
|
||||
} else if(event.mode === "toggle") {
|
||||
event.files.forEach(e => {
|
||||
const index = selection.map(e => e.name).findIndex(b => b === e.name);
|
||||
if(index === -1)
|
||||
selection.push(e);
|
||||
else
|
||||
selection.splice(index);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/* the selection handler */
|
||||
events.on("action_selection_context_menu", event => {
|
||||
const entries = [] as MenuEntry[];
|
||||
|
||||
if(currentPathInfo.type === "root") {
|
||||
entries.push({
|
||||
type: MenuEntryType.ENTRY,
|
||||
name: tr("Refresh file list"),
|
||||
icon_class: "client-file_refresh"
|
||||
});
|
||||
} else {
|
||||
const forceDelete = ppt.key_pressed(SpecialKey.SHIFT);
|
||||
if(selection.length === 0) {
|
||||
entries.push({
|
||||
type: MenuEntryType.ENTRY,
|
||||
name: tr("Upload"),
|
||||
icon_class: "client-upload",
|
||||
callback: () => events.fire("action_start_upload", { mode: "browse", path: currentPath })
|
||||
});
|
||||
} else if(selection.length === 1) {
|
||||
const file = selection[0];
|
||||
if(file.type === FileType.FILE) {
|
||||
entries.push({
|
||||
type: MenuEntryType.ENTRY,
|
||||
name: tr("Download"),
|
||||
icon_class: "client-download",
|
||||
callback: () => events.fire("action_start_download", { files: [{ name: file.name, path: currentPath }] })
|
||||
});
|
||||
}
|
||||
if(currentPathInfo.type === "channel") {
|
||||
entries.push({
|
||||
type: MenuEntryType.ENTRY,
|
||||
name: tr("Rename"),
|
||||
icon_class: "client-change_nickname",
|
||||
callback: () => events.fire("action_start_rename", { name: file.name, path: currentPath })
|
||||
});
|
||||
}
|
||||
entries.push({
|
||||
type: MenuEntryType.ENTRY,
|
||||
name: forceDelete ? tr("Force delete file") : tr("Delete file"),
|
||||
icon_class: "client-delete",
|
||||
callback: () => events.fire("action_delete_file", { mode: forceDelete ? "force" : "ask", files: "selection" })
|
||||
});
|
||||
entries.push(Entry.HR());
|
||||
} else if(selection.length > 1) {
|
||||
if(selection.findIndex(e => e.type === FileType.DIRECTORY) === -1) {
|
||||
entries.push({
|
||||
type: MenuEntryType.ENTRY,
|
||||
name: tr("Download"),
|
||||
icon_class: "client-download",
|
||||
callback: () => events.fire("action_start_download", { files: selection.map(file => { return { name: file.name, path: currentPath }}) })
|
||||
});
|
||||
}
|
||||
entries.push({
|
||||
type: MenuEntryType.ENTRY,
|
||||
name: forceDelete ? tr("Force delete files") : tr("Delete files"),
|
||||
icon_class: "client-delete",
|
||||
callback: () => events.fire("action_delete_file", { mode: forceDelete ? "force" : "ask", files: "selection" })
|
||||
});
|
||||
}
|
||||
entries.push({
|
||||
type: MenuEntryType.ENTRY,
|
||||
name: tr("Refresh file list"),
|
||||
icon_class: "client-file_refresh",
|
||||
callback: () => events.fire("action_navigate_to", { path: currentPath })
|
||||
});
|
||||
entries.push(Entry.HR());
|
||||
entries.push({
|
||||
type: MenuEntryType.ENTRY,
|
||||
name: tr("Create folder"),
|
||||
icon_class: "client-add_folder",
|
||||
callback: () => events.fire("action_start_create_directory", { defaultName: tr("New folder") })
|
||||
});
|
||||
}
|
||||
spawn_context_menu(event.pageX, event.pageY, ...entries);
|
||||
});
|
||||
|
||||
events.on("action_delete_file", event => {
|
||||
const files = event.files === "selection" ? selection.map(e => { return { path: currentPath, name: e.name }}) : event.files;
|
||||
|
||||
if(event.mode === "ask") {
|
||||
spawnYesNo(tr("Are you sure?"), tra("Do you really want to delete {0} {1}?", files.length, files.length === 1 ? tr("files") : tr("files")), result => {
|
||||
if(result)
|
||||
events.fire("action_delete_file", {
|
||||
files: files,
|
||||
mode: "force"
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileInfos = files.map(e => { return { info: parsePath(e.path, connection), path: e.path, name: e.name }});
|
||||
|
||||
connection.serverConnection.send_command("ftdeletefile", fileInfos.map(e => { return {
|
||||
path: e.info.path,
|
||||
cid: e.info.channelId,
|
||||
cpw: e.info.channel?.cached_password(),
|
||||
name: e.name
|
||||
}})).then(async result => {
|
||||
throw result;
|
||||
}).catch(result => {
|
||||
let message;
|
||||
if(result instanceof CommandResult) {
|
||||
if(result.bulks.length !== fileInfos.length) {
|
||||
events.fire_async("action_delete_file_result", {
|
||||
results: fileInfos.map((e) => {
|
||||
return {
|
||||
error: result.bulks.length === 1 ? (result.message + (result.extra_message ? " (" + result.extra_message + ")" : "")) : tr("Response contained invalid bulk length"),
|
||||
path: e.path,
|
||||
name: e.name,
|
||||
status: "error"
|
||||
};
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let results = [];
|
||||
result.getBulks().forEach((e, index) => {
|
||||
if(e.id === ErrorID.PERMISSION_ERROR) {
|
||||
const permission = connection.permissions.resolveInfo(e.json["failed_permid"] as number);
|
||||
results.push({
|
||||
path: fileInfos[index].path,
|
||||
name: fileInfos[index].name,
|
||||
status: "error",
|
||||
error: tr("Failed on permission ") + (permission ? permission.name : "unknown")
|
||||
});
|
||||
return;
|
||||
} else if(e.id === 781) { //Invalid password
|
||||
results.push({
|
||||
path: fileInfos[index].path,
|
||||
name: fileInfos[index].name,
|
||||
status: "error",
|
||||
error: tr("Invalid channel password")
|
||||
});
|
||||
return;
|
||||
} else if(e.id !== 0) {
|
||||
results.push({
|
||||
path: fileInfos[index].path,
|
||||
name: fileInfos[index].name,
|
||||
status: "error",
|
||||
error: e.message + (e.extra_message ? " (" + e.extra_message + ")" : "")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
results.push({
|
||||
path: fileInfos[index].path,
|
||||
name: fileInfos[index].name,
|
||||
status: "success"
|
||||
});
|
||||
return;
|
||||
});
|
||||
|
||||
events.fire_async("action_delete_file_result", {
|
||||
results: results
|
||||
});
|
||||
return;
|
||||
} else if(typeof result === "string") {
|
||||
message = result;
|
||||
} else {
|
||||
log.error(LogCategory.FILE_TRANSFER, tr("Failed to create directory: %o"), result);
|
||||
message = tr("lookup the console");
|
||||
}
|
||||
|
||||
events.fire_async("action_delete_file_result", {
|
||||
results: files.map((e) => {
|
||||
return {
|
||||
error: message,
|
||||
path: e.path,
|
||||
name: e.name,
|
||||
status: "error"
|
||||
};
|
||||
})
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
events.fire_async("action_delete_file_result", {
|
||||
results: files.map((e) => {
|
||||
return {
|
||||
error: tr("Failed to parse path for one or more entries ") + " (" + error + ")",
|
||||
path: e.path,
|
||||
name: e.name,
|
||||
status: "error"
|
||||
};
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
events.on("action_create_directory", event => {
|
||||
let path: PathInfo;
|
||||
try {
|
||||
path = parsePath(event.path, connection);
|
||||
if(path.type !== "channel")
|
||||
throw tr("Directories could only created for channels");
|
||||
} catch (error) {
|
||||
events.fire_async("action_create_directory_result", {
|
||||
name: event.name,
|
||||
path: event.path,
|
||||
status: "error",
|
||||
error: tr("Invalid path") + " (" + error + ")"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
//ftcreatedir cid=4 cpw dirname=\/TestDir return_code=1:17
|
||||
connection.serverConnection.send_command("ftcreatedir", {
|
||||
cid: path.channelId,
|
||||
cpw: path.channel.cached_password(),
|
||||
dirname: path.path + event.name
|
||||
}).then(() => {
|
||||
events.fire("action_create_directory_result", { path: event.path, name: event.name, status: "success" });
|
||||
}).catch(error => {
|
||||
let message;
|
||||
if(error instanceof CommandResult) {
|
||||
if(error.id === ErrorID.PERMISSION_ERROR) {
|
||||
const permission = connection.permissions.resolveInfo(error.json["failed_permid"] as number);
|
||||
events.fire_async("action_create_directory_result", {
|
||||
name: event.name,
|
||||
path: event.path,
|
||||
status: "error",
|
||||
error: tr("Failed on permission ") + (permission ? permission.name : "unknown")
|
||||
});
|
||||
return;
|
||||
} else if(error.id === 781) { //Invalid password
|
||||
events.fire_async("action_create_directory_result", {
|
||||
name: event.name,
|
||||
path: event.path,
|
||||
status: "error",
|
||||
error: tr("Invalid channel password")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
message = error.message + (error.extra_message ? " (" + error.extra_message + ")" : "");
|
||||
} else if(typeof error === "string") {
|
||||
message = error;
|
||||
} else {
|
||||
log.error(LogCategory.FILE_TRANSFER, tr("Failed to create directory: %o"), error);
|
||||
message = tr("lookup the console");
|
||||
}
|
||||
events.fire_async("action_create_directory_result", {
|
||||
name: event.name,
|
||||
path: event.path,
|
||||
status: "error",
|
||||
error: message
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
events.on("action_start_download", event => {
|
||||
event.files.forEach(file => {
|
||||
try {
|
||||
const fileName = file.name;
|
||||
const info = parsePath(file.path, connection);
|
||||
const transfer = connection.fileManager.initializeFileDownload({
|
||||
channel: info.channelId,
|
||||
path: info.type === "channel" ? info.path : "",
|
||||
name: info.type === "channel" ? file.name : "/" + file.name,
|
||||
channelPassword: info.channel?.cached_password(),
|
||||
targetSupplier: async () => TransferProvider.provider().createDownloadTarget()
|
||||
});
|
||||
transfer.awaitFinished().then(() => {
|
||||
if(transfer.transferState() === FileTransferState.ERRORED) {
|
||||
createErrorModal(tr("Failed to download file"), traj("Failed to download {0}:{:br:}{1}", fileName, transfer.currentErrorMessage())).open();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(LogCategory.FILE_TRANSFER, tr("Failed to parse path for file download: %s"), error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
events.on("action_start_upload", event => {
|
||||
if(event.mode === "browse") {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
|
||||
document.body.appendChild(input);
|
||||
input.onchange = () => {
|
||||
if((input.files?.length | 0) === 0)
|
||||
return;
|
||||
|
||||
events.fire("action_start_upload", { mode: "files", path: event.path, files: [...input.files] });
|
||||
};
|
||||
input.onblur = () => input.remove();
|
||||
setTimeout(() => {
|
||||
input.focus({ preventScroll: true });
|
||||
input.click();
|
||||
});
|
||||
return;
|
||||
} else if(event.mode === "files") {
|
||||
const pathInfo = parsePath(event.path, connection);
|
||||
if(pathInfo.type !== "channel") {
|
||||
createErrorModal(tr("Failed to upload file(s)"), tra("Failed to upload files:{:br:}File uplaod is only supported in channel directories")).open();
|
||||
return;
|
||||
}
|
||||
for(const file of event.files) {
|
||||
const fileName = file.name;
|
||||
const transfer = connection.fileManager.initializeFileUpload({
|
||||
channel: pathInfo.channelId,
|
||||
channelPassword: pathInfo.channel?.cached_password(),
|
||||
name: file.name,
|
||||
path: pathInfo.path,
|
||||
source: async () => TransferProvider.provider().createBrowserFileSource(file)
|
||||
});
|
||||
transfer.awaitFinished().then(() => {
|
||||
if(transfer.transferState() === FileTransferState.ERRORED) {
|
||||
createErrorModal(tr("Failed to upload file"), tra("Failed to upload {0}:{:br:}{1}", fileName, transfer.currentErrorMessage())).open();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* transfer status listener */
|
||||
{
|
||||
const listenToTransfer = (transfer: FileTransfer) => {
|
||||
/* We've currently only support for channel files */
|
||||
if(transfer.properties.channel_id === 0)
|
||||
return;
|
||||
|
||||
const progressListener = event => events.fire("notify_transfer_progress", {
|
||||
id: transfer.clientTransferId,
|
||||
progress: event.progress.file_current_offset / event.progress.file_total_size,
|
||||
status: "transferring",
|
||||
fileSize: event.progress.file_current_offset
|
||||
});
|
||||
|
||||
transfer.events.on("notify_progress", progressListener);
|
||||
transfer.events.on("notify_state_updated", () => {
|
||||
switch (transfer.transferState()) {
|
||||
case FileTransferState.INITIALIZING:
|
||||
case FileTransferState.PENDING:
|
||||
case FileTransferState.CONNECTING:
|
||||
events.fire("notify_transfer_status", {id: transfer.clientTransferId, status: "pending"});
|
||||
break;
|
||||
|
||||
case FileTransferState.RUNNING:
|
||||
events.fire("notify_transfer_status", { id: transfer.clientTransferId, status: "transferring", fileSize: transfer.transferProperties().fileSize });
|
||||
break;
|
||||
|
||||
case FileTransferState.FINISHED:
|
||||
case FileTransferState.CANCELED:
|
||||
events.fire("notify_transfer_status", { id: transfer.clientTransferId, status: "finished" });
|
||||
break;
|
||||
|
||||
case FileTransferState.ERRORED:
|
||||
events.fire("notify_transfer_status", { id: transfer.clientTransferId, status: "errored" });
|
||||
break;
|
||||
}
|
||||
|
||||
if(transfer.isFinished()) {
|
||||
unregisterEvents();
|
||||
return;
|
||||
}
|
||||
});
|
||||
events.fire("notify_transfer_start", {
|
||||
id: transfer.clientTransferId,
|
||||
name: transfer.properties.name,
|
||||
path: "/" + channelPathPrefix + transfer.properties.channel_id + transfer.properties.path,
|
||||
mode: transfer instanceof FileUploadTransfer ? "upload" : "download"
|
||||
});
|
||||
|
||||
const closeListener = () => unregisterEvents();
|
||||
events.on("notify_modal_closed", closeListener);
|
||||
|
||||
const unregisterEvents = () => {
|
||||
events.off("notify_modal_closed", closeListener);
|
||||
transfer.events.off("notify_progress", progressListener);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const registeredListener = event => listenToTransfer(event.transfer);
|
||||
connection.fileManager.events.on("notify_transfer_registered", registeredListener);
|
||||
events.on("notify_modal_closed", () => connection.fileManager.events.off("notify_transfer_registered", registeredListener));
|
||||
|
||||
connection.fileManager.registeredTransfers().forEach(transfer => listenToTransfer(transfer));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,478 @@
|
|||
import * as React from "react";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events";
|
||||
import {
|
||||
TransferStatus
|
||||
} from "tc-shared/ui/modal/transfer/ModalFileTransfer";
|
||||
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||
import {HTMLRenderer} from "tc-shared/ui/react-elements/HTMLRenderer";
|
||||
import {ProgressBar} from "tc-shared/ui/react-elements/ProgressBar";
|
||||
import {
|
||||
TransferProgress,
|
||||
} from "tc-shared/file/Transfer";
|
||||
import {tra} from "tc-shared/i18n/localize";
|
||||
import {format_time, network} from "tc-shared/ui/frames/chat";
|
||||
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
|
||||
import {Checkbox} from "tc-shared/ui/react-elements/Checkbox";
|
||||
import {Button} from "tc-shared/ui/react-elements/Button";
|
||||
|
||||
const cssStyle = require("./TransferInfo.scss");
|
||||
const iconArrow = require("./icon_double_arrow.svg");
|
||||
const iconTransferUpload = require("./icon_transfer_upload.svg");
|
||||
const iconTransferDownload = require("./icon_transfer_download.svg");
|
||||
|
||||
export interface TransferInfoEvents {
|
||||
query_transfers: {},
|
||||
query_transfer_result: {
|
||||
status: "success" | "error" | "timeout";
|
||||
|
||||
error?: string;
|
||||
transfers?: TransferInfoData[],
|
||||
showFinished?: boolean
|
||||
}
|
||||
|
||||
action_toggle_expansion: { visible: boolean },
|
||||
action_toggle_finished_transfers: { visible: boolean },
|
||||
action_remove_finished: {},
|
||||
|
||||
notify_transfer_registered: { transfer: TransferInfoData },
|
||||
notify_transfer_status: {
|
||||
id: number,
|
||||
status: TransferStatus,
|
||||
error?: string
|
||||
},
|
||||
notify_transfer_progress: {
|
||||
id: number;
|
||||
status: TransferStatus,
|
||||
progress: TransferProgress
|
||||
},
|
||||
|
||||
notify_modal_closed: {}
|
||||
}
|
||||
|
||||
export interface TransferInfoData {
|
||||
id: number;
|
||||
|
||||
direction: "upload" | "download";
|
||||
status: TransferStatus;
|
||||
|
||||
name: string;
|
||||
path: string;
|
||||
|
||||
progress: number;
|
||||
error?: string;
|
||||
|
||||
timestampRegistered: number;
|
||||
timestampBegin: number;
|
||||
timestampEnd: number;
|
||||
|
||||
transferredBytes: number;
|
||||
}
|
||||
|
||||
const ExpendState = (props: { extended: boolean, events: Registry<TransferInfoEvents>}) => {
|
||||
const [expended, setExpended] = useState(props.extended);
|
||||
|
||||
props.events.reactUse("action_toggle_expansion", event => setExpended(event.visible));
|
||||
return <div className={cssStyle.expansionContainer + (expended ? " " + cssStyle.expended : "")} onClick={() => props.events.fire("action_toggle_expansion", { visible: !expended })}>
|
||||
<HTMLRenderer purify={false}>{iconArrow}</HTMLRenderer>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const ToggleFinishedTransfersCheckbox = (props: { events: Registry<TransferInfoEvents> }) => {
|
||||
const ref = useRef<Checkbox>(null);
|
||||
const [state, setState] = useState({ disabled: true, checked: false });
|
||||
props.events.reactUse("action_toggle_finished_transfers", event => {
|
||||
setState({
|
||||
checked: event.visible,
|
||||
disabled: false
|
||||
});
|
||||
ref.current?.setState({
|
||||
checked: event.visible,
|
||||
disabled: false
|
||||
});
|
||||
});
|
||||
|
||||
props.events.reactUse("query_transfer_result", event => {
|
||||
if(event.status !== "success")
|
||||
return;
|
||||
|
||||
setState({
|
||||
checked: event.showFinished,
|
||||
disabled: false
|
||||
});
|
||||
ref.current?.setState({
|
||||
checked: event.showFinished,
|
||||
disabled: false
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
ref={ref}
|
||||
initialValue={state.checked}
|
||||
disabled={state.disabled}
|
||||
onChange={state => props.events.fire("action_toggle_finished_transfers", { visible: state })}
|
||||
label={<Translatable>Show finished transfers</Translatable>} />
|
||||
);
|
||||
};
|
||||
|
||||
@ReactEventHandler<RunningTransfersInfo>(e => e.props.events)
|
||||
class RunningTransfersInfo extends React.Component<{ events: Registry<TransferInfoEvents> }, { state: "error" | "querying" | "normal" }> {
|
||||
private runningTransfers: { transfer: TransferInfoData, progress: TransferProgress | undefined }[] = [];
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
state: "querying"
|
||||
};
|
||||
}
|
||||
|
||||
private currentStatistic() {
|
||||
const progress = this.runningTransfers.map(e => e.progress).filter(e => !!e);
|
||||
return {
|
||||
totalBytes: progress.map(e => e.file_total_size).reduce((a, b) => a + b, 0),
|
||||
currentOffset: progress.map(e => e.file_current_offset).reduce((a, b) => a + b, 0),
|
||||
speed: progress.map(e => e.network_current_speed).reduce((a, b) => a + b, 0)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if(this.state.state === "querying") {
|
||||
return (
|
||||
<div key={"querying"} className={cssStyle.overlay + " " + cssStyle.querying}>
|
||||
<a><Translatable>loading</Translatable> <LoadingDots maxDots={3} /></a>
|
||||
</div>
|
||||
);
|
||||
} else if(this.state.state === "error") {
|
||||
return (
|
||||
<div key={"query-error"} className={cssStyle.overlay + " " + cssStyle.error}>
|
||||
<a><Translatable>query error</Translatable></a>
|
||||
</div>
|
||||
);
|
||||
} else if(this.runningTransfers.length === 0) {
|
||||
return (
|
||||
<div key={"no-transfers"} className={cssStyle.overlay + " " + cssStyle.noTransfers}>
|
||||
<a><Translatable>No running transfers</Translatable></a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = this.currentStatistic();
|
||||
const totalBytes = network.format_bytes(stats.totalBytes, { unit: "B", time: "", exact: false });
|
||||
const currentOffset = network.format_bytes(stats.currentOffset, { unit: "B", time: "", exact: false });
|
||||
const speed = network.format_bytes(stats.speed, { unit: "B", time: "second", exact: false });
|
||||
|
||||
return (
|
||||
<div key={"running-transfers"} className={cssStyle.overlay + " " + cssStyle.runningTransfers}>
|
||||
<ProgressBar value={stats.currentOffset * 100 / stats.totalBytes} type={"normal"} text={tra("Transferred {0} out of {1} total bytes ({2})", currentOffset, totalBytes, speed)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@EventHandler<TransferInfoEvents>("query_transfers")
|
||||
private handleQueryTransfers() {
|
||||
this.setState({ state: "querying" });
|
||||
}
|
||||
|
||||
@EventHandler<TransferInfoEvents>("query_transfer_result")
|
||||
private handleQueryTransferResult(event: TransferInfoEvents["query_transfer_result"]) {
|
||||
this.setState({
|
||||
state: event.status !== "success" ? "error" : "normal"
|
||||
});
|
||||
|
||||
this.runningTransfers = (event.transfers || []).filter(e => e.status !== "finished" && e.status !== "errored").map(e => {
|
||||
return {
|
||||
progress: undefined,
|
||||
transfer: e
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@EventHandler<TransferInfoEvents>("notify_transfer_registered")
|
||||
private handleTransferRegistered(event: TransferInfoEvents["notify_transfer_registered"]) {
|
||||
this.runningTransfers.push({ transfer: event.transfer, progress: undefined });
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
@EventHandler<TransferInfoEvents>("notify_transfer_status")
|
||||
private handleTransferStatus(event: TransferInfoEvents["notify_transfer_status"]) {
|
||||
const index = this.runningTransfers.findIndex(e => e.transfer.id === event.id);
|
||||
if(index === -1) return;
|
||||
|
||||
if(event.status === "finished" || event.status === "errored")
|
||||
this.runningTransfers.splice(index, 1);
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
@EventHandler<TransferInfoEvents>("notify_transfer_progress")
|
||||
private handleTransferProgress(event: TransferInfoEvents["notify_transfer_progress"]) {
|
||||
const index = this.runningTransfers.findIndex(e => e.transfer.id === event.id);
|
||||
if(index === -1) return;
|
||||
|
||||
this.runningTransfers[index].progress = event.progress;
|
||||
this.forceUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
const BottomTransferInfo = (props: { events: Registry<TransferInfoEvents> }) => {
|
||||
const [extendedInfo, setExtendedInfo] = useState(false);
|
||||
|
||||
props.events.reactUse("action_toggle_expansion", event => setExtendedInfo(event.visible));
|
||||
|
||||
return (
|
||||
<div className={cssStyle.info}>
|
||||
<RunningTransfersInfo events={props.events} />
|
||||
<div className={cssStyle.overlay + (extendedInfo ? "" : " " + cssStyle.hidden) + " " + cssStyle.extended} >
|
||||
<ToggleFinishedTransfersCheckbox events={props.events} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
const BottomBar = (props: { events: Registry<TransferInfoEvents> }) => (
|
||||
<div className={cssStyle.bottomContainer}>
|
||||
<BottomTransferInfo events={props.events} />
|
||||
<ExpendState extended={false} events={props.events} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const TransferEntry = (props: { transfer: TransferInfoData, events: Registry<TransferInfoEvents>, finishedShown: boolean }) => {
|
||||
const [finishedShown, setFinishedShown] = useState(props.finishedShown);
|
||||
const [transferState, setTransferState] = useState<TransferStatus>(props.transfer.status);
|
||||
const [finishAnimationFinished, setFinishAnimationFinished] = useState(props.transfer.status === "finished" || props.transfer.status === "errored");
|
||||
|
||||
const progressBar = useRef<ProgressBar>(null);
|
||||
|
||||
const progressBarText = (status: TransferStatus, info?: TransferProgress) => {
|
||||
switch (status) {
|
||||
case "errored":
|
||||
return props.transfer.error ? tr("file transfer failed: ") + props.transfer.error : tr("file transfer failed");
|
||||
|
||||
case "finished":
|
||||
const neededTime = format_time(props.transfer.timestampEnd - props.transfer.timestampBegin, tr("less than a second"));
|
||||
const totalBytes = network.format_bytes(props.transfer.transferredBytes, { unit: "B", time: "", exact: false });
|
||||
const speed = network.format_bytes(props.transfer.transferredBytes * 1000 / Math.max(props.transfer.timestampEnd - props.transfer.timestampBegin, 1000), { unit: "B", time: "second", exact: false });
|
||||
return tra("transferred {0} in {1} ({2})", totalBytes, format_time(props.transfer.timestampEnd - props.transfer.timestampBegin, neededTime), speed);
|
||||
|
||||
case "pending":
|
||||
return tr("pending");
|
||||
|
||||
case "none":
|
||||
return tr("invalid state!");
|
||||
|
||||
case "transferring": {
|
||||
if(!info) {
|
||||
return tr("awaiting info");
|
||||
}
|
||||
|
||||
const currentBytes = network.format_bytes(info.file_current_offset, { unit: "B", time: "", exact: false });
|
||||
const totalBytes = network.format_bytes(info.file_total_size, { unit: "B", time: "", exact: false });
|
||||
const speed = network.format_bytes(info.network_current_speed, { unit: "B", time: "second", exact: false });
|
||||
|
||||
return tra("transferred {0} out of {1} ({2})", currentBytes, totalBytes, speed);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const progressBarMode = (status: TransferStatus) => {
|
||||
switch (status) {
|
||||
case "errored":
|
||||
return "error";
|
||||
case "finished":
|
||||
return "success";
|
||||
default:
|
||||
return "normal";
|
||||
}
|
||||
};
|
||||
|
||||
props.events.reactUse("notify_transfer_status", event => {
|
||||
if(event.id !== props.transfer.id)
|
||||
return;
|
||||
|
||||
setTransferState(event.status);
|
||||
if(!progressBar.current)
|
||||
return;
|
||||
|
||||
const pbState = {
|
||||
text: progressBarText(event.status),
|
||||
type: progressBarMode(event.status)
|
||||
} as any;
|
||||
|
||||
if(event.status === "errored" || event.status === "finished") {
|
||||
pbState.value = 100;
|
||||
|
||||
} else if(event.status === "none" || event.status === "pending")
|
||||
pbState.value = 0;
|
||||
|
||||
progressBar.current.setState(pbState);
|
||||
});
|
||||
|
||||
props.events.reactUse("notify_transfer_progress", event => {
|
||||
if(event.id !== props.transfer.id || !progressBar.current)
|
||||
return;
|
||||
|
||||
const pb = progressBar.current;
|
||||
pb.setState({
|
||||
text: progressBarText(event.status, event.progress),
|
||||
type: progressBarMode(event.status),
|
||||
value: event.status === "errored" || event.status === "finished" ? 100 : event.status === "pending" || event.status === "none" ? 0 : (event.progress.file_current_offset / event.progress.file_total_size) * 100
|
||||
});
|
||||
});
|
||||
|
||||
props.events.reactUse("action_toggle_finished_transfers", event => setFinishedShown(event.visible));
|
||||
|
||||
useEffect(() => {
|
||||
if(finishAnimationFinished)
|
||||
return;
|
||||
if(transferState !== "finished" && transferState !== "errored")
|
||||
return;
|
||||
|
||||
const id = setTimeout(() => setFinishAnimationFinished(true), 1500);
|
||||
return () => clearTimeout(id);
|
||||
});
|
||||
|
||||
let hidden = transferState === "finished" || transferState === "errored" ? !finishedShown && finishAnimationFinished : false;
|
||||
return <div className={cssStyle.transferEntryContainer + (hidden ? " " + cssStyle.hidden : "")}>
|
||||
<div className={cssStyle.transferEntry}>
|
||||
<div className={cssStyle.image}>
|
||||
<HTMLRenderer purify={false}>{props.transfer.direction === "upload" ? iconTransferUpload : iconTransferDownload}</HTMLRenderer>
|
||||
</div>
|
||||
<div className={cssStyle.info}>
|
||||
<a className={cssStyle.name}>{props.transfer.name}</a>
|
||||
<a className={cssStyle.path}>{props.transfer.path}</a>
|
||||
<div className={cssStyle.status}>
|
||||
<ProgressBar ref={progressBar} value={props.transfer.progress * 100} type={progressBarMode(transferState)} text={progressBarText(transferState)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
@ReactEventHandler<TransferList>(e => e.props.events)
|
||||
class TransferList extends React.PureComponent<{ events: Registry<TransferInfoEvents> }, { state: "loading" | "error" | "normal", error?: string }> {
|
||||
private transfers: TransferInfoData[] = [];
|
||||
private showFinishedTransfers: boolean = true;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
state: "loading"
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const entries = [];
|
||||
|
||||
if(this.state.state === "error") {
|
||||
entries.push(<div key={"query-error"} className={cssStyle.queryError}><a><Translatable>Failed to query the file transfers:</Translatable><br/>{this.state.error}</a></div>);
|
||||
} else if(this.state.state === "loading") {
|
||||
entries.push(<div key={"loading"} className={cssStyle.querying}><a><Translatable>loading</Translatable> <LoadingDots maxDots={3}/></a></div>);
|
||||
} else {
|
||||
this.transfers.forEach(e => {
|
||||
entries.push(<TransferEntry finishedShown={this.showFinishedTransfers} key={"transfer-" + e.id} transfer={e} events={this.props.events} />);
|
||||
});
|
||||
entries.push(<div key={"no-transfers"} className={cssStyle.noTransfers}><a><Translatable>No transfers</Translatable></a></div>);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cssStyle.list}>{entries}</div>
|
||||
);
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.props.events.fire("query_transfers");
|
||||
}
|
||||
|
||||
@EventHandler<TransferInfoEvents>("action_toggle_finished_transfers")
|
||||
private handleToggleFinishedTransfers(event: TransferInfoEvents["action_toggle_finished_transfers"]) {
|
||||
this.showFinishedTransfers = event.visible;
|
||||
}
|
||||
|
||||
|
||||
@EventHandler<TransferInfoEvents>("action_remove_finished")
|
||||
private handleRemoveFinishedTransfers() {
|
||||
this.transfers = this.transfers.filter(e => e.status !== "finished" && e.status !== "errored");
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
@EventHandler<TransferInfoEvents>("query_transfers")
|
||||
private handleQueryTransfers() {
|
||||
this.setState({ state: "loading" });
|
||||
}
|
||||
|
||||
@EventHandler<TransferInfoEvents>("query_transfer_result")
|
||||
private handleQueryTransferResult(event: TransferInfoEvents["query_transfer_result"]) {
|
||||
this.setState({
|
||||
state: event.status === "success" ? "normal" : "error",
|
||||
error: event.status === "timeout" ? tr("Request timed out") : event.error || tr("unknown error")
|
||||
});
|
||||
if(event.status === "success")
|
||||
this.showFinishedTransfers = event.showFinished;
|
||||
|
||||
this.transfers = event.transfers || [];
|
||||
this.transfers.sort((a, b) => b.timestampRegistered - a.timestampRegistered);
|
||||
}
|
||||
|
||||
@EventHandler<TransferInfoEvents>("notify_transfer_registered")
|
||||
private handleTransferRegistered(event: TransferInfoEvents["notify_transfer_registered"]) {
|
||||
this.transfers.splice(0, 0, event.transfer);
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
@EventHandler<TransferInfoEvents>("notify_transfer_status")
|
||||
private handleTransferStatus(event: TransferInfoEvents["notify_transfer_status"]) {
|
||||
const transfer = this.transfers.find(e => e.id === event.id);
|
||||
if(!transfer) return;
|
||||
|
||||
switch (event.status) {
|
||||
case "finished":
|
||||
case "errored":
|
||||
case "none":
|
||||
transfer.timestampEnd = Date.now();
|
||||
break;
|
||||
|
||||
case "transferring":
|
||||
if(transfer.timestampBegin === 0)
|
||||
transfer.timestampBegin = Date.now();
|
||||
}
|
||||
transfer.status = event.status;
|
||||
transfer.error = event.error;
|
||||
}
|
||||
|
||||
@EventHandler<TransferInfoEvents>("notify_transfer_progress")
|
||||
private handleTransferProgress(event: TransferInfoEvents["notify_transfer_progress"]) {
|
||||
const transfer = this.transfers.find(e => e.id === event.id);
|
||||
if(!transfer) return;
|
||||
|
||||
transfer.progress = event.progress.file_current_offset / event.progress.file_total_size;
|
||||
transfer.status = event.status;
|
||||
transfer.transferredBytes = event.progress.file_bytes_transferred;
|
||||
}
|
||||
}
|
||||
|
||||
const ExtendedInfo = (props: { events: Registry<TransferInfoEvents> }) => {
|
||||
const [expended, setExpended] = useState(false);
|
||||
const [finishedShown, setFinishedShown] = useState(true);
|
||||
|
||||
props.events.reactUse("action_toggle_expansion", event => setExpended(event.visible));
|
||||
props.events.reactUse("action_toggle_finished_transfers", event => setFinishedShown(event.visible));
|
||||
props.events.reactUse("query_transfer_result", event => event.status === "success" && setFinishedShown(event.showFinished));
|
||||
|
||||
return <div className={cssStyle.expendedContainer + (expended ? "" : " " + cssStyle.hidden)} >
|
||||
<div className={cssStyle.header}>
|
||||
<a>{finishedShown ? <Translatable key={"file-transfers"}>File transfers</Translatable> : <Translatable key={"running-file-transfers"}>Running file transfers</Translatable>}</a>
|
||||
<Button disabled={!finishedShown} color={"blue"} onClick={() => props.events.fire("action_remove_finished")}><Translatable>Remove finished</Translatable></Button>
|
||||
</div>
|
||||
<TransferList events={props.events} />
|
||||
</div>;
|
||||
};
|
||||
|
||||
export const TransferInfo = (props: { events: Registry<TransferInfoEvents> }) => (
|
||||
<div className={cssStyle.container} >
|
||||
<ExtendedInfo events={props.events} />
|
||||
<BottomBar events={props.events} />
|
||||
</div>
|
||||
);
|
|
@ -0,0 +1,142 @@
|
|||
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
||||
import {Registry} from "tc-shared/events";
|
||||
import {
|
||||
FileTransfer,
|
||||
FileTransferDirection,
|
||||
FileTransferState,
|
||||
TransferProgress,
|
||||
TransferProperties
|
||||
} from "tc-shared/file/Transfer";
|
||||
import {
|
||||
avatarsPathPrefix,
|
||||
channelPathPrefix,
|
||||
iconPathPrefix,
|
||||
TransferStatus
|
||||
} from "tc-shared/ui/modal/transfer/ModalFileTransfer";
|
||||
import {Settings, settings} from "tc-shared/settings";
|
||||
import {TransferInfoData, TransferInfoEvents} from "tc-shared/ui/modal/transfer/TransferInfo";
|
||||
|
||||
export const initializeTransferInfoController = (connection: ConnectionHandler, events: Registry<TransferInfoEvents>) => {
|
||||
const generateTransferPath = (properties: TransferProperties) => {
|
||||
let path;
|
||||
if(properties.channel_id !== 0) {
|
||||
path = "/" + channelPathPrefix + properties.channel_id + properties.path;
|
||||
} else if(properties.name.startsWith("/avatar_")) {
|
||||
path = "/" + avatarsPathPrefix + "/";
|
||||
} else {
|
||||
path = "/" + iconPathPrefix + "/";
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
const getTransferStatus = (transfer: FileTransfer) : TransferStatus => {
|
||||
switch (transfer.transferState()) {
|
||||
case FileTransferState.INITIALIZING:
|
||||
case FileTransferState.PENDING:
|
||||
case FileTransferState.CONNECTING:
|
||||
return "pending";
|
||||
case FileTransferState.RUNNING:
|
||||
return "transferring";
|
||||
case FileTransferState.FINISHED:
|
||||
case FileTransferState.CANCELED:
|
||||
return "finished";
|
||||
case FileTransferState.ERRORED:
|
||||
return "errored";
|
||||
}
|
||||
};
|
||||
|
||||
const generateTransferInfo = (transfer: FileTransfer): TransferInfoData => {
|
||||
return {
|
||||
id: transfer.clientTransferId,
|
||||
direction: transfer.direction === FileTransferDirection.UPLOAD ? "upload" : "download",
|
||||
progress: 0,
|
||||
name: transfer.properties.name,
|
||||
path: generateTransferPath(transfer.properties),
|
||||
status: getTransferStatus(transfer),
|
||||
error: transfer.currentErrorMessage(),
|
||||
timestampRegistered: transfer.timings.timestampScheduled,
|
||||
timestampBegin: transfer.timings.timestampTransferBegin,
|
||||
timestampEnd: transfer.timings.timestampEnd,
|
||||
transferredBytes: transfer.lastProgressInfo() ? transfer.lastProgressInfo().file_current_offset - transfer.lastProgressInfo().file_start_offset : 0
|
||||
};
|
||||
};
|
||||
|
||||
events.on("action_toggle_finished_transfers", event => {
|
||||
settings.changeGlobal(Settings.KEY_TRANSFERS_SHOW_FINISHED, event.visible);
|
||||
});
|
||||
|
||||
events.on("action_remove_finished", () => {
|
||||
connection.fileManager.finishedTransfers.splice(0, connection.fileManager.finishedTransfers.length);
|
||||
});
|
||||
|
||||
events.on("query_transfers", () => {
|
||||
const transfers: TransferInfoData[] = connection.fileManager.registeredTransfers().map(generateTransferInfo);
|
||||
transfers.push(...connection.fileManager.finishedTransfers.map(e => {
|
||||
return {
|
||||
id: e.clientTransferId,
|
||||
direction: e.direction === FileTransferDirection.UPLOAD ? "upload" : "download",
|
||||
progress: 100,
|
||||
name: e.properties.name,
|
||||
path: generateTransferPath(e.properties),
|
||||
|
||||
status: e.state === FileTransferState.FINISHED ? "finished" : "errored",
|
||||
error: e.transferErrorMessage,
|
||||
|
||||
timestampRegistered: e.timings.timestampScheduled,
|
||||
timestampBegin: e.timings.timestampTransferBegin,
|
||||
timestampEnd: e.timings.timestampEnd,
|
||||
|
||||
transferredBytes: e.bytesTransferred
|
||||
} as TransferInfoData;
|
||||
}));
|
||||
|
||||
events.fire_async("query_transfer_result", {
|
||||
status: "success",
|
||||
transfers: transfers,
|
||||
showFinished: settings.global(Settings.KEY_TRANSFERS_SHOW_FINISHED)
|
||||
});
|
||||
});
|
||||
|
||||
/* the active transfer listener */
|
||||
{
|
||||
const listenToTransfer = (transfer: FileTransfer) => {
|
||||
const fireProgress = (progress: TransferProgress) => events.fire("notify_transfer_progress", {
|
||||
id: transfer.clientTransferId,
|
||||
progress: progress,
|
||||
status: "transferring",
|
||||
});
|
||||
|
||||
const progressListener = (event: {progress: TransferProgress}) => fireProgress(event.progress);
|
||||
|
||||
transfer.events.on("notify_progress", progressListener);
|
||||
|
||||
transfer.events.on("notify_state_updated", () => {
|
||||
const status = getTransferStatus(transfer);
|
||||
if(transfer.lastProgressInfo()) fireProgress(transfer.lastProgressInfo()); /* fire the progress info at least once */
|
||||
events.fire("notify_transfer_status", { id: transfer.clientTransferId, status: status, error: transfer.currentErrorMessage() });
|
||||
|
||||
if(transfer.isFinished()) {
|
||||
unregisterEvents();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
events.fire("notify_transfer_registered", { transfer: generateTransferInfo(transfer) });
|
||||
|
||||
const closeListener = () => unregisterEvents();
|
||||
events.on("notify_modal_closed", closeListener);
|
||||
|
||||
const unregisterEvents = () => {
|
||||
events.off("notify_modal_closed", closeListener);
|
||||
transfer.events.off("notify_progress", progressListener);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const registeredListener = event => listenToTransfer(event.transfer);
|
||||
connection.fileManager.events.on("notify_transfer_registered", registeredListener);
|
||||
events.on("notify_modal_closed", () => connection.fileManager.events.off("notify_transfer_registered", registeredListener));
|
||||
|
||||
connection.fileManager.registeredTransfers().forEach(transfer => listenToTransfer(transfer));
|
||||
}
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="898.3px" height="898.3px" viewBox="0 0 898.3 898.3" style="enable-background:new 0 0 898.3 898.3;" xml:space="preserve"
|
||||
>
|
||||
<g>
|
||||
<polygon points="120.2,882.5 553.6,449.2 120.2,15.8 0,136 313.2,449.2 0,762.3"/>
|
||||
<polygon points="344.7,762.3 464.9,882.5 898.3,449.2 464.9,15.8 344.7,136 657.9,449.2"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 584 B |
|
@ -0,0 +1,10 @@
|
|||
<svg width="512" height="512" enable-background="new 0 0 24 24" version="1.1" viewBox="0 0 24 24" fill="#7289da" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="rotate(180 12 4.313)">
|
||||
<path d="m14.25 3.75c-.192 0-.384-.073-.53-.22l-1.72-1.719-1.72 1.72c-.293.293-.768.293-1.061 0s-.293-.768 0-1.061l2.25-2.25c.293-.293.768-.293 1.061 0l2.25 2.25c.293.293.293.768 0 1.061-.146.146-.338.219-.53.219z"/>
|
||||
</g>
|
||||
<path d="m15.25 11h-6.5c-.965 0-1.75-.785-1.75-1.75v-1.5c0-.414.336-.75.75-.75s.75.336.75.75v1.5c0 .138.112.25.25.25h6.5c.138 0 .25-.112.25-.25v-1.5c0-.414.336-.75.75-.75s.75.336.75.75v1.5c0 .965-.785 1.75-1.75 1.75z"/>
|
||||
<path d="m12 8c-.414 0-.75-.336-.75-.75v-6.25c0-.414.336-.75.75-.75s.75.336.75.75v6.25c0 .414-.336.75-.75.75z"/>
|
||||
<path d="m22.25 21h-20.5c-.965 0-1.75-.785-1.75-1.75v-13.5c0-.965.785-1.75 1.75-1.75h3.5c.415 0 .75.336.75.75s-.335.75-.75.75h-3.5c-.138 0-.25.112-.25.25v13.5c0 .138.112.25.25.25h20.5c.138 0 .25-.112.25-.25v-13.5c0-.138-.112-.25-.25-.25h-3.5c-.414 0-.75-.336-.75-.75s.336-.75.75-.75h3.5c.965 0 1.75.785 1.75 1.75v13.5c0 .965-.785 1.75-1.75 1.75z"/>
|
||||
<path d="m23.25 17.5h-22.5c-.414 0-.75-.336-.75-.75s.336-.75.75-.75h22.5c.414 0 .75.336.75.75s-.336.75-.75.75z"/>
|
||||
<path d="m15.25 24h-6.5c-.303 0-.577-.183-.693-.463s-.052-.602.163-.817c1.041-1.041 1.265-2.553 1.267-2.567.054-.411.432-.704.841-.646.411.054.7.431.646.841-.008.059-.147 1.066-.744 2.152h3.523c-.591-1.08-.738-2.088-.746-2.147-.057-.41.229-.788.64-.846.414-.058.788.229.846.639.007.045.232 1.469 1.184 2.487.195.136.323.361.323.617 0 .414-.336.75-.75.75z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
|
@ -0,0 +1,21 @@
|
|||
<svg enable-background="new 0 0 24 24" height="512" viewBox="0 0 24 24" width="512" fill="#7289da"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g>
|
||||
<path d="m15.25 11h-6.5c-.965 0-1.75-.785-1.75-1.75v-1.5c0-.414.336-.75.75-.75s.75.336.75.75v1.5c0 .138.112.25.25.25h6.5c.138 0 .25-.112.25-.25v-1.5c0-.414.336-.75.75-.75s.75.336.75.75v1.5c0 .965-.785 1.75-1.75 1.75z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="m12 8c-.414 0-.75-.336-.75-.75v-6.25c0-.414.336-.75.75-.75s.75.336.75.75v6.25c0 .414-.336.75-.75.75z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="m14.25 3.75c-.192 0-.384-.073-.53-.22l-1.72-1.719-1.72 1.72c-.293.293-.768.293-1.061 0s-.293-.768 0-1.061l2.25-2.25c.293-.293.768-.293 1.061 0l2.25 2.25c.293.293.293.768 0 1.061-.146.146-.338.219-.53.219z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="m22.25 21h-20.5c-.965 0-1.75-.785-1.75-1.75v-13.5c0-.965.785-1.75 1.75-1.75h3.5c.415 0 .75.336.75.75s-.335.75-.75.75h-3.5c-.138 0-.25.112-.25.25v13.5c0 .138.112.25.25.25h20.5c.138 0 .25-.112.25-.25v-13.5c0-.138-.112-.25-.25-.25h-3.5c-.414 0-.75-.336-.75-.75s.336-.75.75-.75h3.5c.965 0 1.75.785 1.75 1.75v13.5c0 .965-.785 1.75-1.75 1.75z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="m23.25 17.5h-22.5c-.414 0-.75-.336-.75-.75s.336-.75.75-.75h22.5c.414 0 .75.336.75.75s-.336.75-.75.75z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="m15.25 24h-6.5c-.303 0-.577-.183-.693-.463s-.052-.602.163-.817c1.041-1.041 1.265-2.553 1.267-2.567.054-.411.432-.704.841-.646.411.054.7.431.646.841-.008.059-.147 1.066-.744 2.152h3.523c-.591-1.08-.738-2.088-.746-2.147-.057-.41.229-.788.64-.846.414-.058.788.229.846.639.007.045.232 1.469 1.184 2.487.195.136.323.361.323.617 0 .414-.336.75-.75.75z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.6 KiB |
|
@ -1,8 +1,9 @@
|
|||
import * as React from "react";
|
||||
import {ReactElement} from "react";
|
||||
const cssStyle = require("./Checkbox.scss");
|
||||
|
||||
export interface CheckboxProperties {
|
||||
label?: string;
|
||||
label?: ReactElement | string;
|
||||
disabled?: boolean;
|
||||
onChange?: (value: boolean) => void;
|
||||
initialValue?: boolean;
|
||||
|
@ -11,7 +12,8 @@ export interface CheckboxProperties {
|
|||
}
|
||||
|
||||
export interface CheckboxState {
|
||||
checked: boolean;
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export class Checkbox extends React.Component<CheckboxProperties, CheckboxState> {
|
||||
|
@ -19,17 +21,20 @@ export class Checkbox extends React.Component<CheckboxProperties, CheckboxState>
|
|||
super(props);
|
||||
|
||||
this.state = {
|
||||
checked: this.props.initialValue
|
||||
checked: this.props.initialValue,
|
||||
disabled: this.props.disabled
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const disabledClass = this.props.disabled ? cssStyle.disabled : "";
|
||||
const disabled = typeof this.state.disabled === "boolean" ? this.state.disabled : this.props.disabled;
|
||||
const checked = typeof this.state.checked === "boolean" ? this.state.checked : this.props.initialValue;
|
||||
const disabledClass = disabled ? cssStyle.disabled : "";
|
||||
|
||||
return (
|
||||
<label className={cssStyle.labelCheckbox + " " + disabledClass}>
|
||||
<div className={cssStyle.checkbox + " " + disabledClass}>
|
||||
<input type={"checkbox"} checked={this.state.checked} disabled={this.props.disabled} onChange={() => this.onStateChange()} />
|
||||
<input type={"checkbox"} checked={checked} disabled={disabled} onChange={() => this.onStateChange()} />
|
||||
<div className={cssStyle.mark} />
|
||||
</div>
|
||||
{this.props.label ? <a>{this.props.label}</a> : undefined}
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
import * as React from "react";
|
||||
import * as purify from "dompurify";
|
||||
|
||||
/*
|
||||
export const HTMLRenderer = (props: { purify: boolean, children: string }) => {
|
||||
const html = props.purify ? purify.sanitize(props.children) : props.children;
|
||||
|
||||
return <span dangerouslySetInnerHTML={{ __html: html }} />
|
||||
};
|
||||
*/
|
||||
|
||||
export class HTMLRenderer extends React.PureComponent<{ purify: boolean, children: string }, {}> {
|
||||
private readonly reference = React.createRef<HTMLSpanElement>();
|
||||
private readonly newNodes: Element[];
|
||||
private originalNode: HTMLSpanElement;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const html = this.props.purify ? purify.sanitize(this.props.children) : this.props.children;
|
||||
const node = document.createElement("div");
|
||||
node.innerHTML = html;
|
||||
|
||||
this.newNodes = [...node.children];
|
||||
}
|
||||
|
||||
render() {
|
||||
if(this.newNodes.length === 0)
|
||||
return null;
|
||||
|
||||
return <span ref={this.reference} />;
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
if(this.newNodes.length === 0)
|
||||
return;
|
||||
|
||||
this.originalNode = this.reference.current;
|
||||
|
||||
this.originalNode.replaceWith(this.newNodes[0]);
|
||||
this.newNodes.forEach((node, index, array) => {
|
||||
if(index === 0) return;
|
||||
|
||||
node.after(array[index - 1]);
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if(this.newNodes.length === 0)
|
||||
return;
|
||||
|
||||
this.newNodes.forEach((node, index) => {
|
||||
if(index === 0) return;
|
||||
|
||||
node.remove();
|
||||
});
|
||||
|
||||
this.newNodes[0].replaceWith(this.originalNode);
|
||||
}
|
||||
}
|
|
@ -46,7 +46,7 @@ export class LocalIconRenderer extends React.Component<LoadedIconRenderer, {}> {
|
|||
return <div className={"icon-container icon-empty"} title={this.props.title} />;
|
||||
return <div className={"icon_em client-group_" + icon.icon_id} />;
|
||||
}
|
||||
return <div key={"icon"} className={"icon-container"}><img src={icon.loaded_url} alt={this.props.title || ("icon " + icon.icon_id)} /></div>;
|
||||
return <div key={"icon"} className={"icon-container"}><img style={{ maxWidth: "100%", maxHeight: "100%" }} src={icon.loaded_url} alt={this.props.title || ("icon " + icon.icon_id)} /></div>;
|
||||
} else if(icon.status === "loading")
|
||||
return <div key={"loading"} className={"icon-container"} title={this.props.title}><div className={"icon_loading"} /></div>;
|
||||
else if(icon.status === "error")
|
||||
|
|
|
@ -0,0 +1,125 @@
|
|||
@import "../../../css/static/mixin";
|
||||
@import "../../../css/static/properties";
|
||||
|
||||
.container {
|
||||
border-radius: .2em;
|
||||
border: 1px solid #111112;
|
||||
|
||||
background-color: #121213;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: stretch;
|
||||
|
||||
color: #b3b3b3;
|
||||
|
||||
&.size-normal {
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
&.size-large {
|
||||
height: 2.5em;
|
||||
}
|
||||
|
||||
&.size-small {
|
||||
height: 1.7em;
|
||||
}
|
||||
|
||||
@include placeholder(&) {
|
||||
color: #606060;
|
||||
};
|
||||
|
||||
.prefix {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
margin: 0;
|
||||
|
||||
line-height: initial;
|
||||
align-self: center;
|
||||
padding: 0 .5em;
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
opacity: 1;
|
||||
|
||||
@include transition($button_hover_animation_time ease-in-out);
|
||||
}
|
||||
|
||||
&.is-invalid {
|
||||
background-color: #180d0d;
|
||||
border-color: #721c1c;
|
||||
|
||||
background-image: unset!important;
|
||||
}
|
||||
|
||||
&:focus, &:focus-within {
|
||||
background-color: #131b22;
|
||||
border-color: #284262;
|
||||
|
||||
color: #e1e2e3;
|
||||
|
||||
.prefix {
|
||||
width: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
input, select, .inputBox {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
|
||||
background: transparent;
|
||||
|
||||
border: none;
|
||||
outline: none;
|
||||
margin: 0;
|
||||
|
||||
color: #b3b3b3;
|
||||
|
||||
min-width: 2em;
|
||||
|
||||
&.editable {
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
|
||||
.inputBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.prefix + input {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
|
||||
&:focus, &:focus-within {
|
||||
.prefix + input {
|
||||
padding-left: .5em;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled, &:disabled {
|
||||
background-color: #1a1819;
|
||||
}
|
||||
|
||||
&.noRightIcon {
|
||||
input, select {
|
||||
padding-right: .5em;
|
||||
}
|
||||
}
|
||||
|
||||
&.noLeftIcon {
|
||||
input, select {
|
||||
padding-left: .5em;
|
||||
}
|
||||
}
|
||||
|
||||
@include transition($button_hover_animation_time ease-in-out);
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
import * as React from "react";
|
||||
import {ReactElement} from "react";
|
||||
|
||||
const cssStyle = require("./InputField.scss");
|
||||
|
||||
export interface BoxedInputFieldProperties {
|
||||
prefix?: string;
|
||||
placeholder?: string;
|
||||
|
||||
disabled?: boolean;
|
||||
editable?: boolean;
|
||||
|
||||
defaultValue?: string;
|
||||
|
||||
rightIcon?: () => ReactElement;
|
||||
leftIcon?: () => ReactElement;
|
||||
inputBox?: () => ReactElement; /* if set the onChange and onInput will not work anymore! */
|
||||
|
||||
isInvalid?: boolean;
|
||||
|
||||
className?: string;
|
||||
|
||||
size?: "normal" | "large" | "small";
|
||||
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
|
||||
onChange?: (newValue: string) => void;
|
||||
onInput?: (newValue: string) => void;
|
||||
}
|
||||
|
||||
export interface BoxedInputFieldState {
|
||||
disabled?: boolean;
|
||||
defaultValue?: string;
|
||||
isInvalid?: boolean;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export class BoxedInputField extends React.Component<BoxedInputFieldProperties, BoxedInputFieldState> {
|
||||
private refInput = React.createRef<HTMLInputElement>();
|
||||
private inputEdited = false;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
draggable={false}
|
||||
className={
|
||||
cssStyle.container + " " +
|
||||
cssStyle["size-" + (this.props.size || "normal")] +
|
||||
(this.state.disabled || this.props.disabled ? cssStyle.disabled : "") + " " +
|
||||
(this.state.isInvalid || this.props.isInvalid ? cssStyle.isInvalid : "") + " " +
|
||||
(this.props.leftIcon ? "" : cssStyle.noLeftIcon) + " " +
|
||||
(this.props.rightIcon ? "" : cssStyle.noRightIcon) + " " +
|
||||
this.props.className
|
||||
}
|
||||
|
||||
onFocus={this.props.onFocus}
|
||||
onBlur={() => this.onInputBlur()}
|
||||
>
|
||||
{this.props.leftIcon ? this.props.leftIcon() : ""}
|
||||
{this.props.prefix ? <a key={"prefix"} className={cssStyle.prefix}>{this.props.prefix}</a> : undefined}
|
||||
{this.props.inputBox ?
|
||||
<span key={"custom-input"} className={cssStyle.inputBox + " " + (this.props.editable ? cssStyle.editable : "")} onClick={this.props.onFocus}>{this.props.inputBox()}</span> :
|
||||
<input key={"input"}
|
||||
ref={this.refInput}
|
||||
value={this.state.value}
|
||||
defaultValue={this.state.defaultValue || this.props.defaultValue}
|
||||
placeholder={this.props.placeholder}
|
||||
readOnly={typeof this.props.editable === "boolean" ? this.props.editable : false}
|
||||
disabled={this.state.disabled || this.props.disabled}
|
||||
onInput={this.props.onInput && (event => this.props.onInput(event.currentTarget.value))}
|
||||
onKeyDown={e => this.onKeyDown(e)}
|
||||
/>}
|
||||
{this.props.rightIcon ? this.props.rightIcon() : ""}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
focusInput() {
|
||||
this.refInput.current?.focus();
|
||||
}
|
||||
|
||||
private onKeyDown(event: React.KeyboardEvent) {
|
||||
this.inputEdited = true;
|
||||
|
||||
if(event.key === "Enter")
|
||||
this.refInput.current?.blur();
|
||||
}
|
||||
|
||||
private onInputBlur() {
|
||||
if(this.props.onChange && this.inputEdited) {
|
||||
this.inputEdited = false;
|
||||
this.props.onChange(this.refInput.current.value);
|
||||
}
|
||||
|
||||
if(this.props.onBlur)
|
||||
this.props.onBlur();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import {useEffect, useState} from "react";
|
||||
import * as React from "react";
|
||||
|
||||
export const LoadingDots = (props: { maxDots?: number, speed?: number }) => {
|
||||
if(!props.maxDots || props.maxDots < 1)
|
||||
props.maxDots = 3;
|
||||
|
||||
const [dots, setDots] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setDots(dots + 1), props.speed || 500);
|
||||
return () => clearTimeout(timeout);
|
||||
});
|
||||
|
||||
let result = ".";
|
||||
for(let index = 0; index < dots % props.maxDots; index++)
|
||||
result += ".";
|
||||
return <div style={{ width: (props.maxDots / 3) + "em", display: "inline-block", textAlign: "left" }}>{result}</div>;
|
||||
};
|
|
@ -21,9 +21,9 @@ export enum ModalState {
|
|||
DESTROYED
|
||||
}
|
||||
|
||||
export class ModalController {
|
||||
export class ModalController<InstanceType extends Modal = Modal> {
|
||||
readonly events: Registry<ModalEvents>;
|
||||
readonly modalInstance: Modal;
|
||||
readonly modalInstance: InstanceType;
|
||||
|
||||
private initializedPromise: Promise<void>;
|
||||
|
||||
|
@ -31,7 +31,7 @@ export class ModalController {
|
|||
private refModal: React.RefObject<ModalImpl>;
|
||||
private modalState_: ModalState = ModalState.HIDDEN;
|
||||
|
||||
constructor(instance: Modal) {
|
||||
constructor(instance: InstanceType) {
|
||||
this.modalInstance = instance;
|
||||
instance["__modal_controller"] = this;
|
||||
|
||||
|
@ -170,6 +170,6 @@ class ModalImpl extends React.PureComponent<{ controller: ModalController }, {
|
|||
}
|
||||
}
|
||||
|
||||
export function spawnReactModal<ModalClass extends Modal>(modalClass: new () => ModalClass) : ModalController {
|
||||
return new ModalController(new modalClass());
|
||||
export function spawnReactModal<ModalClass extends Modal, T>(modalClass: new (T) => ModalClass, properties?: T) : ModalController<ModalClass> {
|
||||
return new ModalController(new modalClass(properties));
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
@import "../../../css/static/mixin";
|
||||
@import "../../../css/static/properties";
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
|
||||
height: 1.4em;
|
||||
border-radius: 0.2em;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
background-color: #242527;
|
||||
-webkit-box-shadow: inset 0 0 2px 0 rgba(0, 0, 0, 0.75);
|
||||
-moz-box-shadow: inset 0 0 2px 0 rgba(0, 0, 0, 0.75);
|
||||
box-shadow: inset 0 0 2px 0 rgba(0, 0, 0, 0.75);
|
||||
|
||||
.filler {
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
|
||||
@include transition($button_hover_animation_time ease-in-out);
|
||||
}
|
||||
|
||||
.text {
|
||||
align-self: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&.type-normal {
|
||||
.filler {
|
||||
background-color: #4370a299;
|
||||
}
|
||||
}
|
||||
|
||||
&.type-error {
|
||||
.filler {
|
||||
background-color: #a1000099;
|
||||
}
|
||||
}
|
||||
|
||||
&.type-success {
|
||||
.filler {
|
||||
background-color: #2b854199;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import * as React from "react";
|
||||
import {ReactElement} from "react";
|
||||
const cssStyle = require("./ProgressBar.scss");
|
||||
|
||||
export interface ProgressBarState {
|
||||
value?: number; /* [0;100] */
|
||||
text?: ReactElement | string;
|
||||
type?: "normal" | "error" | "success";
|
||||
}
|
||||
|
||||
export interface ProgressBarProperties {
|
||||
value: number; /* [0;100] */
|
||||
text?: ReactElement | string;
|
||||
type: "normal" | "error" | "success";
|
||||
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export class ProgressBar extends React.Component<ProgressBarProperties, ProgressBarState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={cssStyle.container + " " + cssStyle["type-" + (typeof this.state.type === "undefined" ? this.props.type : this.state.type)] + " " + (this.props.className || "")}>
|
||||
<div className={cssStyle.filler} style={{width: (typeof this.state.value === "number" ? this.state.value : this.props.value) + "%"}} />
|
||||
<div className={cssStyle.text}>
|
||||
{typeof this.state.text !== "undefined" ? this.state.text : this.props.text}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
.container {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
|
||||
min-height: 5em;
|
||||
|
||||
.dynamicColumn {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
|
||||
min-width: 2em;
|
||||
}
|
||||
|
||||
.fixedColumn {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
|
||||
min-height: 3em;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
|
||||
.headerSpacer {
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: stretch;
|
||||
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: stretch;
|
||||
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,196 @@
|
|||
import * as React from "react";
|
||||
import {ReactElement} from "react";
|
||||
|
||||
const cssStyle = require("./Table.scss");
|
||||
|
||||
export interface TableColumn {
|
||||
name: string;
|
||||
|
||||
header: () => ReactElement | ReactElement[];
|
||||
width?: number;
|
||||
fixedWidth?: string;
|
||||
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface TableRow<T = any> {
|
||||
columns: {[key: string]: () => ReactElement | ReactElement[]};
|
||||
|
||||
className?: string;
|
||||
userData?: T;
|
||||
}
|
||||
|
||||
export interface TableProperties {
|
||||
columns: TableColumn[];
|
||||
rows: TableRow[];
|
||||
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
bodyClassName?: string;
|
||||
|
||||
bodyOverlayOnly?: boolean;
|
||||
bodyOverlay?: () => ReactElement;
|
||||
|
||||
hiddenColumns?: string[];
|
||||
|
||||
onHeaderContextMenu?: (event: React.MouseEvent) => void;
|
||||
onBodyContextMenu?: (event: React.MouseEvent) => void;
|
||||
|
||||
renderRow?: (row: TableRow, columns: TableColumn[], uniqueId: string) => React.ReactElement<TableRowElement>;
|
||||
}
|
||||
|
||||
export interface TableState {
|
||||
hiddenColumns: string[];
|
||||
}
|
||||
|
||||
export interface TableRowProperties {
|
||||
columns: TableColumn[];
|
||||
rowData: TableRow;
|
||||
}
|
||||
|
||||
export interface TableRowState {
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export class TableRowElement extends React.Component<TableRowProperties & React.HTMLProps<HTMLDivElement>, TableRowState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
render() {
|
||||
if(this.state.hidden)
|
||||
return null;
|
||||
|
||||
let totalWidth = this.props.columns.map(e => e.width | 0).reduce((a, b) => a + b, 0);
|
||||
if(totalWidth === 0)
|
||||
totalWidth = 1;
|
||||
|
||||
const properties = Object.assign({}, this.props) as any;
|
||||
delete properties.rowData;
|
||||
delete properties.columns;
|
||||
properties.className = (properties.className || "") + " " + cssStyle.row;
|
||||
|
||||
const children = Array.isArray(this.props.children) ? this.props.children : typeof this.props.children !== "undefined" ? [this.props.children] : [];
|
||||
return React.createElement("div", properties, ...this.props.columns.map(column => {
|
||||
const supplier = this.props.rowData.columns[column.name];
|
||||
if(column.width) {
|
||||
return (
|
||||
<div key={"tr-" + column.name}
|
||||
className={cssStyle.dynamicColumn + " " + (column.className || "")}
|
||||
style={{width: (column.width * 100 / totalWidth) + "%"}}>
|
||||
{supplier ? supplier() : undefined}
|
||||
</div>
|
||||
);
|
||||
} else if(column.fixedWidth) {
|
||||
return (
|
||||
<div key={"th-" + column.name}
|
||||
className={cssStyle.fixedColumn + " " + (column.className || "")}
|
||||
style={{width: column.fixedWidth}}>
|
||||
{supplier ? supplier() : undefined}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}), ...children);
|
||||
}
|
||||
}
|
||||
|
||||
export class Table extends React.Component<TableProperties, TableState> {
|
||||
private rowIndex = 0;
|
||||
|
||||
private refHeader = React.createRef<HTMLDivElement>();
|
||||
private refHiddenHeader = React.createRef<HTMLDivElement>();
|
||||
private refBody = React.createRef<HTMLDivElement>();
|
||||
|
||||
private lastHeaderHeight = 20;
|
||||
private lastScrollbarWidth = 20;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hiddenColumns: this.props.hiddenColumns || []
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const columns = this.props.columns.filter(e => this.state.hiddenColumns.findIndex(b => e.name === b) === -1);
|
||||
let totalWidth = columns.map(e => e.width | 0).reduce((a, b) => a + b, 0);
|
||||
if(totalWidth === 0)
|
||||
totalWidth = 1;
|
||||
|
||||
const rowRenderer = this.props.renderRow || ((row, columns, uniqueId) => {
|
||||
return <TableRowElement key={uniqueId} rowData={row} columns={columns} />;
|
||||
});
|
||||
|
||||
let body;
|
||||
if(this.props.bodyOverlayOnly) {
|
||||
body = this.props.bodyOverlay ? this.props.bodyOverlay() : undefined;
|
||||
} else {
|
||||
body = this.props.rows.map((row: TableRow & { __rowIndex: number }) => {
|
||||
if(typeof row.__rowIndex !== "number")
|
||||
row.__rowIndex = ++this.rowIndex;
|
||||
|
||||
return rowRenderer(row, columns, "tr-" + row.__rowIndex);
|
||||
});
|
||||
|
||||
if(this.props.bodyOverlay)
|
||||
body.push(this.props.bodyOverlay());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cssStyle.container + " " + (this.props.className || " ")}>
|
||||
<div
|
||||
ref={this.refHeader}
|
||||
className={cssStyle.header + " " + (this.props.headerClassName || " ")}
|
||||
style={{right: this.lastScrollbarWidth}}
|
||||
onContextMenu={event => this.props.onHeaderContextMenu && this.props.onHeaderContextMenu(event)}
|
||||
>
|
||||
{columns.map(column => {
|
||||
if(column.width) {
|
||||
return (
|
||||
<div key={"th-" + column.name}
|
||||
className={cssStyle.dynamicColumn + " " + (column.className || "")}
|
||||
style={{width: (column.width * 100 / totalWidth) + "%"}}
|
||||
>
|
||||
{column.header()}
|
||||
</div>
|
||||
);
|
||||
} else if(column.fixedWidth) {
|
||||
return (
|
||||
<div key={"th-" + column.name}
|
||||
className={cssStyle.fixedColumn + " " + (column.className || "")}
|
||||
style={{width: column.fixedWidth}}
|
||||
>
|
||||
{column.header()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className={cssStyle.body + " " + (this.props.bodyClassName || " ")}
|
||||
ref={this.refBody}
|
||||
onContextMenu={e => this.props.onBodyContextMenu && this.props.onBodyContextMenu(e)}
|
||||
>
|
||||
<div ref={this.refHiddenHeader} style={{height: this.lastHeaderHeight}} className={cssStyle.row} />
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<TableProperties>, prevState: Readonly<TableState>, snapshot?: any): void {
|
||||
if(!this.refHiddenHeader.current || !this.refHeader.current || !this.refBody.current)
|
||||
return;
|
||||
|
||||
setTimeout(() => {
|
||||
this.lastHeaderHeight = this.refHeader.current.clientHeight;
|
||||
this.lastScrollbarWidth = this.refBody.current.parentElement.clientWidth - this.refBody.current.clientWidth;
|
||||
|
||||
this.refHiddenHeader.current.style.height = this.lastHeaderHeight + "px";
|
||||
this.refHeader.current.style.right = this.lastScrollbarWidth + "px";
|
||||
}, 10);
|
||||
}
|
||||
}
|
|
@ -9,6 +9,7 @@ import {LocalIconRenderer} from "tc-shared/ui/react-elements/Icon";
|
|||
import {EventHandler, ReactEventHandler} from "tc-shared/events";
|
||||
import {Settings, settings} from "tc-shared/settings";
|
||||
import {TreeEntry, UnreadMarker} from "tc-shared/ui/tree/TreeEntry";
|
||||
import {spawnFileTransferModal} from "tc-shared/ui/modal/transfer/ModalFileTransfer";
|
||||
|
||||
const channelStyle = require("./Channel.scss");
|
||||
const viewStyle = require("./View.scss");
|
||||
|
@ -247,6 +248,7 @@ export class ChannelEntryView extends TreeEntry<ChannelEntryViewProperties, {}>
|
|||
onMouseUp={e => this.onMouseUp(e)}
|
||||
onDoubleClick={() => this.onDoubleClick()}
|
||||
onContextMenu={e => this.onContextMenu(e)}
|
||||
onMouseDown={e => this.onMouseDown(e)}
|
||||
>
|
||||
<UnreadMarker entry={this.props.channel} />
|
||||
{collapsed_indicator && <ChannelCollapsedIndicator key={"collapsed-indicator"} onToggle={() => this.onCollapsedToggle()} collapsed={this.props.channel.collapsed} />}
|
||||
|
@ -279,6 +281,13 @@ export class ChannelEntryView extends TreeEntry<ChannelEntryViewProperties, {}>
|
|||
channel.joinChannel();
|
||||
}
|
||||
|
||||
private onMouseDown(event: React.MouseEvent) {
|
||||
if(event.buttons !== 4)
|
||||
return;
|
||||
|
||||
spawnFileTransferModal(this.props.channel.getChannelId());
|
||||
}
|
||||
|
||||
private onContextMenu(event: React.MouseEvent) {
|
||||
if(settings.static(Settings.KEY_DISABLE_CONTEXT_MENU))
|
||||
return;
|
||||
|
|
5413
shared/test.json
|
@ -0,0 +1,335 @@
|
|||
import {
|
||||
BrowserFileTransferSource,
|
||||
BufferTransferSource,
|
||||
DownloadTransferTarget,
|
||||
FileDownloadTransfer,
|
||||
FileTransfer,
|
||||
FileTransferState,
|
||||
FileUploadTransfer,
|
||||
ResponseTransferTarget,
|
||||
TextTransferSource,
|
||||
TransferProvider,
|
||||
TransferSourceType,
|
||||
TransferTargetType
|
||||
} from "tc-shared/file/Transfer";
|
||||
import * as log from "tc-shared/log";
|
||||
import {LogCategory} from "tc-shared/log";
|
||||
|
||||
TransferProvider.setProvider(new class extends TransferProvider {
|
||||
executeFileUpload(transfer: FileUploadTransfer) {
|
||||
try {
|
||||
if(!transfer.source) throw tr("transfer source is undefined");
|
||||
|
||||
let response: Promise<void>;
|
||||
transfer.setTransferState(FileTransferState.CONNECTING);
|
||||
if(transfer.source instanceof BrowserFileTransferSourceImpl) {
|
||||
response = formDataUpload(transfer, transfer.source.getFile());
|
||||
} else if(transfer.source instanceof BufferTransferSourceImpl) {
|
||||
response = formDataUpload(transfer, transfer.source.getBuffer());
|
||||
} else if(transfer.source instanceof TextTransferSourceImpl) {
|
||||
response = formDataUpload(transfer, transfer.source.getArrayBuffer());
|
||||
} else {
|
||||
transfer.setFailed({
|
||||
error: "io",
|
||||
reason: "unsupported-target"
|
||||
}, tr("invalid source type"));
|
||||
return;
|
||||
}
|
||||
|
||||
/* let the server notify us when the transfer has been finished */
|
||||
response.catch(error => {
|
||||
if(typeof error !== "string")
|
||||
log.error(LogCategory.FILE_TRANSFER, tr("Failed to upload object via HTTPS connection: %o"), error);
|
||||
|
||||
transfer.setFailed({
|
||||
error: "connection",
|
||||
reason: "network-error",
|
||||
extraMessage: typeof error === "string" ? error : tr("Lookup the console")
|
||||
}, typeof error === "string" ? error : tr("Lookup the console"));
|
||||
});
|
||||
} catch (error) {
|
||||
if(typeof error !== "string")
|
||||
log.error(LogCategory.FILE_TRANSFER, tr("Failed to initialize transfer source: %o"), error);
|
||||
|
||||
transfer.setFailed({
|
||||
error: "io",
|
||||
reason: "failed-to-initialize-target",
|
||||
extraMessage: typeof error === "string" ? error : tr("Lookup the console")
|
||||
}, typeof error === "string" ? error : tr("Lookup the console"));
|
||||
}
|
||||
}
|
||||
|
||||
executeFileDownload(transfer: FileDownloadTransfer) {
|
||||
transfer.targetSupplier(transfer).then(target => {
|
||||
if(!target) throw tr("transfer target is undefined");
|
||||
transfer.target = target;
|
||||
|
||||
let response: Promise<void>;
|
||||
transfer.setTransferState(FileTransferState.CONNECTING);
|
||||
if(target instanceof ResponseTransferTargetImpl) {
|
||||
response = responseFileDownload(transfer, target);
|
||||
} else if(target instanceof DownloadTransferTargetImpl) {
|
||||
response = downloadFileDownload(transfer, target);
|
||||
} else {
|
||||
transfer.setFailed({
|
||||
error: "io",
|
||||
reason: "unsupported-target"
|
||||
}, tr("invalid transfer target type"));
|
||||
return;
|
||||
}
|
||||
|
||||
response.then(() => {
|
||||
if(!transfer.isFinished()) {
|
||||
/* we still need to stream the body */
|
||||
transfer.setTransferState(FileTransferState.RUNNING);
|
||||
}
|
||||
}).catch(error => {
|
||||
if(typeof error !== "string")
|
||||
log.error(LogCategory.FILE_TRANSFER, tr("Failed to download file to response object: %o"), error);
|
||||
|
||||
transfer.setFailed({
|
||||
error: "connection",
|
||||
reason: "network-error",
|
||||
extraMessage: typeof error === "string" ? error : tr("Lookup the console")
|
||||
}, typeof error === "string" ? error : tr("Lookup the console"));
|
||||
});
|
||||
}).catch(error => {
|
||||
if(typeof error !== "string")
|
||||
log.error(LogCategory.FILE_TRANSFER, tr("Failed to initialize transfer target: %o"), error);
|
||||
|
||||
transfer.setFailed({
|
||||
error: "io",
|
||||
reason: "failed-to-initialize-target",
|
||||
extraMessage: typeof error === "string" ? error : tr("Lookup the console")
|
||||
}, typeof error === "string" ? error : tr("Lookup the console"));
|
||||
});
|
||||
}
|
||||
|
||||
targetSupported(type: TransferTargetType) {
|
||||
switch (type) {
|
||||
case TransferTargetType.DOWNLOAD:
|
||||
case TransferTargetType.RESPONSE:
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async createDownloadTarget(filename: string) {
|
||||
return new DownloadTransferTargetImpl(filename);
|
||||
}
|
||||
|
||||
async createResponseTarget() {
|
||||
return new ResponseTransferTargetImpl();
|
||||
}
|
||||
|
||||
sourceSupported(type: TransferSourceType) {
|
||||
switch (type) {
|
||||
case TransferSourceType.BROWSER_FILE:
|
||||
case TransferSourceType.BUFFER:
|
||||
case TransferSourceType.TEXT:
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async createBufferSource(buffer: ArrayBuffer): Promise<BufferTransferSource> {
|
||||
return new BufferTransferSourceImpl(buffer);
|
||||
}
|
||||
|
||||
async createBrowserFileSource(file: File): Promise<BrowserFileTransferSource> {
|
||||
return new BrowserFileTransferSourceImpl(file);
|
||||
}
|
||||
|
||||
async createTextSource(text: string): Promise<TextTransferSource> {
|
||||
return new TextTransferSourceImpl(text);
|
||||
}
|
||||
});
|
||||
|
||||
function generateTransferURL(transfer: FileTransfer, fileName?: string) {
|
||||
const properties = transfer.transferProperties();
|
||||
const url = "https://" + properties.addresses[0].serverAddress + ":" + properties.addresses[0].serverPort + "/";
|
||||
const parameters = {
|
||||
"transfer-key": properties.transferKey
|
||||
};
|
||||
if(typeof fileName !== "undefined")
|
||||
parameters["file-name"] = fileName;
|
||||
const query = "?" + Object.keys(parameters).map(e => e + "=" + encodeURIComponent(parameters[e])).join("&");
|
||||
return url + query;
|
||||
}
|
||||
|
||||
async function performHTTPSTransfer(transfer: FileTransfer, body: FormData | undefined) : Promise<Response> {
|
||||
try {
|
||||
const response = await fetch(generateTransferURL(transfer), {
|
||||
method: typeof body === "number" ? "GET" : "POST",
|
||||
cache: "no-cache",
|
||||
mode: "cors",
|
||||
body: body,
|
||||
headers: {
|
||||
/* for legacy TeaSpeak servers (prior to 1.4.15) */
|
||||
'transfer-key': transfer.transferProperties().transferKey,
|
||||
'download-name': transfer.properties.name,
|
||||
/* end legacy */
|
||||
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
"Access-Control-Expose-Headers": "*"
|
||||
}
|
||||
});
|
||||
|
||||
if(!response.ok) {
|
||||
throw (response.type == 'opaque' || response.type == 'opaqueredirect' ? "invalid cross origin flag! May target isn't a TeaSpeak server?" : response.statusText || "response is not ok");
|
||||
}
|
||||
|
||||
/* the transfer may not running anymore, because of a finished signal from the server (especially on file upload!) */
|
||||
if(transfer.isRunning()) {
|
||||
response.clone().blob().then(() => {
|
||||
if(transfer.isRunning())
|
||||
transfer.setTransferState(FileTransferState.FINISHED);
|
||||
}).catch(error => {
|
||||
if(typeof error !== "string")
|
||||
log.error(LogCategory.FILE_TRANSFER, tr("Failed to transfer data throw a HTTPS request: %o"), error);
|
||||
transfer.setFailed({
|
||||
error: "io",
|
||||
reason: "buffer-transfer-failed",
|
||||
extraMessage: typeof error === "string" ? error : tr("lookup the console")
|
||||
}, typeof error === "string" ? error : tr("lookup the console"));
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if(error instanceof Error && error.message === "Failed to fetch")
|
||||
throw "HTTPS download failed";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function responseFileDownload(transfer: FileDownloadTransfer, target: ResponseTransferTargetImpl) {
|
||||
target.setResponse(await performHTTPSTransfer(transfer, undefined));
|
||||
}
|
||||
|
||||
async function downloadFileDownload(transfer: FileDownloadTransfer, target: DownloadTransferTargetImpl) {
|
||||
const url = generateTransferURL(transfer);
|
||||
target.startDownloadURL(url);
|
||||
}
|
||||
|
||||
export class ResponseTransferTargetImpl extends ResponseTransferTarget {
|
||||
private response: Response;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
hasResponse() {
|
||||
return typeof this.response !== "undefined";
|
||||
}
|
||||
|
||||
getResponse() {
|
||||
return this.response;
|
||||
}
|
||||
|
||||
setResponse(response: Response) {
|
||||
this.response = response;
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadTransferTargetImpl extends DownloadTransferTarget {
|
||||
readonly fileName: string | undefined;
|
||||
|
||||
constructor(fileName: string | undefined) {
|
||||
super();
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
||||
startDownloadURL(url: string) {
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.target = "_blank";
|
||||
if(this.fileName)
|
||||
a.download = this.fileName;
|
||||
document.body.appendChild(a);
|
||||
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
}
|
||||
|
||||
class BrowserFileTransferSourceImpl extends BrowserFileTransferSource {
|
||||
private readonly file: File;
|
||||
|
||||
constructor(file: File) {
|
||||
super();
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
getFile(): File {
|
||||
return this.file;
|
||||
}
|
||||
|
||||
async fileSize(): Promise<number> {
|
||||
return this.file.size;
|
||||
}
|
||||
}
|
||||
|
||||
class BufferTransferSourceImpl extends BufferTransferSource {
|
||||
private readonly buffer: ArrayBuffer;
|
||||
|
||||
constructor(buffer: ArrayBuffer) {
|
||||
super();
|
||||
this.buffer = buffer;
|
||||
}
|
||||
|
||||
getBuffer(): ArrayBuffer {
|
||||
return this.buffer;
|
||||
}
|
||||
|
||||
async fileSize(): Promise<number> {
|
||||
return this.buffer.byteLength;
|
||||
}
|
||||
}
|
||||
|
||||
class TextTransferSourceImpl extends TextTransferSource {
|
||||
private readonly text: string;
|
||||
private buffer: ArrayBuffer;
|
||||
|
||||
constructor(text: string) {
|
||||
super();
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
getText(): string {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
|
||||
async fileSize(): Promise<number> {
|
||||
return this.getArrayBuffer().byteLength;
|
||||
}
|
||||
|
||||
getArrayBuffer() : ArrayBuffer {
|
||||
if(this.buffer) return this.buffer;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
this.buffer = encoder.encode(this.text);
|
||||
return this.buffer;
|
||||
}
|
||||
}
|
||||
|
||||
async function formDataUpload(transfer: FileUploadTransfer, data: File | ArrayBuffer | string) {
|
||||
const formData = new FormData();
|
||||
|
||||
if(data instanceof File) {
|
||||
formData.append("file", data);
|
||||
} else if(typeof(data) === "string") {
|
||||
formData.append("file", new Blob([data], { type: "application/octet-stream" }));
|
||||
} else {
|
||||
const buffer = data as BufferSource;
|
||||
formData.append("file", new Blob([buffer], { type: "application/octet-stream" }));
|
||||
}
|
||||
|
||||
await performHTTPSTransfer(transfer, formData);
|
||||
}
|
|
@ -384,7 +384,7 @@ export class ServerConnection extends AbstractServerConnection {
|
|||
this._ping.last_response = 'now' in performance ? performance.now() : Date.now();
|
||||
this._ping.value = this._ping.last_response - this._ping.last_request;
|
||||
this._ping.value_native = parseInt(json["ping_native"]) / 1000; /* we're getting it in microseconds and not milliseconds */
|
||||
log.debug(LogCategory.NETWORKING, tr("Received new pong. Updating ping to: JS: %o Native: %o"), this._ping.value.toFixed(3), this._ping.value_native.toFixed(3));
|
||||
//log.debug(LogCategory.NETWORKING, tr("Received new pong. Updating ping to: JS: %o Native: %o"), this._ping.value.toFixed(3), this._ping.value_native.toFixed(3));
|
||||
}
|
||||
} else {
|
||||
log.warn(LogCategory.NETWORKING, tr("Unknown command type %o"), json["type"]);
|
||||
|
|
|
@ -2,3 +2,5 @@ const webrtc_adapter = require("webrtc-adapter");
|
|||
/* typescript keep alive */ let _x = (webrtc_adapter || "").toString();
|
||||
const tc = require("tc-shared/main");
|
||||
export = tc;
|
||||
|
||||
require("./FileTransfer");
|
|
@ -1 +1 @@
|
|||
Subproject commit adcb7bc21d0afa79c1975030b29dfeef76651839
|
||||
Subproject commit 5c94ec3205c30171ffd01056f5b4622b7c0ab54c
|
|
@ -97,7 +97,7 @@ export const config = async (target: "web" | "client") => { return {
|
|||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.s[ac]ss$/,
|
||||
test: /\.(s[ac]|c)ss$/,
|
||||
loader: [
|
||||
'style-loader',
|
||||
/*
|
||||
|
@ -138,7 +138,8 @@ export const config = async (target: "web" | "client") => { return {
|
|||
getCustomTransformers: (prog: ts.Program) => {
|
||||
return {
|
||||
before: [trtransformer(prog, {
|
||||
optimized: true,
|
||||
optimized: false,
|
||||
verbose: true,
|
||||
target_file: path.join(__dirname, "dist", "translations.json")
|
||||
})]
|
||||
};
|
||||
|
@ -158,6 +159,10 @@ export const config = async (target: "web" | "client") => { return {
|
|||
loader: [
|
||||
"./webpack/WatLoader.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /\.svg$/,
|
||||
loader: 'svg-inline-loader'
|
||||
}
|
||||
],
|
||||
},
|
||||
|
|