2020-05-03 18:59:18 +00:00
|
|
|
import * as log from "tc-shared/log";
|
|
|
|
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 {image_type, ImageCache, ImageType, media_image_type} from "tc-shared/file/ImageCache";
|
2020-06-10 16:13:56 +00:00
|
|
|
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";
|
2020-07-19 15:12:41 +00:00
|
|
|
import {tr} from "tc-shared/i18n/localize";
|
2020-05-03 18:59:18 +00:00
|
|
|
|
|
|
|
const icon_cache: ImageCache = new ImageCache("icons");
|
|
|
|
export interface IconManagerEvents {
|
|
|
|
notify_icon_state_changed: {
|
|
|
|
icon_id: number,
|
|
|
|
server_unique_id: string,
|
|
|
|
|
|
|
|
icon: LocalIcon
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
//TODO: Invalidate icon after certain time if loading has failed and try to redownload (only if an icon loader has been set!)
|
2020-05-03 21:32:04 +00:00
|
|
|
export type IconLoader = (icon?: LocalIcon) => Promise<Response>;
|
2020-05-03 18:59:18 +00:00
|
|
|
export class LocalIcon {
|
|
|
|
readonly icon_id: number;
|
|
|
|
readonly server_unique_id: string;
|
|
|
|
readonly status_change_callbacks: ((icon?: LocalIcon) => void)[] = [];
|
|
|
|
|
|
|
|
status: "loading" | "loaded" | "empty" | "error" | "destroyed";
|
|
|
|
|
|
|
|
loaded_url?: string;
|
|
|
|
error_message?: string;
|
|
|
|
|
|
|
|
private callback_icon_loader: IconLoader;
|
|
|
|
|
|
|
|
constructor(id: number, server: string, loader_or_response: Response | IconLoader | undefined) {
|
|
|
|
this.icon_id = id;
|
|
|
|
this.server_unique_id = server;
|
|
|
|
|
|
|
|
if(id >= 0 && id <= 1000) {
|
|
|
|
/* Internal TeaSpeak icons. These must be handled differently! */
|
|
|
|
this.status = "loaded";
|
|
|
|
} else {
|
|
|
|
this.status = "loading";
|
|
|
|
if(loader_or_response instanceof Response) {
|
|
|
|
this.set_image(loader_or_response).catch(error => {
|
|
|
|
log.error(LogCategory.GENERAL, tr("Icon set image method threw an unexpected error: %o"), error);
|
|
|
|
this.status = "error";
|
|
|
|
this.error_message = "unexpected parse error";
|
|
|
|
this.triggerStatusChange();
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
this.callback_icon_loader = loader_or_response;
|
|
|
|
this.load().catch(error => {
|
|
|
|
log.error(LogCategory.GENERAL, tr("Icon load method threw an unexpected error: %o"), error);
|
|
|
|
this.status = "error";
|
|
|
|
this.error_message = "unexpected load error";
|
|
|
|
this.triggerStatusChange();
|
|
|
|
}).then(() => {
|
|
|
|
this.callback_icon_loader = undefined; /* release resources captured by possible closures */
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private triggerStatusChange() {
|
|
|
|
for(const lister of this.status_change_callbacks.slice(0))
|
|
|
|
lister(this);
|
|
|
|
}
|
|
|
|
|
|
|
|
/* called within the CachedIconManager */
|
|
|
|
protected destroy() {
|
|
|
|
if(typeof this.loaded_url === "string" && URL.revokeObjectURL)
|
|
|
|
URL.revokeObjectURL(this.loaded_url);
|
|
|
|
|
|
|
|
this.status = "destroyed";
|
|
|
|
this.loaded_url = undefined;
|
|
|
|
this.error_message = undefined;
|
|
|
|
|
|
|
|
this.triggerStatusChange();
|
|
|
|
this.status_change_callbacks.splice(0, this.status_change_callbacks.length);
|
|
|
|
}
|
|
|
|
|
|
|
|
private async load() {
|
|
|
|
if(!icon_cache.setupped())
|
|
|
|
await icon_cache.setup();
|
|
|
|
|
|
|
|
let response = await icon_cache.resolve_cached("icon_" + this.server_unique_id + "_" + this.icon_id); //TODO age!
|
|
|
|
if(!response) {
|
|
|
|
if(typeof this.callback_icon_loader !== "function") {
|
|
|
|
this.status = "empty";
|
|
|
|
this.triggerStatusChange();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
response = await this.callback_icon_loader(this);
|
|
|
|
} catch (error) {
|
|
|
|
log.warn(LogCategory.GENERAL, tr("Failed to download icon %d: %o"), this.icon_id, error);
|
|
|
|
await this.set_error(typeof error === "string" ? error : tr("Failed to load icon"));
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
try {
|
|
|
|
await this.set_image(response);
|
|
|
|
} catch (error) {
|
|
|
|
log.error(LogCategory.GENERAL, tr("Failed to update icon image for icon %d: %o"), this.icon_id, error);
|
|
|
|
await this.set_error(typeof error === "string" ? error : tr("Failed to update icon from downloaded file"));
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.loaded_url = await response_to_url(response);
|
|
|
|
this.status = "loaded";
|
|
|
|
this.triggerStatusChange();
|
|
|
|
}
|
|
|
|
|
|
|
|
async set_image(response: Response) {
|
|
|
|
if(this.icon_id >= 0 && this.icon_id <= 1000) throw "Could not set image for internal icon";
|
|
|
|
|
|
|
|
const type = image_type(response.headers.get('X-media-bytes'));
|
|
|
|
if(type === ImageType.UNKNOWN) throw "unknown image type";
|
|
|
|
|
|
|
|
const media = media_image_type(type);
|
|
|
|
await icon_cache.put_cache("icon_" + this.server_unique_id + "_" + this.icon_id, response.clone(), "image/" + media);
|
|
|
|
|
|
|
|
this.loaded_url = await response_to_url(response);
|
|
|
|
this.status = "loaded";
|
|
|
|
this.triggerStatusChange();
|
|
|
|
}
|
|
|
|
|
|
|
|
set_error(error: string) {
|
|
|
|
if(this.status === "loaded" || this.status === "destroyed") return;
|
|
|
|
if(this.status === "error" && this.error_message === error) return;
|
|
|
|
this.status = "error";
|
|
|
|
this.error_message = error;
|
|
|
|
this.triggerStatusChange();
|
|
|
|
}
|
|
|
|
|
|
|
|
async await_loading() {
|
|
|
|
await new Promise(resolve => {
|
|
|
|
if(this.status !== "loading") {
|
|
|
|
resolve();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const callback = () => {
|
|
|
|
if(this.status === "loading") return;
|
|
|
|
|
|
|
|
this.status_change_callbacks.remove(callback);
|
|
|
|
resolve();
|
|
|
|
};
|
|
|
|
this.status_change_callbacks.push(callback);
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function response_to_url(response: Response) {
|
|
|
|
if(!response.headers.has('X-media-bytes'))
|
|
|
|
throw "missing media bytes";
|
|
|
|
|
|
|
|
const type = image_type(response.headers.get('X-media-bytes'));
|
|
|
|
const media = media_image_type(type);
|
|
|
|
|
|
|
|
const blob = await response.blob();
|
|
|
|
if(blob.type !== "image/" + media)
|
|
|
|
return URL.createObjectURL(blob.slice(0, blob.size, "image/" + media));
|
|
|
|
else
|
|
|
|
return URL.createObjectURL(blob)
|
|
|
|
}
|
|
|
|
|
|
|
|
class CachedIconManager {
|
|
|
|
private loaded_icons: {[id: string]:LocalIcon} = {};
|
|
|
|
|
|
|
|
async clear_cache() {
|
|
|
|
await icon_cache.reset();
|
|
|
|
this.clear_memory_cache();
|
|
|
|
}
|
|
|
|
|
|
|
|
clear_memory_cache() {
|
|
|
|
for(const icon_id of Object.keys(this.loaded_icons))
|
|
|
|
this.loaded_icons[icon_id]["destroy"]();
|
|
|
|
this.loaded_icons = {};
|
|
|
|
}
|
|
|
|
|
|
|
|
load_icon(id: number, server_unique_id: string, fallback_load?: IconLoader) : LocalIcon {
|
|
|
|
const cache_id = server_unique_id + "_" + (id >>> 0);
|
|
|
|
if(this.loaded_icons[cache_id]) return this.loaded_icons[cache_id];
|
|
|
|
|
|
|
|
return (this.loaded_icons[cache_id] = new LocalIcon(id >>> 0, server_unique_id, fallback_load));
|
|
|
|
}
|
|
|
|
|
|
|
|
async put_icon(id: number, server_unique_id: string, icon: Response) {
|
|
|
|
const cache_id = server_unique_id + "_" + (id >>> 0);
|
|
|
|
if(this.loaded_icons[cache_id])
|
|
|
|
await this.loaded_icons[cache_id].set_image(icon);
|
|
|
|
else {
|
|
|
|
const licon = this.loaded_icons[cache_id] = new LocalIcon(id >>> 0, server_unique_id, icon);
|
|
|
|
await new Promise((resolve, reject) => {
|
|
|
|
const cb = () => {
|
|
|
|
licon.status_change_callbacks.remove(cb);
|
|
|
|
if(licon.status === "loaded")
|
|
|
|
resolve();
|
|
|
|
else
|
|
|
|
reject(licon.status === "error" ? licon.error_message || tr("Unknown error") : tr("Invalid status"));
|
|
|
|
};
|
|
|
|
|
|
|
|
licon.status_change_callbacks.push(cb);
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
export const icon_cache_loader = new CachedIconManager();
|
|
|
|
window.addEventListener("beforeunload", () => {
|
|
|
|
icon_cache_loader.clear_memory_cache();
|
|
|
|
});
|
|
|
|
|
2020-06-10 16:13:56 +00:00
|
|
|
(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();
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2020-05-03 18:59:18 +00:00
|
|
|
type IconManagerLoadingData = {
|
|
|
|
result: "success" | "error" | "unset";
|
|
|
|
next_retry?: number;
|
|
|
|
error?: string;
|
|
|
|
}
|
|
|
|
export class IconManager {
|
|
|
|
handle: FileManager;
|
|
|
|
readonly events: Registry<IconManagerEvents>;
|
|
|
|
private loading_timestamps: {[key: number]: IconManagerLoadingData} = {};
|
|
|
|
|
|
|
|
constructor(handle: FileManager) {
|
|
|
|
this.handle = handle;
|
|
|
|
this.events = new Registry<IconManagerEvents>();
|
|
|
|
}
|
|
|
|
|
|
|
|
destroy() {
|
|
|
|
this.loading_timestamps = {};
|
|
|
|
}
|
|
|
|
|
|
|
|
async delete_icon(id: number) : Promise<void> {
|
|
|
|
if(id <= 1000)
|
|
|
|
throw "invalid id!";
|
|
|
|
|
2020-06-10 16:13:56 +00:00
|
|
|
await this.handle.deleteFile({
|
2020-05-03 18:59:18 +00:00
|
|
|
name: '/icon_' + id
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-06-10 16:13:56 +00:00
|
|
|
iconList() : Promise<FileInfo[]> {
|
2020-05-03 18:59:18 +00:00
|
|
|
return this.handle.requestFileList("/icons");
|
|
|
|
}
|
|
|
|
|
2020-06-10 16:13:56 +00:00
|
|
|
createIconDownload(id: number) : FileDownloadTransfer {
|
|
|
|
return this.handle.initializeFileDownload({
|
|
|
|
path: "",
|
|
|
|
name: "/icon_" + id,
|
|
|
|
targetSupplier: async () => await TransferProvider.provider().createResponseTarget()
|
|
|
|
});
|
2020-05-03 18:59:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
private async server_icon_loader(icon: LocalIcon) : Promise<Response> {
|
|
|
|
const loading_data: IconManagerLoadingData = this.loading_timestamps[icon.icon_id] || (this.loading_timestamps[icon.icon_id] = { result: "unset" });
|
|
|
|
if(loading_data.result === "error") {
|
|
|
|
if(!loading_data.next_retry || loading_data.next_retry > Date.now()) {
|
|
|
|
log.debug(LogCategory.GENERAL, tr("Don't retry icon download from server. We'll try again in %s"),
|
|
|
|
!loading_data.next_retry ? tr("never") : format_time(loading_data.next_retry - Date.now(), tr("1 second")));
|
|
|
|
throw loading_data.error;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
2020-06-10 16:13:56 +00:00
|
|
|
let transfer = this.createIconDownload(icon.icon_id);
|
|
|
|
|
2020-05-03 18:59:18 +00:00
|
|
|
try {
|
2020-06-10 16:13:56 +00:00
|
|
|
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");
|
|
|
|
}
|
2020-05-03 18:59:18 +00:00
|
|
|
} catch(error) {
|
|
|
|
if(error instanceof CommandResult) {
|
|
|
|
if(error.id === ErrorID.FILE_NOT_FOUND)
|
|
|
|
throw tr("Icon could not be found");
|
|
|
|
else if(error.id === ErrorID.PERMISSION_ERROR)
|
|
|
|
throw tr("No permissions to download icon");
|
|
|
|
else
|
|
|
|
throw error.extra_message || error.message;
|
|
|
|
}
|
|
|
|
log.error(LogCategory.CLIENT, tr("Could not request download for icon %d: %o"), icon.icon_id, error);
|
2020-06-10 16:13:56 +00:00
|
|
|
if(error === transfer.currentError())
|
|
|
|
throw transfer.currentErrorMessage();
|
2020-05-03 18:59:18 +00:00
|
|
|
throw typeof error === "string" ? error : tr("Failed to initialize icon download");
|
|
|
|
}
|
|
|
|
|
2020-06-10 16:13:56 +00:00
|
|
|
/* 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");
|
2020-05-03 18:59:18 +00:00
|
|
|
|
|
|
|
loading_data.result = "success";
|
2020-06-10 16:13:56 +00:00
|
|
|
return response.getResponse();
|
2020-05-03 18:59:18 +00:00
|
|
|
} catch (error) {
|
|
|
|
loading_data.result = "error";
|
|
|
|
loading_data.error = error as string;
|
|
|
|
loading_data.next_retry = Date.now() + 300 * 1000;
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
static generate_tag(icon: LocalIcon | undefined, options?: {
|
|
|
|
animate?: boolean
|
|
|
|
}) : JQuery<HTMLDivElement> {
|
|
|
|
options = options || {};
|
|
|
|
|
|
|
|
let icon_container = $.spawn("div").addClass("icon-container icon_empty");
|
|
|
|
let icon_load_image = $.spawn("div").addClass("icon_loading");
|
|
|
|
|
|
|
|
const icon_image = $.spawn("img").attr("width", 16).attr("height", 16).attr("alt", "");
|
|
|
|
|
|
|
|
if (icon.icon_id == 0) {
|
|
|
|
icon_load_image = undefined;
|
|
|
|
} else if (icon.icon_id < 1000) {
|
|
|
|
icon_load_image = undefined;
|
|
|
|
icon_container.removeClass("icon_empty").addClass("icon_em client-group_" + icon.icon_id);
|
|
|
|
} else {
|
|
|
|
const loading_done = sync => {//TODO: Show error?
|
|
|
|
if(icon.status === "empty") {
|
|
|
|
icon_load_image.remove();
|
|
|
|
icon_load_image = undefined;
|
|
|
|
} else if(icon.status === "error") {
|
|
|
|
//TODO: Error icon?
|
|
|
|
icon_load_image.remove();
|
|
|
|
icon_load_image = undefined;
|
|
|
|
} else {
|
|
|
|
icon_image.attr("src", icon.loaded_url);
|
|
|
|
icon_container.append(icon_image).removeClass("icon_empty");
|
|
|
|
|
|
|
|
if (!sync && (typeof (options.animate) !== "boolean" || options.animate)) {
|
|
|
|
icon_image.css("opacity", 0);
|
|
|
|
|
|
|
|
icon_load_image.animate({opacity: 0}, 50, function () {
|
|
|
|
icon_load_image.remove();
|
|
|
|
icon_image.animate({opacity: 1}, 150);
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
icon_load_image.remove();
|
|
|
|
icon_load_image = undefined;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
if(icon.status !== "loading")
|
|
|
|
loading_done(true);
|
|
|
|
else {
|
|
|
|
const cb = () => {
|
|
|
|
if(icon.status === "loading") return;
|
|
|
|
|
|
|
|
icon.status_change_callbacks.remove(cb);
|
|
|
|
loading_done(false);
|
|
|
|
};
|
|
|
|
icon.status_change_callbacks.push(cb);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if(icon_load_image)
|
|
|
|
icon_load_image.appendTo(icon_container);
|
|
|
|
return icon_container;
|
|
|
|
}
|
|
|
|
|
|
|
|
generateTag(id: number, options?: {
|
|
|
|
animate?: boolean
|
|
|
|
}) : JQuery<HTMLDivElement> {
|
|
|
|
options = options || {};
|
|
|
|
return IconManager.generate_tag(this.load_icon(id), options);
|
|
|
|
}
|
|
|
|
|
|
|
|
load_icon(id: number) : LocalIcon {
|
2020-06-10 16:13:56 +00:00
|
|
|
const server_uid = this.handle.connectionHandler.channelTree.server.properties.virtualserver_unique_identifier;
|
2020-05-03 18:59:18 +00:00
|
|
|
let icon = icon_cache_loader.load_icon(id, server_uid, this.server_icon_loader.bind(this));
|
|
|
|
if(icon.status !== "loading" && icon.status !== "loaded") {
|
|
|
|
this.server_icon_loader(icon).then(response => {
|
|
|
|
return icon.set_image(response);
|
|
|
|
}).catch(error => {
|
|
|
|
console.warn("Failed to update broken cached icon from server: %o", error);
|
|
|
|
})
|
|
|
|
}
|
|
|
|
return icon;
|
|
|
|
}
|
2020-06-10 16:13:56 +00:00
|
|
|
|
|
|
|
flush_cache() {
|
|
|
|
this.loading_timestamps = {};
|
|
|
|
}
|
2020-05-03 18:59:18 +00:00
|
|
|
}
|