TeaWeb/shared/js/ui/modal/icon-viewer/Controller.ts

437 lines
No EOL
18 KiB
TypeScript

import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {Registry} from "tc-events";
import {
IconUploadProgress,
ModalIconViewerEvents,
ModalIconViewerVariables,
RemoteIconList,
SelectedIconTab
} from "tc-shared/ui/modal/icon-viewer/Definitions";
import {IpcUiVariableProvider} from "tc-shared/ui/utils/IpcVariable";
import {CallOnce, ignorePromise} from "tc-shared/proto";
import {spawnModal} from "tc-shared/ui/react-elements/modal";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import {LogCategory, logError, logWarn} from "tc-shared/log";
import {ErrorCode} from "tc-shared/connection/ErrorCode";
import PermissionType from "tc-shared/permission/PermissionType";
import {FileTransferState, TransferProvider} from "tc-shared/file/Transfer";
type IconUpload = {
state: "initializing"
} | {
state: "uploading",
iconId: number,
buffer: ArrayBuffer,
progress: IconUploadProgress
};
const kUploadingIconPrefix = "uploading-";
const kRemoteIconPrefix = "remote-";
class Controller {
readonly connection: ConnectionHandler;
readonly events: Registry<ModalIconViewerEvents>;
readonly variables: IpcUiVariableProvider<ModalIconViewerVariables>;
private connectionListener: (() => void)[];
private remoteIconList: RemoteIconList;
private selectedIconId: string;
private selectedTab: SelectedIconTab;
private runningUploads: { [key: string]: IconUpload };
constructor(connection: ConnectionHandler) {
this.connection = connection;
this.events = new Registry<ModalIconViewerEvents>();
this.variables = new IpcUiVariableProvider<ModalIconViewerVariables>();
this.selectedTab = "remote";
this.selectedIconId = undefined;
this.remoteIconList = { status: "loading" };
this.runningUploads = {};
this.variables.setVariableProvider("remoteIconList", () => this.remoteIconList);
this.variables.setVariableProvider("remoteIconInfo", (iconId: string) => {
if(iconId.startsWith(kRemoteIconPrefix)) {
return { status: "live", iconId: parseInt(iconId.substring(7)) >>> 0 };
} else if(iconId.startsWith(kUploadingIconPrefix)) {
const uploadId = iconId.substring(kUploadingIconPrefix.length);
const upload = this.runningUploads[uploadId];
if(!upload) {
return { status: "unknown" };
}
switch (upload.state) {
case "initializing":
return { status: "uploading", process: { state: "pre-process" }};
case "uploading":
return { status: "uploading", process: upload.progress };
default:
return { status: "uploading", process: { state: "failed", message: tr("unknown state") } };
}
}
return { status: "unknown" };
});
this.variables.setVariableProvider("uploadingIconPayload", (iconId: string) => {
if(!iconId.startsWith(kUploadingIconPrefix)) {
return undefined;
}
const uploadId = iconId.substring(kUploadingIconPrefix.length);
const upload = this.runningUploads[uploadId];
if(!upload || upload.state !== "uploading") {
return undefined;
}
return upload.buffer;
});
this.variables.setVariableProvider("selectedIconId", () => this.selectedIconId);
this.variables.setVariableEditor("selectedIconId", newValue => this.setSelectedIcon(newValue, false));
this.variables.setVariableProvider("selectedTab", () => this.selectedTab);
this.variables.setVariableEditor("selectedTab", newValue => this.setSelectedTab(newValue, false));
this.events.on("action_refresh", () => this.updateRemoteIconList());
this.events.on("action_delete", event => {
if(!event.iconId.startsWith(kRemoteIconPrefix)) {
this.events.fire("notify_delete_error", { status: "not-found" });
return;
}
const iconId = parseInt(event.iconId.substring(7)) >>> 0;
this.connection.fileManager.deleteIcon(iconId).then(() => {
if(this.remoteIconList.status !== "loaded") {
return;
}
this.remoteIconList.icons.remove(event.iconId);
this.variables.sendVariable("remoteIconList");
}).catch(error => {
if(error instanceof CommandResult) {
if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
this.events.fire("notify_delete_error", { status: "no-permissions", failedPermission: this.connection.permissions.getFailedPermission(error) });
} else {
this.events.fire("notify_delete_error", { status: "error", message: error.formattedMessage() });
}
} else if(typeof error === "string") {
this.events.fire("notify_delete_error", { status: "error", message: error });
} else {
logError(LogCategory.NETWORKING, tr("Failed to delete icon {}: {}"), iconId, error)
this.events.fire("notify_delete_error", { status: "error", message: tr("lookup the console") });
}
});
});
this.events.on("action_initialize_upload", event => {
this.runningUploads[event.uploadId] = { state: "initializing" };
this.variables.sendVariable("remoteIconInfo", kUploadingIconPrefix + event.uploadId);
if(this.remoteIconList.status === "loaded") {
/* Register icon in the current list (use toggle to only add it once) */
this.remoteIconList.icons.toggle(kUploadingIconPrefix + event.uploadId, true);
}
this.variables.sendVariable("remoteIconList");
});
this.events.on("action_fail_upload", event => {
this.runningUploads[event.uploadId] = { state: "uploading", progress: { state: "failed", message: event.message }, iconId: 0, buffer: undefined };
this.variables.sendVariable("remoteIconInfo", kUploadingIconPrefix + event.uploadId);
});
this.events.on("action_clear_failed", () => {
let transferChanged = false;
for(const key of Object.keys(this.runningUploads)) {
const upload = this.runningUploads[key];
if(upload.state !== "uploading") {
continue;
}
if(upload.progress.state !== "failed") {
continue;
}
delete this.runningUploads[key];
if(this.remoteIconList.status === "loaded") {
this.remoteIconList.icons.remove(kUploadingIconPrefix + key);
transferChanged = true;
}
}
if(transferChanged) {
this.variables.sendVariable("remoteIconList");
}
});
this.events.on("action_upload", event => {
/* Remove all old uploads with the same icon id or remove this upload since it's a duplicate */
{
let transferChanged = false;
for(const key of Object.keys(this.runningUploads)) {
const upload = this.runningUploads[key];
if(upload.state !== "uploading") {
continue;
}
if(upload.iconId !== event.iconId) {
continue;
}
if(upload.progress.state === "failed") {
delete this.runningUploads[key];
if(this.remoteIconList.status === "loaded") {
this.remoteIconList.icons.remove(kUploadingIconPrefix + key);
transferChanged = true;
}
} else {
/* We already have a transfer for this icon */
delete this.runningUploads[event.uploadId];
if(this.remoteIconList.status === "loaded") {
this.remoteIconList.icons.remove(kUploadingIconPrefix + event.uploadId);
}
this.variables.sendVariable("remoteIconList");
return;
}
}
if(transferChanged) {
this.variables.sendVariable("remoteIconList");
}
}
const uploadId = event.uploadId;
const iconInfo = this.runningUploads[uploadId] = {
state: "uploading",
buffer: event.buffer,
iconId: event.iconId,
progress: { state: "pending" } as IconUploadProgress
};
this.variables.sendVariable("uploadingIconPayload", kUploadingIconPrefix + uploadId);
const transfer = this.connection.fileManager.initializeFileUpload({
path: "",
name: "/icon_" + iconInfo.iconId,
source: () => TransferProvider.provider().createBufferSource(iconInfo.buffer),
processCommandResult: false
});
const sendUpdateIconInfo = () => {
if(this.runningUploads[uploadId] !== iconInfo) {
return;
}
this.variables.sendVariable("remoteIconInfo", kUploadingIconPrefix + uploadId);
}
const transferListener = [];
transferListener.push(transfer.events.on("notify_progress", event => {
if(iconInfo.progress.state !== "transferring") {
/* We're not transferring so we don't need any updates */
return;
}
iconInfo.progress.process = event.progress.file_current_offset / event.progress.file_total_size;
sendUpdateIconInfo();
}));
transferListener.push(transfer.events.on("notify_state_updated", event => {
switch (event.newState) {
case FileTransferState.CANCELED:
iconInfo.progress = { state: "failed", message: tr("Transfer canceled") };
break;
case FileTransferState.ERRORED:
iconInfo.progress = { state: "failed", message: transfer.currentErrorMessage() || tr("Transfer error") };
break;
case FileTransferState.PENDING:
iconInfo.progress = { state: "pending" };
break;
case FileTransferState.RUNNING:
iconInfo.progress = { state: "transferring", process: 0 };
break;
case FileTransferState.FINISHED:
/* TODO: Place icon in local icon cache to avoid redownload */
iconInfo.progress = { state: "transferring", process: 1 };
delete this.runningUploads[uploadId];
if(this.remoteIconList.status === "loaded") {
const remoteIconName = kRemoteIconPrefix + iconInfo.iconId;
const uploadIndex = this.remoteIconList.icons.indexOf(kUploadingIconPrefix + uploadId);
const icons = this.remoteIconList.icons;
if(uploadIndex === -1) {
/* Just add the new icon if not already done so */
icons.toggle(remoteIconName, true);
} else if(icons.indexOf(remoteIconName) === -1) {
/* Replace the uploading icon with a remote icon */
icons.splice(uploadIndex, 1, remoteIconName);
} else {
/* Just delete the upload icon. Nothing to change */
icons.splice(uploadIndex, 1);
}
}
/* Clear up the array buffer cache */
this.variables.sendVariable("uploadingIconPayload", uploadId);
this.variables.sendVariable("remoteIconList");
break;
case FileTransferState.INITIALIZING:
case FileTransferState.CONNECTING:
iconInfo.progress = { state: "initializing" };
}
sendUpdateIconInfo();
if(transfer.isFinished()) {
transferListener.forEach(callback => callback());
}
}));
sendUpdateIconInfo();
});
this.connectionListener = [];
this.connectionListener.push(this.connection.permissions.register_needed_permission(PermissionType.B_ICON_MANAGE, () => this.updateRemoteIconList()));
this.events.fire("action_refresh");
}
setSelectedTab(tab: SelectedIconTab, updateVariable: boolean) {
if(this.selectedTab === tab) {
return;
}
this.selectedTab = tab;
if(updateVariable) {
this.variables.sendVariable("selectedTab");
}
}
setSelectedIcon(iconId: string, updateVariable: boolean) {
if(this.selectedIconId === iconId) {
return;
}
this.selectedIconId = iconId;
if(updateVariable) {
this.variables.sendVariable("selectedIconId");
}
}
@CallOnce
destroy() {
this.connectionListener?.forEach(callback => callback());
this.connectionListener = undefined;
this.events.destroy();
this.variables.destroy();
}
private async updateRemoteIconList() {
this.remoteIconList = { status: "loading" };
this.variables.sendVariable("remoteIconList");
try {
const iconIds = [];
for(const icon of await this.connection.fileManager.requestFileList("/icons", undefined, undefined, false)) {
if(!icon.name?.startsWith("icon_")) {
logWarn(LogCategory.NETWORKING, tr("Icon list returned invalid file %s."), icon.name);
continue;
}
const iconId = parseInt(icon.name.substring(5));
if(isNaN(iconId)) {
logWarn(LogCategory.NETWORKING, tr("Remote icon list contains invalid icon file: %s"), icon.name);
continue;
}
iconIds.push(iconId >>> 0);
}
this.remoteIconList = {
status: "loaded",
icons: [
...iconIds.map(iconId => kRemoteIconPrefix + iconId),
...Object.keys(this.runningUploads).map(uploadId => kUploadingIconPrefix + uploadId)
],
refreshTimestamp: Date.now() + 5 * 1000
};
} catch (error) {
if(error instanceof CommandResult) {
if(error.id === ErrorCode.DATABASE_EMPTY_RESULT) {
this.remoteIconList = { status: "loaded", icons: [], refreshTimestamp: Date.now() + 5 * 1000 };
} else if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
this.remoteIconList = { status: "no-permission", failedPermission: this.connection.permissions.getFailedPermission(error), refreshTimestamp: Date.now() + 5 * 1000 };
} else {
this.remoteIconList = { status: "error", message: error.formattedMessage(), refreshTimestamp: Date.now() + 5 * 1000 };
}
} else if(typeof error === "string") {
this.remoteIconList = { status: "error", message: error, refreshTimestamp: Date.now() + 5 * 1000 };
} else {
logError(LogCategory.NETWORKING, tr("Failed to query remote icon list: %o"), error);
this.remoteIconList = { status: "error", message: tr("lookup the console"), refreshTimestamp: Date.now() + 5 * 1000 };
}
} finally {
this.variables.sendVariable("remoteIconList");
}
}
}
export type IconSelectCallback = (iconId: number | 0) => void;
export function spawnIconManage(connection: ConnectionHandler, preselectedIconId: number | 0, callbackSelect: undefined | IconSelectCallback) {
const controller = new Controller(connection);
if(preselectedIconId === 0) {
/* No icon selected */
controller.setSelectedTab("remote", true);
} else if(preselectedIconId < 1000) {
controller.setSelectedTab("local", true);
controller.setSelectedIcon(preselectedIconId.toString(), true);
} else {
controller.setSelectedTab("remote", true);
controller.setSelectedIcon(kRemoteIconPrefix + preselectedIconId, true);
}
if(callbackSelect) {
controller.events.on("action_select", event => {
if(!event.targetIcon) {
callbackSelect(0);
} else if(event.targetIcon.startsWith(kUploadingIconPrefix)) {
const iconId = parseInt(event.targetIcon.substring(kUploadingIconPrefix.length)) >>> 0;
if(isNaN(iconId)) {
return;
}
callbackSelect(iconId);
} else if(event.targetIcon.startsWith(kRemoteIconPrefix)) {
const iconId = parseInt(event.targetIcon.substring(kRemoteIconPrefix.length)) >>> 0;
if(isNaN(iconId)) {
return;
}
callbackSelect(iconId);
} else {
const iconId = parseInt(event.targetIcon) >>> 0;
if(isNaN(iconId)) {
return;
}
callbackSelect(iconId);
}
modal.destroy();
});
}
const modal = spawnModal("modal-icon-viewer", [
connection.handlerId,
controller.events.generateIpcDescription(),
controller.variables.generateConsumerDescription(),
!!callbackSelect
], {
popoutable: true,
noOpener: true
});
modal.getEvents().on("destroy", () => controller.destroy());
ignorePromise(modal.show());
}