Reworked the icon upload and select modal and fixed some minor css issues
parent
f2ab9800d4
commit
7a351893ab
|
@ -1,4 +1,9 @@
|
|||
# Changelog:
|
||||
* **05.05.21**
|
||||
- Reworked the icon modal
|
||||
- Fixed some minor icon and avatar related issues
|
||||
- Improved icon modal performance
|
||||
|
||||
* **29.04.21**
|
||||
- Fixed a bug which caused chat messages to appear twice
|
||||
- Adding support for poping out channel conversations
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
@import "mixin";
|
||||
:global {
|
||||
/* Avatar/Icon loading animations */
|
||||
.icon_loading {
|
||||
.iconLoading, .icon_loading {
|
||||
border: 2px solid #f3f3f3; /* Light grey */
|
||||
border-top: 2px solid #3498db; /* Blue */
|
||||
border-radius: 50%;
|
||||
|
|
|
@ -1823,88 +1823,6 @@
|
|||
</div>
|
||||
</script>
|
||||
|
||||
<script class="jsrender-template" id="tmpl_icon_select" type="text/html">
|
||||
<div class="container-icons">
|
||||
<div class="left">
|
||||
<div class="header">{{tr "Remote" /}}</div>
|
||||
<div class="content">
|
||||
<div class="container-icons-list">
|
||||
<div class="container-icons-remote {{if enable_select || enable_delete}}icon-select{{/if}}"></div>
|
||||
<div class="container-loading">
|
||||
<a>{{tr "loading..." /}}</a>
|
||||
</div>
|
||||
<div class="container-no-permissions">
|
||||
<a>{{tr "You dont have permissions the view the icons" /}}</a>
|
||||
</div>
|
||||
<div class="container-error">
|
||||
<a class="error-message">{{ŧr "An error occurred" /}}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container-buttons">
|
||||
{{if enable_upload}}
|
||||
<button class="btn btn-success button-upload">{{tr "Upload" /}}</button>
|
||||
{{/if}}
|
||||
{{if enable_delete}}
|
||||
<button class="btn btn-danger button-delete">{{tr "Delete" /}}</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="header">{{tr "Local" /}}</div>
|
||||
<div class="content">
|
||||
<div class="container-icons-list">
|
||||
<div class="container-icons-local {{if enable_select}}icon-select{{/if}}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container-buttons">
|
||||
<button class="btn btn-primary btn-raised button-reload">{{tr "Reload" /}}</button>
|
||||
<div class="spacer"></div>
|
||||
{{if enable_select}}
|
||||
<button class="btn btn-success btn-raised button-select-no-icon">{{tr "Remove icon" /}}</button>
|
||||
<button class="btn btn-success btn-raised button-select"><a>{{tr "Select " /}}</a>
|
||||
<div class="selected-item-container"></div>
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script class="jsrender-template" id="tmpl_icon_upload" type="text/html">
|
||||
<div class="container-select">
|
||||
<div class="container-icons"></div>
|
||||
<div class="container-buttons">
|
||||
<div class="buttons-manage">
|
||||
<button class="btn btn-primary btn-raised button-add">{{tr "Add icon" /}}</button>
|
||||
<button class="btn btn-danger button-remove">{{tr "Remove selected" /}}</button>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-raised button-upload"></button>
|
||||
|
||||
<input type="file" class="input-file-upload" accept="image/*" multiple/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container-upload">
|
||||
<div class="container-error">
|
||||
<div class="error-message">You're not connected. Failed to upload icons</div>
|
||||
<button type="button" class="btn btn-danger btn-raised button-upload-abort">{{tr "abort" /}}
|
||||
</button>
|
||||
</div>
|
||||
<div class="container-process"></div>
|
||||
<div class="container-info">
|
||||
<div class="container-info-uploaded">{{tr "Uploaded icons (total | successfully | error): "
|
||||
/}}
|
||||
</div>
|
||||
<div class="uploaded-statistics"></div>
|
||||
</div>
|
||||
<div class="container-success">
|
||||
<div class="message">Uploaded 10 icons successfully</div>
|
||||
<button type="button" class="btn btn-success btn-raised button-upload-abort">{{tr "okey" /}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script class="jsrender-template" id="tmpl_avatar_list" type="text/html">
|
||||
<div class="modal-avatar-list">
|
||||
<div class="container-list">
|
||||
|
|
|
@ -373,6 +373,8 @@ export type InitializeUploadOptions = {
|
|||
channelPassword?: string;
|
||||
|
||||
source: TransferSourceSupplier;
|
||||
|
||||
processCommandResult?: boolean
|
||||
};
|
||||
|
||||
export type InitializeDownloadOptions = {
|
||||
|
@ -424,12 +426,14 @@ export class FileManager {
|
|||
clearInterval(this.transerUpdateIntervalId);
|
||||
}
|
||||
|
||||
requestFileList(path: string, channelId?: number, channelPassword?: string) : Promise<FileInfo[]> {
|
||||
requestFileList(path: string, channelId?: number, channelPassword?: string, processResult?: boolean) : Promise<FileInfo[]> {
|
||||
return this.commandHandler.registerFileList(path, channelId | 0, (resolve, reject) => {
|
||||
this.connectionHandler.serverConnection.send_command("ftgetfilelist", {
|
||||
path: path,
|
||||
cid: channelId || "0",
|
||||
cpw: channelPassword
|
||||
}, {
|
||||
process_result: typeof processResult !== "boolean" || processResult
|
||||
}).then(() => {
|
||||
reject(tr("Missing server file list response"));
|
||||
}).catch(error => {
|
||||
|
@ -598,7 +602,7 @@ export class FileManager {
|
|||
"overwrite": true,
|
||||
"resume": false,
|
||||
"proto": 1
|
||||
});
|
||||
}, { process_result: options.processCommandResult });
|
||||
|
||||
if(transfer.transferState() === FileTransferState.INITIALIZING)
|
||||
throw tr("missing transfer start notify");
|
||||
|
@ -718,15 +722,42 @@ export class FileManager {
|
|||
}
|
||||
|
||||
private updateRegisteredTransfers() {
|
||||
/* drop timeouted transfers */
|
||||
/* drop timed out transfers */
|
||||
{
|
||||
const timeout = Date.now() - 10 * 1000;
|
||||
const timeouted = this.registeredTransfers_.filter(e => e.transfer.lastStateUpdate < timeout).filter(e => e.transfer.isRunning());
|
||||
timeouted.forEach(e => {
|
||||
e.transfer.setFailed({
|
||||
const timedOutTransfers = this.registeredTransfers_.filter(entry => {
|
||||
if(entry.transfer.lastStateUpdate >= timeout) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (entry.transfer.transferState()) {
|
||||
case FileTransferState.PENDING:
|
||||
/* Transfer is locally pending because of some limits */
|
||||
return false;
|
||||
|
||||
case FileTransferState.CONNECTING:
|
||||
case FileTransferState.INITIALIZING:
|
||||
case FileTransferState.RUNNING:
|
||||
/* These states can time out */
|
||||
return true;
|
||||
|
||||
case FileTransferState.CANCELED:
|
||||
case FileTransferState.ERRORED:
|
||||
case FileTransferState.FINISHED:
|
||||
/* Transfer finished, we can't have a timeout */
|
||||
return false;
|
||||
|
||||
default:
|
||||
/* Should never happen but just in case time it out */
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
for(const entry of timedOutTransfers) {
|
||||
entry.transfer.setFailed({
|
||||
error: "timeout"
|
||||
}, tr("Timed out"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* check if we could start a new transfer */
|
||||
|
|
|
@ -27,7 +27,7 @@ export function imageType2MediaType(type: ImageType, file?: boolean) {
|
|||
}
|
||||
}
|
||||
|
||||
export function responseImageType(encoded_data: string | ArrayBuffer, base64_encoded?: boolean) {
|
||||
export function responseImageType(encoded_data: string | ArrayBuffer, base64_encoded?: boolean) : ImageType {
|
||||
const ab2str10 = () => {
|
||||
const buf = new Uint8Array(encoded_data as ArrayBuffer);
|
||||
if(buf.byteLength < 10)
|
||||
|
|
|
@ -427,7 +427,7 @@ class LocalAvatarManagerFactory extends AbstractAvatarManagerFactory {
|
|||
remoteAvatarId: avatarId,
|
||||
unregisterCallback: avatar.events.registerConsumer({
|
||||
handleEvent(mode: EventDispatchType, type: string, payload: any) {
|
||||
this.ipcChannel.sendMessage("avatar-event", { handlerId: handlerId, avatarId: avatarId, type, payload }, remoteId);
|
||||
this.ipcChannel?.sendMessage("avatar-event", { handlerId: handlerId, avatarId: avatarId, type, payload }, remoteId);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
|
|
@ -207,7 +207,7 @@ class IconManager extends AbstractIconManager {
|
|||
if(!handler.connected) {
|
||||
logWarn(LogCategory.FILE_TRANSFER, tr("Received handler id hint for icon download, but handler %s is not connected. Trying others."), handlerIdHint);
|
||||
handler = undefined;
|
||||
} else if(handler.channelTree.server.properties.virtualserver_unique_identifier !== icon.serverUniqueId) {
|
||||
} else if(icon.serverUniqueId && handler.channelTree.server.properties.virtualserver_unique_identifier !== icon.serverUniqueId) {
|
||||
logWarn(LogCategory.FILE_TRANSFER,
|
||||
tr("Received handler id hint for icon download, but handler %s is not connected to the expected server (%s <=> %s). Trying others."),
|
||||
handlerIdHint, handler.channelTree.server.properties.virtualserver_unique_identifier, icon.serverUniqueId);
|
||||
|
|
|
@ -2,6 +2,7 @@ import {Registry} from "../events";
|
|||
import {CommandResult} from "../connection/ServerConnectionDeclaration";
|
||||
import {tr} from "../i18n/localize";
|
||||
import {ErrorCode} from "../connection/ErrorCode";
|
||||
import {assertMainApplication} from "tc-shared/ui/utils";
|
||||
|
||||
/* Transfer source types */
|
||||
export enum TransferSourceType {
|
||||
|
@ -420,6 +421,7 @@ export abstract class TransferProvider {
|
|||
private static instance_;
|
||||
public static provider() : TransferProvider { return this.instance_; }
|
||||
public static setProvider(provider: TransferProvider) {
|
||||
assertMainApplication();
|
||||
this.instance_ = provider;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
export const downloadTextAsFile = (text: string, name: string) => {
|
||||
const payloadBlob = new Blob([ text ], { type: "text/plain" });
|
||||
const payloadUrl = URL.createObjectURL(payloadBlob);
|
||||
|
||||
export const downloadUrl = (payloadUrl: string, name: string, cleanupCallback?: () => void) => {
|
||||
const element = document.createElement("a");
|
||||
element.text = "download";
|
||||
element.setAttribute("href", payloadUrl);
|
||||
|
@ -13,11 +10,19 @@ export const downloadTextAsFile = (text: string, name: string) => {
|
|||
|
||||
setTimeout(() => {
|
||||
element.remove();
|
||||
URL.revokeObjectURL(payloadUrl);
|
||||
if(cleanupCallback) {
|
||||
cleanupCallback();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export const downloadTextAsFile = (text: string, name: string) => {
|
||||
const payloadBlob = new Blob([ text ], { type: "text/plain" });
|
||||
const payloadUrl = URL.createObjectURL(payloadBlob);
|
||||
downloadUrl(payloadUrl, name, () => URL.revokeObjectURL(payloadUrl));
|
||||
};
|
||||
|
||||
export const requestFile = async (options: {
|
||||
export const promptFile = async (options: {
|
||||
accept?: string,
|
||||
multiple?: boolean
|
||||
}): Promise<File[]> => {
|
||||
|
@ -49,7 +54,7 @@ export const requestFile = async (options: {
|
|||
}
|
||||
|
||||
export const requestFileAsText = async (): Promise<string> => {
|
||||
const files = await requestFile({ multiple: false });
|
||||
const files = await promptFile({ multiple: false });
|
||||
if(files.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@ import * as log from "../log";
|
|||
import {LogCategory, logError, logInfo, LogType} from "../log";
|
||||
import {Sound} from "../audio/Sounds";
|
||||
import {createServerModal} from "../ui/modal/ModalServerEdit";
|
||||
import {spawnIconSelect} from "../ui/modal/ModalIconSelect";
|
||||
import {spawnAvatarList} from "../ui/modal/ModalAvatarList";
|
||||
import {Registry} from "../events";
|
||||
import {ChannelTreeEntry, ChannelTreeEntryEvents} from "./ChannelTreeEntry";
|
||||
|
@ -21,6 +20,7 @@ import {
|
|||
ServerConnectionInfoResult,
|
||||
ServerProperties
|
||||
} from "tc-shared/tree/ServerDefinitions";
|
||||
import {spawnIconManage} from "tc-shared/ui/modal/icon-viewer/Controller";
|
||||
|
||||
/* TODO: Rework all imports */
|
||||
export * from "./ServerDefinitions";
|
||||
|
@ -180,7 +180,7 @@ export class ServerEntry extends ChannelTreeEntry<ServerEvents> {
|
|||
type: contextmenu.MenuEntryType.ENTRY,
|
||||
icon_class: "client-iconviewer",
|
||||
name: tr("View icons"),
|
||||
callback: () => spawnIconSelect(this.channelTree.client)
|
||||
callback: () => spawnIconManage(this.channelTree.client, 0, undefined)
|
||||
}, {
|
||||
type: contextmenu.MenuEntryType.ENTRY,
|
||||
icon_class: 'client-iconsview',
|
||||
|
|
|
@ -1,661 +0,0 @@
|
|||
import {ConnectionHandler} from "../../ConnectionHandler";
|
||||
import PermissionType from "../../permission/PermissionType";
|
||||
import {createErrorModal, createModal} from "../../ui/elements/Modal";
|
||||
import {LogCategory, logError, logInfo, logWarn} from "../../log";
|
||||
import {CommandResult} from "../../connection/ServerConnectionDeclaration";
|
||||
import {tra, trJQuery} from "../../i18n/localize";
|
||||
import {arrayBufferBase64} from "../../utils/buffers";
|
||||
import * as crc32 from "../../crypto/crc32";
|
||||
import {FileInfo} from "../../file/FileManager";
|
||||
import {FileTransferState, TransferProvider} from "../../file/Transfer";
|
||||
import {ErrorCode} from "../../connection/ErrorCode";
|
||||
import {generateIconJQueryTag, getIconManager} from "tc-shared/file/Icons";
|
||||
|
||||
export function spawnIconSelect(client: ConnectionHandler, callback_icon?: (id: number) => any, selected_icon?: number) {
|
||||
selected_icon = selected_icon || 0;
|
||||
let allow_manage = client.permissions.neededPermission(PermissionType.B_ICON_MANAGE).granted(1);
|
||||
|
||||
const modal = createModal({
|
||||
header: tr("Icons"),
|
||||
footer: undefined,
|
||||
body: () => {
|
||||
return $("#tmpl_icon_select").renderTag({
|
||||
enable_select: !!callback_icon,
|
||||
|
||||
enable_upload: allow_manage,
|
||||
enable_delete: allow_manage
|
||||
});
|
||||
},
|
||||
|
||||
min_width: "20em"
|
||||
});
|
||||
|
||||
modal.htmlTag.find(".modal-body").addClass("modal-icon-select");
|
||||
|
||||
const button_select = modal.htmlTag.find(".button-select");
|
||||
const button_delete = modal.htmlTag.find(".button-delete").prop("disabled", true);
|
||||
const button_upload = modal.htmlTag.find(".button-upload").prop("disabled", !allow_manage);
|
||||
|
||||
const container_loading = modal.htmlTag.find(".container-loading").hide();
|
||||
const container_no_permissions = modal.htmlTag.find(".container-no-permissions").hide();
|
||||
const container_error = modal.htmlTag.find(".container-error").hide();
|
||||
|
||||
const selected_container = modal.htmlTag.find(".selected-item-container");
|
||||
|
||||
const container_icons = modal.htmlTag.find(".container-icons");
|
||||
const container_icons_remote = container_icons.find(".container-icons-remote");
|
||||
const container_icons_local = container_icons.find(".container-icons-local");
|
||||
|
||||
const update_local_icons = (icons: number[]) => {
|
||||
container_icons_local.empty();
|
||||
|
||||
for (const iconId of icons) {
|
||||
const iconTag = generateIconJQueryTag(getIconManager().resolveIcon(iconId, client.channelTree.server.properties.virtualserver_unique_identifier), { animate: false });
|
||||
|
||||
const tag = iconTag.attr('title', "Icon " + iconId);
|
||||
if (callback_icon) {
|
||||
tag.on('click', event => {
|
||||
container_icons.find(".selected").removeClass("selected");
|
||||
tag.addClass("selected");
|
||||
|
||||
selected_container.empty().append(tag.clone());
|
||||
selected_icon = iconId;
|
||||
button_select.prop("disabled", false);
|
||||
});
|
||||
tag.on('dblclick', event => {
|
||||
callback_icon(iconId);
|
||||
modal.close();
|
||||
});
|
||||
if (iconId == selected_icon)
|
||||
tag.trigger('click');
|
||||
}
|
||||
tag.appendTo(container_icons_local);
|
||||
}
|
||||
};
|
||||
|
||||
const update_remote_icons = () => {
|
||||
container_no_permissions.hide();
|
||||
container_error.hide();
|
||||
container_loading.show();
|
||||
const display_remote_error = (error?: string) => {
|
||||
if (typeof (error) === "string") {
|
||||
container_error.find(".error-message").text(error);
|
||||
container_error.show();
|
||||
} else {
|
||||
container_error.hide();
|
||||
}
|
||||
};
|
||||
|
||||
client.fileManager.requestFileList("/icons").then(icons => {
|
||||
const container_icons_remote_parent = container_icons_remote.parent();
|
||||
container_icons_remote.detach().empty();
|
||||
|
||||
const chunk_size = 50;
|
||||
const icon_chunks: FileInfo[][] = [];
|
||||
let index = 0;
|
||||
while (icons.length > index) {
|
||||
icon_chunks.push(icons.slice(index, index + chunk_size));
|
||||
index += chunk_size;
|
||||
}
|
||||
|
||||
const process_next_chunk = () => {
|
||||
const chunk = icon_chunks.pop_front();
|
||||
if (!chunk) return;
|
||||
|
||||
for (const icon of chunk) {
|
||||
const iconId = parseInt((icon.name || "").substr("icon_".length));
|
||||
if (Number.isNaN(iconId)) {
|
||||
logWarn(LogCategory.GENERAL, tr("Received an unparsable icon within icon list (%o)"), icon);
|
||||
continue;
|
||||
}
|
||||
|
||||
const iconTag = generateIconJQueryTag(getIconManager().resolveIcon(iconId, client.channelTree.server.properties.virtualserver_unique_identifier), { animate: false });
|
||||
const tag = iconTag.attr('title', "Icon " + iconId);
|
||||
if (callback_icon || allow_manage) {
|
||||
tag.on('click', event => {
|
||||
container_icons.find(".selected").removeClass("selected");
|
||||
tag.addClass("selected");
|
||||
|
||||
selected_container.empty().append(tag.clone());
|
||||
selected_icon = iconId;
|
||||
button_select.prop("disabled", false);
|
||||
button_delete.prop("disabled", !allow_manage);
|
||||
});
|
||||
tag.on('dblclick', event => {
|
||||
if (!callback_icon)
|
||||
return;
|
||||
|
||||
callback_icon(iconId);
|
||||
modal.close();
|
||||
});
|
||||
if (iconId == selected_icon)
|
||||
tag.trigger('click');
|
||||
}
|
||||
tag.appendTo(container_icons_remote);
|
||||
}
|
||||
setTimeout(process_next_chunk, 100);
|
||||
};
|
||||
process_next_chunk();
|
||||
|
||||
container_icons_remote_parent.append(container_icons_remote);
|
||||
container_error.hide();
|
||||
container_loading.hide();
|
||||
container_no_permissions.hide();
|
||||
}).catch(error => {
|
||||
if (error instanceof CommandResult && error.id == ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
|
||||
container_no_permissions.show();
|
||||
} else {
|
||||
logError(LogCategory.GENERAL, tr("Failed to fetch icon list. Error: %o"), error);
|
||||
display_remote_error(tr("Failed to fetch icon list"));
|
||||
}
|
||||
container_loading.hide();
|
||||
});
|
||||
};
|
||||
|
||||
button_delete.on('click', event => {
|
||||
if (!selected_icon)
|
||||
return;
|
||||
|
||||
const selected = modal.htmlTag.find(".selected");
|
||||
if (selected.length != 1)
|
||||
logWarn(LogCategory.GENERAL, tr("UI selected icon length does not equal with 1! (%o)"), selected.length);
|
||||
|
||||
if (selected_icon < 1000) return; /* we cant delete local icons */
|
||||
|
||||
client.fileManager.deleteIcon(selected_icon).then(() => {
|
||||
selected.detach();
|
||||
}).catch(error => {
|
||||
if (error instanceof CommandResult && error.id == ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS)
|
||||
return;
|
||||
logWarn(LogCategory.GENERAL, tr("Failed to delete icon %d: %o"), selected_icon, error);
|
||||
|
||||
error = error instanceof CommandResult ? error.extra_message || error.message : error;
|
||||
|
||||
createErrorModal(tr("Failed to delete icon"), tra("Failed to delete icon.<br>Error: ", error)).open();
|
||||
});
|
||||
});
|
||||
|
||||
button_upload.on('click', event => spawnIconUpload(client));
|
||||
|
||||
update_local_icons([100, 200, 300, 500, 600]);
|
||||
update_remote_icons();
|
||||
modal.htmlTag.find('.button-reload').on('click', () => update_remote_icons());
|
||||
button_select.prop("disabled", true).on('click', () => {
|
||||
if (callback_icon) callback_icon(selected_icon);
|
||||
modal.close();
|
||||
});
|
||||
modal.htmlTag.find(".button-select-no-icon").on('click', () => {
|
||||
if (callback_icon) callback_icon(0);
|
||||
modal.close();
|
||||
});
|
||||
modal.open();
|
||||
}
|
||||
|
||||
interface UploadingIcon {
|
||||
file: File;
|
||||
state: "loading" | "valid" | "error";
|
||||
upload_state: "unset" | "uploading" | "uploaded" | "error";
|
||||
|
||||
html_tag?: JQuery;
|
||||
image_element?: () => HTMLImageElement;
|
||||
|
||||
loader: Promise<void>;
|
||||
|
||||
upload_icon: () => () => Promise<void>;
|
||||
upload_html_tag?: JQuery;
|
||||
|
||||
icon_id: string;
|
||||
}
|
||||
|
||||
function handle_icon_upload(file: File, client: ConnectionHandler): UploadingIcon {
|
||||
const icon = {} as UploadingIcon;
|
||||
icon.file = file;
|
||||
icon.upload_state = "unset";
|
||||
|
||||
const file_too_big = () => {
|
||||
logError(LogCategory.GENERAL, tr("Failed to load file %s: File is too big!"), file.name);
|
||||
createErrorModal(tr("Icon upload failed"), tra("Failed to upload icon {}.<br>The given file is too big!", file.name)).open();
|
||||
icon.state = "error";
|
||||
};
|
||||
if (file.size > 1024 * 1024 * 512) {
|
||||
file_too_big();
|
||||
} else if ((file.size | 0) <= 0) {
|
||||
logError(LogCategory.GENERAL, tr("Failed to load file %s: Your browser does not support file sizes!"), file.name);
|
||||
createErrorModal(tr("Icon upload failed"), tra("Failed to upload icon {}.<br>Your browser does not support file sizes!", file.name)).open();
|
||||
icon.state = "error";
|
||||
return;
|
||||
} else {
|
||||
icon.state = "loading";
|
||||
icon.loader = (async () => {
|
||||
const reader = new FileReader();
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
reader.onload = resolve;
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
} catch (error) {
|
||||
logError(LogCategory.CLIENT, tr("Failed to load file %s: Image failed to load: %o"), file.name, error);
|
||||
createErrorModal(tr("Icon upload failed"), tra("Failed to upload icon {}.<br>Failed to load image", file.name)).open();
|
||||
icon.state = "error";
|
||||
return;
|
||||
}
|
||||
|
||||
const result = reader.result as string;
|
||||
if (typeof (result) !== "string") {
|
||||
logError(LogCategory.GENERAL, tr("Failed to load file %s: Result is not an media string (%o)"), file.name, result);
|
||||
createErrorModal(tr("Icon upload failed"), tra("Failed to upload icon {}.<br>Result is not an media string", file.name)).open();
|
||||
icon.state = "error";
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
/* get the CRC32 sum */
|
||||
{
|
||||
if (!result.startsWith("data:image/")) {
|
||||
logError(LogCategory.GENERAL, tr("Failed to load file %s: Invalid data media type (%o)"), file.name, result);
|
||||
createErrorModal(tr("Icon upload failed"), tra("Failed to upload icon {}.<br>File is not an image", file.name)).open();
|
||||
icon.state = "error";
|
||||
return;
|
||||
}
|
||||
const semi = result.indexOf(';');
|
||||
const type = result.substring(11, semi);
|
||||
logInfo(LogCategory.GENERAL, tr("Given image has type %s"), type);
|
||||
if (!result.substr(semi + 1).startsWith("base64,")) {
|
||||
logError(LogCategory.GENERAL, tr("Failed to load file %s: Mimetype isn't base64 encoded (%o)"), file.name, result.substr(semi + 1));
|
||||
createErrorModal(tr("Icon upload failed"), tra("Failed to upload icon {}.<br>Decoder returned unknown result", file.name)).open();
|
||||
icon.state = "error";
|
||||
return;
|
||||
}
|
||||
|
||||
const crc = new crc32.Crc32();
|
||||
crc.update(arrayBufferBase64(result.substr(semi + 8)));
|
||||
icon.icon_id = crc.digest(10);
|
||||
}
|
||||
|
||||
|
||||
const image = document.createElement("img");
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
image.onload = resolve;
|
||||
image.onerror = reject;
|
||||
image.src = result;
|
||||
});
|
||||
} catch (error) {
|
||||
logInfo(LogCategory.GENERAL, "Image failed to load (%o)", error);
|
||||
logError(LogCategory.GENERAL, tr("Failed to load file %s: Image failed to load"), file.name);
|
||||
createErrorModal(tr("Icon upload failed"), tra("Failed to upload icon {}.\nFailed to load image", file.name)).open();
|
||||
icon.state = "error";
|
||||
}
|
||||
|
||||
const width_error = message => {
|
||||
logError(LogCategory.GENERAL, tr("Failed to load file %s: Invalid bounds: %s"), file.name, message);
|
||||
createErrorModal(tr("Icon upload failed"), tra("Failed to upload icon {}.\nImage is too large ({})", file.name, message)).open();
|
||||
icon.state = "error";
|
||||
};
|
||||
|
||||
if (!result.startsWith("data:image/svg+xml")) {
|
||||
if (image.naturalWidth > 128 && image.naturalHeight > 128) {
|
||||
width_error("width and height (max 32px). Given: " + image.naturalWidth + "x" + image.naturalHeight);
|
||||
return;
|
||||
}
|
||||
if (image.naturalWidth > 128) {
|
||||
width_error("width (max 32px)");
|
||||
return;
|
||||
}
|
||||
if (image.naturalHeight > 128) {
|
||||
width_error("height (max 32px)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
logInfo(LogCategory.GENERAL, "Image loaded (%dx%d) %s (%s)", image.naturalWidth, image.naturalHeight, image.name, icon.icon_id);
|
||||
icon.image_element = () => {
|
||||
const image = document.createElement("img");
|
||||
image.src = result;
|
||||
return image;
|
||||
};
|
||||
icon.state = "valid";
|
||||
})();
|
||||
|
||||
icon.upload_icon = () => {
|
||||
const create_progress_bar = () => {
|
||||
const html = $.spawn("div").addClass("progress");
|
||||
const indicator = $.spawn("div").addClass("progress-bar bg-success progress-bar-striped progress-bar-animated");
|
||||
const message = $.spawn("div").addClass("progress-message");
|
||||
const set_value = value => {
|
||||
indicator.stop(true, false).animate({width: value + "%"}, 250);
|
||||
if (value === 100)
|
||||
setTimeout(() => indicator.removeClass("progress-bar-striped progress-bar-animated"), 900)
|
||||
};
|
||||
|
||||
return {
|
||||
html_tag: html.append(indicator).append(message),
|
||||
set_value: set_value,
|
||||
set_message: msg => message.text(msg),
|
||||
set_error: (msg: string) => {
|
||||
let index = msg.lastIndexOf(':');
|
||||
message.text(index == -1 ? msg : msg.substring(index + 1));
|
||||
message.attr('title', msg);
|
||||
set_value(100);
|
||||
indicator.removeClass("bg-success").addClass("bg-danger");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const container_image = $.spawn("div").addClass("container-icon");
|
||||
const bar = create_progress_bar();
|
||||
|
||||
const set_error = message => {
|
||||
bar.set_value(100);
|
||||
bar.set_message(tr("error: ") + message);
|
||||
};
|
||||
|
||||
const html_tag = $.spawn("div")
|
||||
.addClass("upload-entry")
|
||||
.append(container_image)
|
||||
.append(bar.html_tag);
|
||||
|
||||
icon.upload_html_tag = html_tag;
|
||||
|
||||
let icon_added = false;
|
||||
if (icon.image_element) {
|
||||
container_image.append(icon.image_element());
|
||||
icon_added = true;
|
||||
}
|
||||
|
||||
|
||||
bar.set_value(0);
|
||||
bar.set_value(tr("waiting"));
|
||||
|
||||
return async () => {
|
||||
const time_begin = Date.now();
|
||||
|
||||
if (icon.state === "loading") {
|
||||
bar.set_message(tr("Awaiting local processing"));
|
||||
await icon.loader;
|
||||
// @ts-ignore Could happen because the loader function updates the state
|
||||
if (icon.state !== "valid") {
|
||||
set_error(tr("local processing failed"));
|
||||
icon.upload_state = "error";
|
||||
return;
|
||||
}
|
||||
} else if (icon.state === "error") {
|
||||
set_error(tr("local processing error"));
|
||||
icon.upload_state = "error";
|
||||
return;
|
||||
}
|
||||
if (!icon_added)
|
||||
container_image.append(icon.image_element());
|
||||
|
||||
bar.set_value(25);
|
||||
bar.set_message(tr("initializing"));
|
||||
|
||||
const transfer = client.fileManager.initializeFileUpload({
|
||||
channel: 0,
|
||||
channelPassword: undefined,
|
||||
|
||||
path: "",
|
||||
name: "/icon_" + icon.icon_id,
|
||||
|
||||
source: async () => await TransferProvider.provider().createBrowserFileSource(icon.file)
|
||||
});
|
||||
|
||||
transfer.events.on("notify_state_updated", event => {
|
||||
switch (event.newState) {
|
||||
case FileTransferState.PENDING:
|
||||
bar.set_value(10);
|
||||
bar.set_message(tr("pending"));
|
||||
break;
|
||||
case FileTransferState.INITIALIZING:
|
||||
case FileTransferState.CONNECTING:
|
||||
bar.set_value(30);
|
||||
bar.set_message(tr("connecting"));
|
||||
break;
|
||||
case FileTransferState.RUNNING:
|
||||
bar.set_value(50);
|
||||
bar.set_message(tr("uploading"));
|
||||
break;
|
||||
|
||||
case FileTransferState.FINISHED:
|
||||
bar.set_value(100);
|
||||
bar.set_message(tr("upload completed"));
|
||||
icon.upload_state = "uploaded";
|
||||
break;
|
||||
|
||||
case FileTransferState.ERRORED:
|
||||
logWarn(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();
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
return icon;
|
||||
}
|
||||
|
||||
export function spawnIconUpload(client: ConnectionHandler) {
|
||||
const modal = createModal({
|
||||
header: tr("Upload Icons"),
|
||||
footer: undefined,
|
||||
body: () => $("#tmpl_icon_upload").renderTag(),
|
||||
closeable: false,
|
||||
|
||||
min_width: "20em"
|
||||
});
|
||||
modal.htmlTag.find(".modal-body").addClass("modal-icon-upload");
|
||||
|
||||
const button_upload = modal.htmlTag.find(".button-upload");
|
||||
const button_delete = modal.htmlTag.find(".button-remove").prop("disabled", true);
|
||||
const button_add = modal.htmlTag.find(".button-add");
|
||||
const button_upload_abort = modal.htmlTag.find(".button-upload-abort");
|
||||
const input_file = modal.htmlTag.find(".input-file-upload") as JQuery<HTMLInputElement>;
|
||||
const container_icons = modal.htmlTag.find(".container-icons");
|
||||
|
||||
let selected_icon: UploadingIcon;
|
||||
let icons: UploadingIcon[] = [];
|
||||
|
||||
const update_upload_button = () => {
|
||||
const icon_count = icons.filter(e => e.state === "valid").length;
|
||||
button_upload.empty();
|
||||
trJQuery("Upload icons ({})", icon_count).forEach(e => e.appendTo(button_upload));
|
||||
button_upload.prop("disabled", icon_count == 0);
|
||||
};
|
||||
update_upload_button();
|
||||
|
||||
const add_icon = (icon: UploadingIcon) => {
|
||||
icons.push(icon);
|
||||
icon.loader.then(e => {
|
||||
if (icon.state === "valid") {
|
||||
const image = icon.image_element();
|
||||
const element = $.spawn("div")
|
||||
.addClass("icon-container")
|
||||
.append(image);
|
||||
container_icons.append(icon.html_tag = element);
|
||||
|
||||
element.on('click', event => {
|
||||
container_icons.find(".selected").removeClass("selected");
|
||||
element.addClass("selected");
|
||||
|
||||
selected_icon = icon;
|
||||
button_delete.prop("disabled", false);
|
||||
});
|
||||
|
||||
update_upload_button();
|
||||
}
|
||||
});
|
||||
};
|
||||
button_delete.on('click', event => {
|
||||
if (!selected_icon)
|
||||
return;
|
||||
icons = icons.filter(e => e !== selected_icon);
|
||||
if (selected_icon.html_tag)
|
||||
selected_icon.html_tag.detach();
|
||||
button_delete.prop("disabled", true);
|
||||
update_upload_button();
|
||||
});
|
||||
|
||||
button_add.on('click', event => input_file.click());
|
||||
input_file.on('change', event => {
|
||||
if (input_file[0].files.length > 0) {
|
||||
for (let index = 0; index < input_file[0].files.length; index++) {
|
||||
const file = input_file[0].files.item(index);
|
||||
{
|
||||
let duplicate = false;
|
||||
|
||||
for (const icon of icons)
|
||||
if (icon.file.name === file.name && icon.file.lastModified === file.lastModified && icon.state !== "error") {
|
||||
duplicate = true;
|
||||
break;
|
||||
}
|
||||
if (duplicate)
|
||||
continue;
|
||||
}
|
||||
|
||||
add_icon(handle_icon_upload(file, client));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
container_icons.on('dragover', ((event: DragEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}) as any);
|
||||
container_icons.on('drop', ((event: DragEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
for (let index = 0; index < event.dataTransfer.files.length; index++) {
|
||||
const file = event.dataTransfer.files.item(index);
|
||||
{
|
||||
let duplicate = false;
|
||||
|
||||
for (const icon of icons)
|
||||
if (icon.file === file && icon.state !== "error") {
|
||||
duplicate = true;
|
||||
break;
|
||||
}
|
||||
if (duplicate)
|
||||
continue;
|
||||
}
|
||||
|
||||
add_icon(handle_icon_upload(file, client));
|
||||
}
|
||||
}) as any);
|
||||
|
||||
/* upload process */
|
||||
{
|
||||
const container_upload = modal.htmlTag.find(".container-upload");
|
||||
const container_error = container_upload.find(".container-error");
|
||||
const container_success = container_upload.find(".container-success");
|
||||
const container_process = container_upload.find(".container-process");
|
||||
const container_info = container_upload.find(".container-info");
|
||||
const container_statistics = container_upload.find(".uploaded-statistics");
|
||||
|
||||
const show_critical_error = message => {
|
||||
container_error.find(".error-message").text(message);
|
||||
container_error.removeClass("hidden");
|
||||
};
|
||||
|
||||
const finish_upload = () => {
|
||||
icons = icons.filter(e => {
|
||||
if (e.upload_state === "uploaded") {
|
||||
e.html_tag.detach();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
update_upload_button();
|
||||
button_upload.prop("disabled", false);
|
||||
button_upload.prop("disabled", false);
|
||||
container_upload.hide();
|
||||
container_error.addClass("hidden");
|
||||
container_error.addClass("hidden");
|
||||
modal.set_closeable(true);
|
||||
};
|
||||
|
||||
|
||||
const execute_upload = async () => {
|
||||
if (!client || !client.fileManager) {
|
||||
show_critical_error(tr("Invalid client handle"));
|
||||
return;
|
||||
}
|
||||
if (!client.connected) {
|
||||
show_critical_error(tr("Not connected"));
|
||||
return;
|
||||
}
|
||||
|
||||
let invoke_count = 0;
|
||||
let succeed_count = 0;
|
||||
let failed_count = 0;
|
||||
|
||||
const uploads = icons.filter(e => e.state !== "error");
|
||||
|
||||
const executes: { icon: UploadingIcon, task: () => Promise<void> }[] = [];
|
||||
for (const icon of uploads) {
|
||||
executes.push({
|
||||
icon: icon,
|
||||
task: icon.upload_icon()
|
||||
});
|
||||
|
||||
if (!icon.upload_html_tag)
|
||||
continue; /* TODO: error? */
|
||||
icon.upload_html_tag.appendTo(container_process);
|
||||
}
|
||||
|
||||
const update_state = () => container_statistics.text(invoke_count + " | " + succeed_count + " | " + failed_count);
|
||||
for (const execute of executes) {
|
||||
invoke_count++;
|
||||
update_state();
|
||||
try {
|
||||
await execute.task();
|
||||
if (execute.icon.upload_state !== "uploaded")
|
||||
throw "failed";
|
||||
succeed_count++;
|
||||
} catch (error) {
|
||||
failed_count++;
|
||||
}
|
||||
update_state();
|
||||
}
|
||||
container_info.css({opacity: 1}).animate({opacity: 0}, 250, () => container_info.css({opacity: undefined}).hide());
|
||||
container_success.find(".message").html(
|
||||
"Total icons: " + invoke_count + "<br>" +
|
||||
"Succeeded icons: " + succeed_count + "<br>" +
|
||||
"Failed icons: " + failed_count
|
||||
);
|
||||
|
||||
container_success.removeClass("hidden");
|
||||
};
|
||||
|
||||
button_upload.on('click', event => {
|
||||
modal.set_closeable(false);
|
||||
button_upload.prop("disabled", true);
|
||||
button_delete.prop("disabled", true);
|
||||
button_add.prop("disabled", true);
|
||||
container_process.empty();
|
||||
container_upload.show();
|
||||
execute_upload();
|
||||
});
|
||||
|
||||
button_upload_abort.on('click', event => finish_upload());
|
||||
|
||||
container_error.addClass("hidden");
|
||||
container_success.addClass("hidden");
|
||||
container_upload.hide();
|
||||
}
|
||||
|
||||
modal.open();
|
||||
modal.set_closeable(true);
|
||||
}
|
|
@ -4,11 +4,11 @@ import PermissionType from "../../permission/PermissionType";
|
|||
import {GroupManager} from "../../permission/GroupManager";
|
||||
import {hashPassword} from "../../utils/helpers";
|
||||
import * as tooltip from "../../ui/elements/Tooltip";
|
||||
import {spawnIconSelect} from "../../ui/modal/ModalIconSelect";
|
||||
import {network} from "../../ui/frames/chat";
|
||||
import {generateIconJQueryTag, getIconManager} from "tc-shared/file/Icons";
|
||||
import {tr} from "tc-shared/i18n/localize";
|
||||
import {LogCategory, logTrace} from "tc-shared/log";
|
||||
import {spawnIconManage} from "tc-shared/ui/modal/icon-viewer/Controller";
|
||||
|
||||
export function createServerModal(server: ServerEntry, callback: (properties?: ServerProperties) => Promise<void>) {
|
||||
const properties = Object.assign({}, server.properties);
|
||||
|
@ -120,15 +120,15 @@ function apply_general_listener(tag: JQuery, server: ServerEntry, properties: Se
|
|||
/* icon */
|
||||
{
|
||||
tag.find(".button-select-icon").on('click', event => {
|
||||
spawnIconSelect(server.channelTree.client, id => {
|
||||
spawnIconManage(server.channelTree.client, properties.virtualserver_icon_id, newIconId => {
|
||||
const icon_node = tag.find(".icon-preview");
|
||||
icon_node.children().remove();
|
||||
icon_node.append(generateIconJQueryTag(getIconManager().resolveIcon(id, server.properties.virtualserver_unique_identifier, server.channelTree.client.handlerId)));
|
||||
icon_node.append(generateIconJQueryTag(getIconManager().resolveIcon(newIconId, server.properties.virtualserver_unique_identifier, server.channelTree.client.handlerId)));
|
||||
|
||||
logTrace(LogCategory.GENERAL, "Selected icon ID: %d", id);
|
||||
properties.virtualserver_icon_id = id;
|
||||
logTrace(LogCategory.GENERAL, "Selected icon ID: %d", newIconId);
|
||||
properties.virtualserver_icon_id = newIconId;
|
||||
callback_valid(undefined); //Toggle save button update
|
||||
}, properties.virtualserver_icon_id);
|
||||
});
|
||||
});
|
||||
|
||||
tag.find(".button-icon-remove").on('click', event => {
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
import {UiVariableConsumer} from "tc-shared/ui/utils/Variable";
|
||||
import {createIpcUiVariableConsumer, IpcVariableDescriptor} from "tc-shared/ui/utils/IpcVariable";
|
||||
import {Button} from "tc-shared/ui/react-elements/Button";
|
||||
import {requestFile} from "tc-shared/file/Utils";
|
||||
import {promptFile} from "tc-shared/file/Utils";
|
||||
import {network} from "tc-shared/ui/frames/chat";
|
||||
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
|
||||
import {getOwnAvatarStorage} from "tc-shared/file/OwnAvatarStorage";
|
||||
|
@ -277,7 +277,7 @@ class ModalAvatarUpload extends AbstractModal {
|
|||
|
||||
this.events.on("action_open_select", async () => {
|
||||
this.events.fire("action_file_cache_loading");
|
||||
const files = await requestFile({
|
||||
const files = await promptFile({
|
||||
multiple: false,
|
||||
accept: ".svg, .png, .jpg, .jpeg, gif"
|
||||
});
|
||||
|
|
|
@ -17,7 +17,7 @@ import PermissionType from "tc-shared/permission/PermissionType";
|
|||
import {ChannelPropertyValidators} from "tc-shared/ui/modal/channel-edit/ControllerValidation";
|
||||
import {hashPassword} from "tc-shared/utils/helpers";
|
||||
import {ErrorCode} from "tc-shared/connection/ErrorCode";
|
||||
import {spawnIconSelect} from "tc-shared/ui/modal/ModalIconSelect";
|
||||
import {spawnIconManage} from "tc-shared/ui/modal/icon-viewer/Controller";
|
||||
|
||||
export type ChannelEditCallback = (properties: Partial<ChannelProperties>, permissions: ChannelEditChangedPermission[]) => void;
|
||||
export type ChannelEditChangedPermission = { permission: PermissionType, value: number };
|
||||
|
@ -132,9 +132,9 @@ class ChannelEditController {
|
|||
this.notifyPermission(event.permission);
|
||||
});
|
||||
this.uiEvents.on("action_icon_select", () => {
|
||||
spawnIconSelect(this.connection, id => {
|
||||
this.uiEvents.fire("action_change_property", { property: "icon", value: { iconId: id } });
|
||||
}, this.currentProperties.channel_icon_id);
|
||||
spawnIconManage(this.connection, this.currentProperties.channel_icon_id, newIconId => {
|
||||
this.uiEvents.fire("action_change_property", { property: "icon", value: { iconId: newIconId } });
|
||||
});
|
||||
});
|
||||
|
||||
this.listenerPermissions = [];
|
||||
|
|
|
@ -0,0 +1,437 @@
|
|||
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());
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
export type IconUploadProgress = {
|
||||
state: "pre-process" | "pending" | "initializing",
|
||||
} | {
|
||||
state: "transferring",
|
||||
process: number
|
||||
} | {
|
||||
state: "failed",
|
||||
message: string
|
||||
};
|
||||
|
||||
export type RemoteIconStatus = {
|
||||
status: "uploading",
|
||||
process: IconUploadProgress,
|
||||
} | {
|
||||
status: "live",
|
||||
iconId: number
|
||||
} | {
|
||||
status: "unknown"
|
||||
};
|
||||
|
||||
export type RemoteIconList = {
|
||||
status: "loading"
|
||||
} | {
|
||||
status: "no-permission",
|
||||
failedPermission: string,
|
||||
refreshTimestamp: number,
|
||||
} | {
|
||||
status: "loaded",
|
||||
icons: string[],
|
||||
refreshTimestamp: number,
|
||||
} | {
|
||||
status: "error",
|
||||
message: string,
|
||||
refreshTimestamp: number,
|
||||
};
|
||||
|
||||
export type SelectedIconTab = "remote" | "local";
|
||||
|
||||
export type IconDeleteError = {
|
||||
status: "no-permissions",
|
||||
failedPermission: string
|
||||
} | {
|
||||
status: "error",
|
||||
message: string
|
||||
} | {
|
||||
status: "not-found"
|
||||
};
|
||||
|
||||
export interface ModalIconViewerVariables {
|
||||
readonly uploadingIconPayload: ArrayBuffer | undefined;
|
||||
readonly remoteIconList: RemoteIconList;
|
||||
readonly remoteIconInfo: RemoteIconStatus;
|
||||
selectedIconId: string;
|
||||
selectedTab: SelectedIconTab;
|
||||
}
|
||||
|
||||
export interface ModalIconViewerEvents {
|
||||
/* Register a new icon for upload */
|
||||
action_initialize_upload: {
|
||||
uploadId: string
|
||||
},
|
||||
/* Register an upload fail */
|
||||
action_fail_upload: {
|
||||
uploadId: string,
|
||||
message: string
|
||||
},
|
||||
/* Actually upload the icon */
|
||||
action_upload: {
|
||||
uploadId: string,
|
||||
iconId: number,
|
||||
buffer: ArrayBuffer
|
||||
},
|
||||
|
||||
action_clear_failed: {},
|
||||
action_refresh: {},
|
||||
action_delete: {
|
||||
iconId: string
|
||||
},
|
||||
action_select: {
|
||||
targetIcon: string | undefined
|
||||
}
|
||||
|
||||
notify_delete_error: IconDeleteError,
|
||||
}
|
|
@ -0,0 +1,236 @@
|
|||
@import "../../../../css/static/mixin";
|
||||
@import "../../../../css/static/properties";
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
|
||||
user-select: none;
|
||||
|
||||
width: 40em;
|
||||
height: 40em;
|
||||
|
||||
min-width: 20em;
|
||||
min-height: 20em;
|
||||
|
||||
&.windowed {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.selectButtons {
|
||||
flex-shrink: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
|
||||
/* Overlap the tak to prevent the border from being shown */
|
||||
margin-top: -.5em;
|
||||
|
||||
padding: .5em;
|
||||
background-color: #17171a;
|
||||
|
||||
button:not(:last-of-type) {
|
||||
margin-right: .5em;
|
||||
}
|
||||
}
|
||||
|
||||
.tab {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
flex-shrink: 1;
|
||||
min-height: 8em;
|
||||
|
||||
.tabBody {
|
||||
min-height: 5em!important;
|
||||
}
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
|
||||
.body {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
|
||||
padding: .5em;
|
||||
box-shadow: inset 0 0 .3em rgba(0, 0, 0, .5);
|
||||
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
|
||||
overflow-y: scroll;
|
||||
|
||||
@include chat-scrollbar();
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
background: #17171a;
|
||||
padding: 1em;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.text {
|
||||
text-align: center;
|
||||
font-size: 1.5em;
|
||||
color: #666666;
|
||||
|
||||
&.error {
|
||||
color: #7d3636;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
|
||||
display: flex;
|
||||
/* TODO */
|
||||
|
||||
height: 3.2em;
|
||||
padding: .5em;
|
||||
|
||||
.button {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.buttonUpload {}
|
||||
.buttonDelete {
|
||||
margin-left: .5em;
|
||||
}
|
||||
.buttonRefresh {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&.buttons {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
color: rgb(119, 119, 119);
|
||||
line-height: 1.2em;
|
||||
font-size: .9em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
|
||||
padding: .5em;
|
||||
cursor: pointer;
|
||||
|
||||
position: relative;
|
||||
|
||||
border-radius: .2em;
|
||||
|
||||
transition: $button_hover_animation_time ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background: #ffffff17;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: #050505;
|
||||
}
|
||||
|
||||
.circle {
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
padding: .125em;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.error {
|
||||
circle {
|
||||
stroke: #a82424;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
circle {
|
||||
stroke: #389738;
|
||||
transition: stroke-dashoffset 0.35s;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
align-self: center;
|
||||
|
||||
&.uploading {
|
||||
font-size: .8em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-width: 30em) {
|
||||
.footer {
|
||||
height: 5.25em!important;
|
||||
|
||||
.text {
|
||||
overflow: hidden;
|
||||
|
||||
br {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,679 @@
|
|||
import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions";
|
||||
import React, {useContext, useEffect, useMemo, useRef, useState} from "react";
|
||||
import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n";
|
||||
import {IpcRegistryDescription, Registry} from "tc-events";
|
||||
import {
|
||||
IconUploadProgress,
|
||||
ModalIconViewerEvents,
|
||||
ModalIconViewerVariables,
|
||||
RemoteIconList
|
||||
} from "tc-shared/ui/modal/icon-viewer/Definitions";
|
||||
import {UiVariableConsumer} from "tc-shared/ui/utils/Variable";
|
||||
import {createIpcUiVariableConsumer, IpcVariableDescriptor} from "tc-shared/ui/utils/IpcVariable";
|
||||
import {Tab, TabEntry} from "tc-shared/ui/react-elements/Tab";
|
||||
import {joinClassList} from "tc-shared/ui/react-elements/Helper";
|
||||
import {Button} from "tc-shared/ui/react-elements/Button";
|
||||
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
|
||||
import {getIconManager} from "tc-shared/file/Icons";
|
||||
import {IconEmpty, IconError, IconLoading, IconUrl} from "tc-shared/ui/react-elements/Icon";
|
||||
import {tra} from "tc-shared/i18n/localize";
|
||||
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
|
||||
import {ClientIcon} from "svg-sprites/client-icons";
|
||||
import {spawnContextMenu} from "tc-shared/ui/ContextMenu";
|
||||
import {guid} from "tc-shared/crypto/uid";
|
||||
import {LogCategory, logError} from "tc-shared/log";
|
||||
import {ImageType, imageType2MediaType, responseImageType} from "tc-shared/file/ImageCache";
|
||||
import {Crc32} from "tc-shared/crypto/crc32";
|
||||
import {downloadUrl, promptFile} from "tc-shared/file/Utils";
|
||||
import {createErrorModal} from "tc-shared/ui/elements/Modal";
|
||||
import {Tooltip} from "tc-shared/ui/react-elements/Tooltip";
|
||||
import {ignorePromise} from "tc-shared/proto";
|
||||
|
||||
const cssStyle = require("./Renderer.scss");
|
||||
|
||||
const HandlerIdContext = React.createContext<string>(undefined);
|
||||
const EventContext = React.createContext<Registry<ModalIconViewerEvents>>(undefined);
|
||||
const VariablesContext = React.createContext<UiVariableConsumer<ModalIconViewerVariables>>(undefined);
|
||||
|
||||
const kLocalIconIds = [
|
||||
"100",
|
||||
"200",
|
||||
"300",
|
||||
"500",
|
||||
"600"
|
||||
];
|
||||
|
||||
const LocalIcon = React.memo((props: { iconId: string, selected: boolean }) => {
|
||||
const variables = useContext(VariablesContext);
|
||||
const events = useContext(EventContext);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={joinClassList(cssStyle.iconContainer, props.selected && cssStyle.selected)}
|
||||
title={tra("Local icon {}", props.iconId)}
|
||||
onClick={() => variables.setVariable("selectedIconId", undefined, props.iconId)}
|
||||
onDoubleClick={() => events.fire("action_select", { targetIcon: props.iconId })}
|
||||
>
|
||||
<div className={joinClassList(cssStyle.circle, cssStyle.hidden)}>
|
||||
<ProgressRing progress={74} stroke={25} />
|
||||
</div>
|
||||
<ClientIconRenderer icon={ClientIcon["Group_" + props.iconId] || ClientIcon.About} className={cssStyle.icon} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const LocalIconTab = React.memo(() => {
|
||||
const variables = useContext(VariablesContext);
|
||||
const selectedIconId = variables.useReadOnly("selectedIconId", undefined, undefined);
|
||||
|
||||
return (
|
||||
<div className={cssStyle.tabContent}>
|
||||
<div className={cssStyle.body}>
|
||||
{kLocalIconIds.map(iconId => (
|
||||
<LocalIcon iconId={iconId} selected={selectedIconId === iconId} key={iconId} />
|
||||
))}
|
||||
</div>
|
||||
<div className={cssStyle.footer}>
|
||||
<div className={cssStyle.text}>
|
||||
<Translatable>
|
||||
Local icons are icons which are defined by your icon pack. <br />
|
||||
Everybody has the same set of local icons which only differ in their appearance.
|
||||
</Translatable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const ProgressRing = React.memo((props: { stroke, progress }) => {
|
||||
const radius = 100;
|
||||
const { stroke, progress } = props;
|
||||
|
||||
const normalizedRadius = radius - stroke / 2;
|
||||
const circumference = normalizedRadius * 2 * Math.PI;
|
||||
const strokeDashoffset = circumference - progress / 100 * circumference;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={"0 0 " + (radius * 2) + " " + (radius * 2)}
|
||||
>
|
||||
<circle
|
||||
stroke="white"
|
||||
fill="transparent"
|
||||
strokeWidth={ stroke }
|
||||
strokeDasharray={ circumference + ' ' + circumference }
|
||||
style={ { strokeDashoffset } }
|
||||
r={ normalizedRadius }
|
||||
cx={ radius }
|
||||
cy={ radius }
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
|
||||
const RemoteIconLiveRenderer = React.memo((props: { iconId: string, remoteIconId: number, selected: boolean }) => {
|
||||
const variables = useContext(VariablesContext);
|
||||
const handlerId = useContext(HandlerIdContext);
|
||||
const events = useContext(EventContext);
|
||||
const icon = useMemo(() => getIconManager().resolveIcon(props.remoteIconId, undefined, handlerId), [ props.remoteIconId ]);
|
||||
|
||||
const [ , setRevision ] = useState(0);
|
||||
icon.events.reactUse("notify_state_changed", () => {
|
||||
setRevision(Date.now());
|
||||
});
|
||||
|
||||
let iconBody, iconUrl;
|
||||
switch (icon.getState()) {
|
||||
case "destroyed":
|
||||
case "empty":
|
||||
iconBody = (
|
||||
<IconEmpty key={"empty"} className={cssStyle.icon} />
|
||||
);
|
||||
break;
|
||||
|
||||
case "loading":
|
||||
iconBody = (
|
||||
<IconLoading key={"loading"} className={cssStyle.icon} />
|
||||
);
|
||||
break;
|
||||
|
||||
case "error":
|
||||
iconBody = (
|
||||
<IconError title={tra("Failed to load icon {}:\n{}", props.remoteIconId, icon.getErrorMessage())} className={cssStyle.icon} key={"error"} />
|
||||
);
|
||||
break;
|
||||
|
||||
case "loaded":
|
||||
iconUrl = icon.getImageUrl();
|
||||
iconBody = (
|
||||
<IconUrl iconUrl={icon.getImageUrl()} title={tra("Icon {}", props.remoteIconId)} className={cssStyle.icon} key={"loaded"} />
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={joinClassList(cssStyle.iconContainer, props.selected && cssStyle.selected)}
|
||||
title={tra("Remote icon {}", props.remoteIconId)}
|
||||
onDoubleClick={() => events.fire("action_select", { targetIcon: props.iconId })}
|
||||
onClick={() => variables.setVariable("selectedIconId", undefined, props.iconId)}
|
||||
onContextMenu={event => {
|
||||
event.preventDefault();
|
||||
variables.setVariable("selectedIconId", undefined, props.iconId);
|
||||
spawnContextMenu({ pageY: event.pageY, pageX: event.pageX }, [
|
||||
{
|
||||
type: "normal",
|
||||
icon: ClientIcon.Download,
|
||||
label: tr("Download"),
|
||||
click: () => downloadUrl(iconUrl, "icon_" + props.remoteIconId),
|
||||
visible: !!iconUrl
|
||||
},
|
||||
{
|
||||
type: "normal",
|
||||
icon: ClientIcon.Delete,
|
||||
label: tr("Delete"),
|
||||
click: () => events.fire("action_delete", { iconId: props.iconId })
|
||||
}
|
||||
]);
|
||||
}}
|
||||
>
|
||||
{iconBody}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const UploadingTooltipRenderer = React.memo((props: { process: IconUploadProgress }) => {
|
||||
switch (props.process.state) {
|
||||
case "failed":
|
||||
return (
|
||||
<VariadicTranslatable text={"Icon upload failed:\n{}"} key={props.process.state}>
|
||||
{props.process.message}
|
||||
</VariadicTranslatable>
|
||||
);
|
||||
|
||||
case "transferring":
|
||||
return (
|
||||
<VariadicTranslatable text={"Uploading icon ({}%)"} key={props.process.state}>
|
||||
{props.process.process * 100}
|
||||
</VariadicTranslatable>
|
||||
);
|
||||
|
||||
case "pre-process":
|
||||
return (
|
||||
<Translatable key={props.process.state}>
|
||||
Preprocessing icon for upload
|
||||
</Translatable>
|
||||
);
|
||||
|
||||
case "pending":
|
||||
return (
|
||||
<Translatable key={props.process.state}>
|
||||
Icon upload pending and will start soon
|
||||
</Translatable>
|
||||
);
|
||||
|
||||
case "initializing":
|
||||
return (
|
||||
<Translatable key={props.process.state}>
|
||||
Icon upload initializing
|
||||
</Translatable>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Translatable key={"unknown"}>
|
||||
Unknown upload state
|
||||
</Translatable>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const RemoteIconUploadingRenderer = React.memo((props: { iconId: string, selected: boolean, progress: IconUploadProgress }) => {
|
||||
const events = useContext(EventContext);
|
||||
const variables = useContext(VariablesContext);
|
||||
const iconBuffer = variables.useReadOnly("uploadingIconPayload", props.iconId, undefined);
|
||||
|
||||
const iconUrl = useMemo(() => {
|
||||
if(!iconBuffer) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const imageType = responseImageType(iconBuffer);
|
||||
if(imageType === ImageType.UNKNOWN) {
|
||||
return URL.createObjectURL(new Blob([ iconBuffer ]));
|
||||
} else {
|
||||
const media = imageType2MediaType(imageType);
|
||||
return URL.createObjectURL(new Blob([ iconBuffer ], { type: media }));
|
||||
}
|
||||
}, [ iconBuffer ]);
|
||||
useEffect(() => () => URL.revokeObjectURL(iconUrl), [ iconUrl ]);
|
||||
|
||||
let icon;
|
||||
if(iconUrl) {
|
||||
icon = <IconUrl iconUrl={iconUrl} key={"icon"} className={cssStyle.icon + " " + cssStyle.uploading} />;
|
||||
} else if(props.progress.state === "failed") {
|
||||
icon = <IconError key={"error"} className={cssStyle.icon} />;
|
||||
} else {
|
||||
icon = <IconLoading key={"loading"} className={cssStyle.icon + " " + cssStyle.uploading} />;
|
||||
}
|
||||
|
||||
let progress: number;
|
||||
switch (props.progress.state) {
|
||||
default:
|
||||
case "failed":
|
||||
progress = 100;
|
||||
break;
|
||||
|
||||
case "pre-process":
|
||||
progress = 10;
|
||||
break;
|
||||
|
||||
case "pending":
|
||||
progress = 20;
|
||||
break;
|
||||
|
||||
case "initializing":
|
||||
progress = 30;
|
||||
break;
|
||||
|
||||
case "transferring":
|
||||
progress = 30 + 70 * props.progress.process;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip tooltip={() => <UploadingTooltipRenderer process={props.progress} />}>
|
||||
<div
|
||||
className={joinClassList(cssStyle.iconContainer, props.selected && cssStyle.selected)}
|
||||
onContextMenu={event => {
|
||||
event.preventDefault();
|
||||
if(props.progress.state === "failed") {
|
||||
spawnContextMenu({ pageX: event.pageX, pageY: event.pageY }, [
|
||||
{
|
||||
type: "normal",
|
||||
label: tr("Clear failed icons"),
|
||||
icon: ClientIcon.Delete,
|
||||
click: () => events.fire("action_clear_failed")
|
||||
}
|
||||
])
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={joinClassList(cssStyle.circle, props.progress.state === "failed" && cssStyle.error)}>
|
||||
<ProgressRing progress={progress} stroke={25} />
|
||||
</div>
|
||||
{icon}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})
|
||||
|
||||
const RemoteIcon = React.memo((props: { iconId: string, selected: boolean }) => {
|
||||
const variables = useContext(VariablesContext);
|
||||
const iconInfo = variables.useReadOnly("remoteIconInfo", props.iconId);
|
||||
|
||||
if(iconInfo.status === "loading") {
|
||||
return (
|
||||
<div
|
||||
key={"loading"}
|
||||
className={joinClassList(cssStyle.iconContainer, props.selected && cssStyle.selected)}
|
||||
title={tr("loading icon")}
|
||||
>
|
||||
<IconLoading className={cssStyle.icon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (iconInfo.value.status) {
|
||||
case "live":
|
||||
return (
|
||||
<RemoteIconLiveRenderer key={"remote-icon"} iconId={props.iconId} selected={props.selected} remoteIconId={iconInfo.value.iconId} />
|
||||
);
|
||||
|
||||
case "uploading":
|
||||
return (
|
||||
<RemoteIconUploadingRenderer key={"uploading"} iconId={props.iconId} selected={props.selected} progress={iconInfo.value.process} />
|
||||
);
|
||||
|
||||
case "unknown":
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const RemoteIconListRenderer = React.memo((props: { status: RemoteIconList }) => {
|
||||
const variables = useContext(VariablesContext);
|
||||
const selectedIconId = variables.useReadOnly("selectedIconId", undefined, undefined);
|
||||
|
||||
switch (props.status.status) {
|
||||
case "loading":
|
||||
return (
|
||||
<div className={cssStyle.overlay} key={props.status.status}>
|
||||
<div className={cssStyle.text}>
|
||||
<Translatable>Loading</Translatable> <LoadingDots />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "no-permission":
|
||||
return (
|
||||
<div className={cssStyle.overlay} key={props.status.status}>
|
||||
<div className={joinClassList(cssStyle.text, cssStyle.error)}>
|
||||
<VariadicTranslatable text={"You don't have permissions to view icons:\n{}"}>{props.status.failedPermission}</VariadicTranslatable>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "loaded":
|
||||
return (
|
||||
<React.Fragment key={props.status.status}>
|
||||
{props.status.icons.map(iconId => (
|
||||
<RemoteIcon iconId={iconId} selected={selectedIconId === iconId} key={"icon-" + iconId} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
case "error":
|
||||
default:
|
||||
return (
|
||||
<div className={cssStyle.overlay} key={props.status.status}>
|
||||
<div className={joinClassList(cssStyle.text, cssStyle.error)}>
|
||||
<VariadicTranslatable text={"An error occurred:\n{}"}>{props.status.status === "error" ? props.status.message : tr("Invalid state")}</VariadicTranslatable>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
async function doExecuteIconUpload(uploadId: string, file: File, events: Registry<ModalIconViewerEvents>) {
|
||||
/* TODO: Upload */
|
||||
if(file.size > 16 * 1024 * 1024) {
|
||||
throw tr("Icon file too large");
|
||||
}
|
||||
|
||||
/* Only check the type here if given else we'll check the content type later */
|
||||
if(file.type) {
|
||||
if(!file.type.startsWith("image/")) {
|
||||
throw tra("Icon isn't an image ({})", file.type);
|
||||
}
|
||||
}
|
||||
|
||||
let buffer: ArrayBuffer;
|
||||
try {
|
||||
buffer = await file.arrayBuffer();
|
||||
} catch (error) {
|
||||
logError(LogCategory.GENERAL, tr("Failed to read icon file as array buffer: %o"), error);
|
||||
throw tr("Failed to read file");
|
||||
}
|
||||
|
||||
const contentType = responseImageType(buffer);
|
||||
switch (contentType) {
|
||||
case ImageType.BITMAP:
|
||||
case ImageType.GIF:
|
||||
case ImageType.JPEG:
|
||||
case ImageType.PNG:
|
||||
case ImageType.SVG:
|
||||
break;
|
||||
|
||||
case ImageType.UNKNOWN:
|
||||
default:
|
||||
throw tr("File content isn't an image");
|
||||
}
|
||||
|
||||
let iconId: number;
|
||||
{
|
||||
const crc = new Crc32();
|
||||
crc.update(buffer);
|
||||
iconId = parseInt(crc.digest(10)) >>> 0;
|
||||
}
|
||||
|
||||
events.fire("action_upload", {
|
||||
buffer: buffer,
|
||||
iconId: iconId,
|
||||
uploadId: uploadId
|
||||
});
|
||||
}
|
||||
|
||||
async function executeIconUploads(files: File[], events: Registry<ModalIconViewerEvents>) {
|
||||
const uploads = files.map<[string, File]>(file => [ guid(), file ]);
|
||||
for(const [ uploadId, ] of uploads) {
|
||||
events.fire("action_initialize_upload", { uploadId: uploadId });
|
||||
}
|
||||
|
||||
for(const [ uploadId, file ] of uploads) {
|
||||
await doExecuteIconUpload(uploadId, file, events).catch(error => {
|
||||
let message;
|
||||
if(typeof error === "string") {
|
||||
message = error;
|
||||
} else {
|
||||
logError(LogCategory.GENERAL, tr("Failed to run icon upload: %o"), error);
|
||||
message = tr("lookup the console");
|
||||
}
|
||||
events.fire("action_fail_upload", { uploadId: uploadId, message: message });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const ButtonDelete = React.memo(() => {
|
||||
return (
|
||||
<Button color={"red"} className={joinClassList(cssStyle.button, cssStyle.buttonDelete)}>
|
||||
<Translatable>Delete</Translatable>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
const ButtonRefresh = React.memo(() => {
|
||||
const events = useContext(EventContext);
|
||||
const variables = useContext(VariablesContext);
|
||||
const iconIds = variables.useReadOnly("remoteIconList", undefined, { status: "loading" });
|
||||
|
||||
const [ renderTimestamp, setRenderTimestamp ] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
if(iconIds.status === "loading") {
|
||||
return;
|
||||
}
|
||||
|
||||
if(renderTimestamp >= iconIds.refreshTimestamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = setTimeout(() => setRenderTimestamp(Date.now()), iconIds.refreshTimestamp - Date.now());
|
||||
return () => clearTimeout(id);
|
||||
}, [ iconIds ]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
color={"blue"}
|
||||
className={joinClassList(cssStyle.button, cssStyle.buttonRefresh)}
|
||||
disabled={iconIds.status === "loading" ? true : renderTimestamp < iconIds.refreshTimestamp}
|
||||
onClick={() => events.fire("action_refresh")}
|
||||
>
|
||||
<Translatable>Refresh</Translatable>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
const RemoteIconTab = React.memo(() => {
|
||||
const events = useContext(EventContext);
|
||||
const variables = useContext(VariablesContext);
|
||||
const iconIds = variables.useReadOnly("remoteIconList", undefined, { status: "loading" });
|
||||
const [ dragOverCount, setDragOverCount ] = useState(0);
|
||||
const dragOverTimeout = useRef(0);
|
||||
|
||||
return (
|
||||
<div className={cssStyle.tabContent}>
|
||||
<div
|
||||
className={cssStyle.body}
|
||||
onDragOver={event => {
|
||||
clearTimeout(dragOverTimeout.current);
|
||||
dragOverTimeout.current = 0;
|
||||
|
||||
const files = [...event.dataTransfer.items].filter(item => item.kind === "file");
|
||||
if(files.length === 0) {
|
||||
/* No files */
|
||||
return;
|
||||
}
|
||||
|
||||
/* Allow drop */
|
||||
event.preventDefault();
|
||||
setDragOverCount(files.length);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if(dragOverTimeout.current) {
|
||||
clearTimeout(dragOverTimeout.current);
|
||||
return;
|
||||
}
|
||||
|
||||
dragOverTimeout.current = setTimeout(() => setDragOverCount(0), 250);
|
||||
}}
|
||||
onDrop={event => {
|
||||
event.preventDefault();
|
||||
|
||||
clearTimeout(dragOverTimeout.current);
|
||||
dragOverTimeout.current = 0;
|
||||
|
||||
setDragOverCount(0);
|
||||
|
||||
const files = [...event.dataTransfer.items]
|
||||
.filter(item => item.kind === "file")
|
||||
.map(file => file.getAsFile())
|
||||
.filter(file => !!file);
|
||||
|
||||
if(files.length === 0) {
|
||||
/* No files */
|
||||
return;
|
||||
}
|
||||
|
||||
ignorePromise(executeIconUploads(files, events));
|
||||
}}
|
||||
>
|
||||
<RemoteIconListRenderer status={iconIds} key={"icons"} />
|
||||
<div className={joinClassList(cssStyle.overlay, !dragOverCount && cssStyle.hidden)} key={"drag-overlay"}>
|
||||
<div className={joinClassList(cssStyle.text)}>
|
||||
<VariadicTranslatable text={"Drop {} icons to upload them"}>{dragOverCount}</VariadicTranslatable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cssStyle.footer}>
|
||||
<Button
|
||||
color={"green"}
|
||||
className={joinClassList(cssStyle.button, cssStyle.buttonUpload)}
|
||||
onClick={() => {
|
||||
promptFile({ multiple: true }).then(files => {
|
||||
ignorePromise(executeIconUploads(files, events));
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Translatable>Upload</Translatable>
|
||||
</Button>
|
||||
<ButtonDelete />
|
||||
<ButtonRefresh />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const IconTabs = React.memo(() => {
|
||||
const variables = useContext(VariablesContext);
|
||||
const selectedTab = variables.useVariable("selectedTab", undefined, "remote");
|
||||
|
||||
return (
|
||||
<Tab
|
||||
selectedTab={selectedTab.localValue}
|
||||
onChange={newTab => selectedTab.setValue(newTab as any)}
|
||||
className={cssStyle.tab}
|
||||
bodyClassName={cssStyle.tabBody}
|
||||
>
|
||||
<TabEntry id={"remote"}>
|
||||
<Translatable>Remote</Translatable>
|
||||
<RemoteIconTab />
|
||||
</TabEntry>
|
||||
<TabEntry id={"local"}>
|
||||
<Translatable>Local</Translatable>
|
||||
<LocalIconTab />
|
||||
</TabEntry>
|
||||
</Tab>
|
||||
);
|
||||
});
|
||||
|
||||
const SelectButtons = React.memo(() => {
|
||||
const events = useContext(EventContext);
|
||||
const variables = useContext(VariablesContext);
|
||||
const selectedIcon = variables.useReadOnly("selectedIconId", undefined, undefined);
|
||||
|
||||
return (
|
||||
<div className={cssStyle.selectButtons}>
|
||||
<Button color={"red"} onClick={() => events.fire("action_select", { targetIcon: undefined })}>
|
||||
<Translatable>Remove Icon</Translatable>
|
||||
</Button>
|
||||
<Button color={"green"} disabled={!selectedIcon} onClick={() => events.fire("action_select", { targetIcon: selectedIcon })}>
|
||||
<Translatable>Select Icon</Translatable>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
class Modal extends AbstractModal {
|
||||
private readonly handlerId: string;
|
||||
private readonly events: Registry<ModalIconViewerEvents>;
|
||||
private readonly variables: UiVariableConsumer<ModalIconViewerVariables>;
|
||||
private readonly isIconSelect: boolean;
|
||||
|
||||
constructor(handlerId: string, events: IpcRegistryDescription<ModalIconViewerEvents>, variables: IpcVariableDescriptor<ModalIconViewerVariables>, isIconSelect: boolean) {
|
||||
super();
|
||||
|
||||
this.handlerId = handlerId;
|
||||
this.events = Registry.fromIpcDescription(events);
|
||||
this.variables = createIpcUiVariableConsumer(variables);
|
||||
this.isIconSelect = isIconSelect;
|
||||
|
||||
this.events.on("notify_delete_error", event => {
|
||||
switch (event.status) {
|
||||
case "no-permissions":
|
||||
createErrorModal(tr("Failed to delete icon"), tra("Failed to delete icon (No permissions):\n{}", event.failedPermission)).open();
|
||||
break;
|
||||
|
||||
case "not-found":
|
||||
createErrorModal(tr("Failed to delete icon"), tra("Failed to delete icon (Icon not found)")).open();
|
||||
break;
|
||||
|
||||
case "error":
|
||||
default:
|
||||
createErrorModal(tr("Failed to delete icon"), tra("Failed to delete icon:\n{}", event.message || tr("Unknown/invalid error status"))).open();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected onDestroy() {
|
||||
super.onDestroy();
|
||||
|
||||
this.events.destroy();
|
||||
this.variables.destroy();
|
||||
}
|
||||
|
||||
renderBody(): React.ReactElement {
|
||||
return (
|
||||
<HandlerIdContext.Provider value={this.handlerId}>
|
||||
<EventContext.Provider value={this.events}>
|
||||
<VariablesContext.Provider value={this.variables}>
|
||||
<div className={joinClassList(cssStyle.container, this.properties.windowed && cssStyle.windowed)}>
|
||||
<IconTabs />
|
||||
{this.isIconSelect ? (
|
||||
<SelectButtons key={"select-buttons"} />
|
||||
) : undefined}
|
||||
</div>
|
||||
</VariablesContext.Provider>
|
||||
</EventContext.Provider>
|
||||
</HandlerIdContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
renderTitle(): React.ReactNode {
|
||||
return (
|
||||
<Translatable>Icon manager</Translatable>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Modal;
|
|
@ -9,7 +9,6 @@ import {formatMessage, formatMessageString} from "tc-shared/ui/frames/chat";
|
|||
import {tra} from "tc-shared/i18n/localize";
|
||||
import {PermissionType} from "tc-shared/permission/PermissionType";
|
||||
import {GroupedPermissions, PermissionValue} from "tc-shared/permission/PermissionManager";
|
||||
import {spawnIconSelect} from "tc-shared/ui/modal/ModalIconSelect";
|
||||
import {Settings, settings} from "tc-shared/settings";
|
||||
import {
|
||||
senseless_channel_group_permissions,
|
||||
|
@ -32,6 +31,7 @@ import {
|
|||
} from "tc-shared/ui/modal/permission/ModalDefinitions";
|
||||
import {EditorGroupedPermissions, PermissionEditorEvents} from "tc-shared/ui/modal/permission/EditorDefinitions";
|
||||
import {promptYesNo} from "tc-shared/ui/modal/yes-no/Controller";
|
||||
import {spawnIconManage} from "tc-shared/ui/modal/icon-viewer/Controller";
|
||||
|
||||
export function spawnPermissionEditorModal(connection: ConnectionHandler, defaultTab: PermissionEditorTab = "groups-server", defaultTabValues?: DefaultTabValues) {
|
||||
const modalEvents = new Registry<PermissionModalEvents>();
|
||||
|
@ -927,17 +927,17 @@ function initializePermissionEditor(connection: ConnectionHandler, modalEvents:
|
|||
});
|
||||
|
||||
events.on("action_open_icon_select", event => {
|
||||
spawnIconSelect(connection,
|
||||
id => events.fire("action_set_permissions", {
|
||||
spawnIconManage(connection, event.iconId, newIconId => {
|
||||
events.fire("action_set_permissions", {
|
||||
permissions: [{
|
||||
mode: "value",
|
||||
name: PermissionType.I_ICON_ID,
|
||||
flagSkip: false,
|
||||
flagNegate: false,
|
||||
value: id
|
||||
value: newIconId
|
||||
}]
|
||||
}),
|
||||
event.iconId);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import * as React from "react";
|
||||
import {Settings, settings} from "tc-shared/settings";
|
||||
import {LogCategory, logWarn} from "tc-shared/log";
|
||||
|
||||
const cssStyle = require("./ContextDivider.scss");
|
||||
|
||||
|
@ -19,11 +20,12 @@ export interface ContextDividerState {
|
|||
active: boolean;
|
||||
}
|
||||
|
||||
export class ContextDivider extends React.Component<ContextDividerProperties, ContextDividerState> {
|
||||
export class ContextDivider extends React.PureComponent<ContextDividerProperties, ContextDividerState> {
|
||||
private readonly refSeparator = React.createRef<HTMLDivElement>();
|
||||
private readonly listenerMove;
|
||||
private readonly listenerUp;
|
||||
|
||||
private observer: MutationObserver;
|
||||
private value;
|
||||
|
||||
constructor(props) {
|
||||
|
@ -117,14 +119,25 @@ export class ContextDivider extends React.Component<ContextDividerProperties, Co
|
|||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
const separator = this.refSeparator.current;
|
||||
if(!separator) return;
|
||||
this.observer = new MutationObserver(() => {
|
||||
this.tryApplySeparator();
|
||||
});
|
||||
|
||||
this.applySeparator(separator.previousSibling as HTMLElement, separator.nextSibling as HTMLElement);
|
||||
this.observer.observe(this.refSeparator.current.parentElement, {
|
||||
attributes: false,
|
||||
childList: true,
|
||||
subtree: false,
|
||||
characterData: false,
|
||||
});
|
||||
|
||||
this.tryApplySeparator();
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
this.stopMovement();
|
||||
|
||||
this.observer.disconnect();
|
||||
this.observer = undefined;
|
||||
}
|
||||
|
||||
private startMovement(event: React.MouseEvent | React.TouchEvent) {
|
||||
|
@ -156,6 +169,13 @@ export class ContextDivider extends React.Component<ContextDividerProperties, Co
|
|||
}));
|
||||
}
|
||||
|
||||
private tryApplySeparator() {
|
||||
const separator = this.refSeparator.current;
|
||||
if(!separator) return;
|
||||
|
||||
this.applySeparator(separator.previousSibling as HTMLElement, separator.nextSibling as HTMLElement);
|
||||
}
|
||||
|
||||
private applySeparator(previousElement: HTMLElement, nextElement: HTMLElement) {
|
||||
if(!this.refSeparator.current || !previousElement || !nextElement) {
|
||||
return;
|
||||
|
|
|
@ -10,3 +10,22 @@
|
|||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.iconLoading {
|
||||
border: .2em solid #f3f3f3; /* Light grey */
|
||||
border-top: .2em solid #3498db; /* Blue */
|
||||
border-radius: 50%;
|
||||
animation: loading_spin 2s linear infinite;
|
||||
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
@keyframes loading_spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
|
@ -1,12 +1,32 @@
|
|||
import * as React from "react";
|
||||
import {useState} from "react";
|
||||
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
|
||||
import {ClientIcon} from "svg-sprites/client-icons";
|
||||
import {getIconManager, RemoteIcon, RemoteIconInfo} from "tc-shared/file/Icons";
|
||||
import {useState} from "react";
|
||||
|
||||
const cssStyle = require("./Icon.scss");
|
||||
|
||||
const EmptyIcon = (props: { className?: string, title?: string }) => <div className={cssStyle.container + " icon-container icon-empty " + props.className} title={props.title} />;
|
||||
export const IconEmpty = React.memo((props: { className?: string, title?: string }) => (
|
||||
<div className={cssStyle.container + " icon-container icon-empty " + props.className} title={props.title} />
|
||||
));
|
||||
|
||||
export const IconRenderer = (props: {
|
||||
export const IconLoading = React.memo((props: { className?: string, title?: string }) => (
|
||||
<div className={cssStyle.container + " icon-container " + props.className} title={props.title}>
|
||||
<div className={cssStyle.iconLoading} />
|
||||
</div>
|
||||
));
|
||||
|
||||
export const IconError = React.memo((props: { className?: string, title?: string }) => (
|
||||
<ClientIconRenderer icon={ClientIcon.Warning} className={props.className} title={props.title} />
|
||||
));
|
||||
|
||||
export const IconUrl = React.memo((props: { iconUrl: string, className?: string, title?: string }) => (
|
||||
<div className={cssStyle.container + " icon-container " + props.className}>
|
||||
<img style={{ maxWidth: "100%", maxHeight: "100%" }} src={props.iconUrl} alt={props.title} draggable={false} />
|
||||
</div>
|
||||
));
|
||||
|
||||
export const IconRenderer = React.memo((props: {
|
||||
icon: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
|
@ -18,9 +38,9 @@ export const IconRenderer = (props: {
|
|||
} else {
|
||||
throw "JQuery icons are not longer supported";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const RemoteIconRenderer = (props: { icon: RemoteIcon | undefined, className?: string, title?: string }) => {
|
||||
export const RemoteIconRenderer = React.memo((props: { icon: RemoteIcon | undefined, className?: string, title?: string }) => {
|
||||
const [ revision, setRevision ] = useState(0);
|
||||
|
||||
props.icon.events.reactUse("notify_state_changed", () => setRevision(revision + 1));
|
||||
|
@ -28,36 +48,43 @@ export const RemoteIconRenderer = (props: { icon: RemoteIcon | undefined, classN
|
|||
switch (props.icon.getState()) {
|
||||
case "empty":
|
||||
case "destroyed":
|
||||
return <div key={"empty"} className={cssStyle.container + " icon-container icon-empty " + props.className} title={props.title} />;
|
||||
return (
|
||||
<IconEmpty key={"empty"} className={props.className} title={props.title} />
|
||||
);
|
||||
|
||||
case "loaded":
|
||||
if(props.icon.iconId >= 0 && props.icon.iconId <= 1000) {
|
||||
if(props.icon.iconId === 0) {
|
||||
return <div key={"loaded-empty"} className={cssStyle.container + " icon-container icon-empty " + props.className} title={props.title} />;
|
||||
return (
|
||||
<IconEmpty key={"loaded-empty"} className={props.className} title={props.title} />
|
||||
);
|
||||
}
|
||||
|
||||
return <div key={"loaded"} className={cssStyle.container + " icon_em client-group_" + props.icon.iconId + " " + props.className} title={props.title} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={"icon-" + props.icon.iconId} className={cssStyle.container + " icon-container " + props.className}>
|
||||
<img style={{ maxWidth: "100%", maxHeight: "100%" }} src={props.icon.getImageUrl()} alt={props.title || ("icon " + props.icon.iconId)} draggable={false} />
|
||||
</div>
|
||||
<IconUrl iconUrl={props.icon.getImageUrl()} title={props.title || ("icon " + props.icon.iconId)} key={"icon-" + props.icon.iconId} />
|
||||
);
|
||||
|
||||
case "loading":
|
||||
return <div key={"loading"} className={cssStyle.container + " icon-container " + props.className} title={props.title}><div className={"icon_loading"} /></div>;
|
||||
return (
|
||||
<IconLoading className={props.className} title={props.title} key={"loading"} />
|
||||
);
|
||||
|
||||
case "error":
|
||||
return <div key={"error"} className={cssStyle.container + " icon client-warning " + props.className} title={props.icon.getErrorMessage() || tr("Failed to load icon")} />;
|
||||
return (
|
||||
<IconError key={"error"} className={props.className} title={props.icon.getErrorMessage()} />
|
||||
);
|
||||
|
||||
default:
|
||||
throw "invalid icon state";
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const RemoteIconInfoRenderer = React.memo((props: { icon: RemoteIconInfo | undefined, className?: string, title?: string }) => {
|
||||
if(!props.icon || props.icon.iconId === 0) {
|
||||
return <EmptyIcon title={props.title} className={props.className} key={"empty-icon"} />;
|
||||
return <IconEmpty title={props.title} className={props.className} key={"empty-icon"} />;
|
||||
} else {
|
||||
return <RemoteIconRenderer icon={getIconManager().resolveIconInfo(props.icon)} className={props.className} title={props.title} key={"icon"} />;
|
||||
}
|
||||
|
|
|
@ -21,8 +21,6 @@
|
|||
flex-direction: row;
|
||||
justify-content: stretch;
|
||||
|
||||
border-bottom: 1px solid #1d1d1d;
|
||||
|
||||
.entry {
|
||||
padding: .5em;
|
||||
|
||||
|
@ -32,10 +30,8 @@
|
|||
flex-shrink: 1;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #b6c4d6;
|
||||
}
|
||||
border-bottom: 1px solid #1d1d1d;
|
||||
transition: $button_hover_animation_time;
|
||||
|
||||
&.selected {
|
||||
border-bottom: 3px solid #245184;
|
||||
|
@ -44,7 +40,9 @@
|
|||
color: #245184;
|
||||
}
|
||||
|
||||
@include transition(color $button_hover_animation_time, border-bottom-color $button_hover_animation_time);
|
||||
&:hover {
|
||||
color: #5f95d3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import * as React from "react";
|
||||
import {tra} from "tc-shared/i18n/localize";
|
||||
import {joinClassList} from "tc-shared/ui/react-elements/Helper";
|
||||
|
||||
const cssStyle = require("./Tab.scss");
|
||||
|
||||
|
@ -20,21 +21,51 @@ export class TabEntry extends React.Component<{
|
|||
export class Tab extends React.PureComponent<{
|
||||
children: React.ReactElement[],
|
||||
|
||||
defaultTab: string;
|
||||
selectedTab?: string;
|
||||
|
||||
/* permanent render all body parts (defaults to true) */
|
||||
permanentRender?: boolean,
|
||||
|
||||
className?: string
|
||||
}, {
|
||||
className?: string,
|
||||
bodyClassName?: string
|
||||
} & ({
|
||||
/* Controlled by the component itself */
|
||||
defaultTab: string,
|
||||
onChange?: (newTab: string) => void
|
||||
} | {
|
||||
/* Controlled via the parent */
|
||||
selectedTab: string,
|
||||
onChange: (newTab: string) => void
|
||||
}), {
|
||||
selectedTab: string
|
||||
}> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
if('defaultTab' in this.props) {
|
||||
this.state = { selectedTab: this.props.defaultTab };
|
||||
}
|
||||
}
|
||||
|
||||
private getSelectedTab() : string {
|
||||
if('defaultTab' in this.props) {
|
||||
return this.state.selectedTab;
|
||||
} else {
|
||||
return this.props.selectedTab;
|
||||
}
|
||||
}
|
||||
|
||||
private setSelectedTab(newTab: string) {
|
||||
if(this.getSelectedTab() === newTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
if('defaultTab' in this.props) {
|
||||
this.setState({ selectedTab: newTab });
|
||||
}
|
||||
|
||||
if(this.props.onChange) {
|
||||
this.props.onChange(newTab);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.props.children?.forEach((child, index) => {
|
||||
|
@ -43,7 +74,7 @@ export class Tab extends React.PureComponent<{
|
|||
}
|
||||
});
|
||||
|
||||
const selectedTab = this.props.selectedTab || this.state.selectedTab;
|
||||
const selectedTab = this.getSelectedTab();
|
||||
return (
|
||||
<div className={cssStyle.container + " " + this.props.className}>
|
||||
<div className={cssStyle.categories}>
|
||||
|
@ -52,14 +83,14 @@ export class Tab extends React.PureComponent<{
|
|||
<div
|
||||
className={cssStyle.entry + " " + (child.props.id === selectedTab ? cssStyle.selected : "")}
|
||||
key={child.props.id}
|
||||
onClick={() => !this.props.selectedTab && this.setState({ selectedTab: child.props.id })}
|
||||
onClick={() => this.setSelectedTab(child.props.id)}
|
||||
>
|
||||
{child.props.children[0]}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className={cssStyle.bodies}>
|
||||
<div className={joinClassList(cssStyle.bodies, this.props.bodyClassName)}>
|
||||
{this.props.children?.filter(child => typeof this.props.permanentRender !== "boolean" || this.props.permanentRender || child.props.id === selectedTab).map(child => {
|
||||
return (
|
||||
<div className={cssStyle.body + " " + (child.props.id === selectedTab ? "" : cssStyle.hidden)} key={child.props.id}>
|
||||
|
|
|
@ -28,6 +28,7 @@ import {ModalYesNoEvents, ModalYesNoVariables} from "tc-shared/ui/modal/yes-no/D
|
|||
import {ModalChannelInfoEvents, ModalChannelInfoVariables} from "tc-shared/ui/modal/channel-info/Definitions";
|
||||
import {ModalVideoViewersEvents, ModalVideoViewersVariables} from "tc-shared/ui/modal/video-viewers/Definitions";
|
||||
import {ModalChannelChatParameters} from "tc-shared/ui/modal/channel-chat/Definitions";
|
||||
import {ModalIconViewerEvents, ModalIconViewerVariables} from "tc-shared/ui/modal/icon-viewer/Definitions";
|
||||
|
||||
export type ModalType = "error" | "warning" | "info" | "none";
|
||||
export type ModalRenderType = "page" | "dialog";
|
||||
|
@ -68,7 +69,13 @@ export interface ModalOptions {
|
|||
* The default popout state.
|
||||
* Default: `false`
|
||||
*/
|
||||
popedOut?: boolean
|
||||
popedOut?: boolean,
|
||||
|
||||
/**
|
||||
* Don't share the same JavaScript environment.
|
||||
* Default: `true`
|
||||
*/
|
||||
noOpener?: boolean
|
||||
}
|
||||
|
||||
export interface ModalEvents {
|
||||
|
@ -137,8 +144,8 @@ export abstract class AbstractModal {
|
|||
currentModalProperties = undefined;
|
||||
}
|
||||
|
||||
abstract renderBody() : ReactElement;
|
||||
abstract renderTitle() : string | React.ReactElement;
|
||||
abstract renderBody() : React.ReactNode;
|
||||
abstract renderTitle() : React.ReactNode;
|
||||
|
||||
/* only valid for the "inline" modals */
|
||||
type() : ModalType { return "none"; }
|
||||
|
@ -248,5 +255,11 @@ export interface ModalConstructorArguments {
|
|||
"modal-video-viewers": [
|
||||
/* events */ IpcRegistryDescription<ModalVideoViewersEvents>,
|
||||
/* variables */ IpcVariableDescriptor<ModalVideoViewersVariables>
|
||||
],
|
||||
"modal-icon-viewer": [
|
||||
/* handlerId */ string,
|
||||
/* events */ IpcRegistryDescription<ModalIconViewerEvents>,
|
||||
/* variables */ IpcVariableDescriptor<ModalIconViewerVariables>,
|
||||
/* select */ boolean
|
||||
]
|
||||
}
|
|
@ -156,3 +156,9 @@ registerModal({
|
|||
classLoader: async () => await import("tc-shared/ui/modal/video-viewers/Renderer"),
|
||||
popoutSupported: true
|
||||
});
|
||||
|
||||
registerModal({
|
||||
modalId: "modal-icon-viewer",
|
||||
classLoader: async () => await import("tc-shared/ui/modal/icon-viewer/Renderer"),
|
||||
popoutSupported: true
|
||||
});
|
|
@ -137,7 +137,8 @@ export class ExternalModalController implements ModalInstanceController {
|
|||
defaultSize: this.modalOptions.defaultSize,
|
||||
appParameters: {
|
||||
"modal-channel": this.ipcChannel.channelId,
|
||||
}
|
||||
},
|
||||
noOpener: typeof this.modalOptions.noOpener === "boolean" ? this.modalOptions.noOpener : true
|
||||
});
|
||||
|
||||
if(result.status === "error-user-rejected") {
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
import {UiVariableConsumer, UiVariableMap, UiVariableProvider} from "tc-shared/ui/utils/Variable";
|
||||
import {guid} from "tc-shared/crypto/uid";
|
||||
import {LogCategory, logWarn} from "tc-shared/log";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
export class IpcUiVariableProvider<Variables extends UiVariableMap> extends UiVariableProvider<Variables> {
|
||||
readonly ipcChannelId: string;
|
||||
private broadcastChannel: BroadcastChannel;
|
||||
|
||||
private enqueuedMessages: any[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
|
@ -27,7 +30,7 @@ export class IpcUiVariableProvider<Variables extends UiVariableMap> extends UiVa
|
|||
}
|
||||
|
||||
protected doSendVariable(variable: string, customData: any, value: any) {
|
||||
this.broadcastChannel.postMessage({
|
||||
this.broadcastIpcMessage({
|
||||
type: "notify",
|
||||
|
||||
variable,
|
||||
|
@ -48,7 +51,7 @@ export class IpcUiVariableProvider<Variables extends UiVariableMap> extends UiVa
|
|||
error
|
||||
});
|
||||
} else {
|
||||
this.broadcastChannel.postMessage({
|
||||
this.broadcastIpcMessage({
|
||||
type: "edit-result",
|
||||
token,
|
||||
error
|
||||
|
@ -73,6 +76,10 @@ export class IpcUiVariableProvider<Variables extends UiVariableMap> extends UiVa
|
|||
}
|
||||
} else if(message.type === "query") {
|
||||
this.sendVariable(message.variable, message.customData, true);
|
||||
} else if(message.type === "bundled") {
|
||||
for(const bundledMessage of message.messages) {
|
||||
this.handleIpcMessage(bundledMessage, source, origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -81,6 +88,28 @@ export class IpcUiVariableProvider<Variables extends UiVariableMap> extends UiVa
|
|||
ipcChannelId: this.ipcChannelId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an IPC message.
|
||||
* We bundle messages to improve performance when doing a lot of combined requests.
|
||||
* @param message IPC message to send
|
||||
* @private
|
||||
*/
|
||||
private broadcastIpcMessage(message: any) {
|
||||
if(Array.isArray(this.enqueuedMessages)) {
|
||||
this.enqueuedMessages.push(message);
|
||||
return;
|
||||
}
|
||||
|
||||
this.enqueuedMessages = [ message ];
|
||||
setImmediate(() => {
|
||||
this.broadcastChannel.postMessage({
|
||||
type: "bundled",
|
||||
messages: this.enqueuedMessages
|
||||
});
|
||||
this.enqueuedMessages = undefined;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export type IpcVariableDescriptor<Variables extends UiVariableMap> = {
|
||||
|
@ -93,6 +122,8 @@ class IpcUiVariableConsumer<Variables extends UiVariableMap> extends UiVariableC
|
|||
private broadcastChannel: BroadcastChannel;
|
||||
private editListener: {[key: string]: { resolve: () => void, reject: (error) => void }};
|
||||
|
||||
private enqueuedMessages: any[];
|
||||
|
||||
constructor(description: IpcVariableDescriptor<Variables>) {
|
||||
super();
|
||||
this.description = description;
|
||||
|
@ -121,7 +152,7 @@ class IpcUiVariableConsumer<Variables extends UiVariableMap> extends UiVariableC
|
|||
const token = "t" + ++editTokenIndex;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.broadcastChannel.postMessage({
|
||||
this.sendIpcMessage({
|
||||
type: "edit",
|
||||
token,
|
||||
variable,
|
||||
|
@ -137,14 +168,14 @@ class IpcUiVariableConsumer<Variables extends UiVariableMap> extends UiVariableC
|
|||
}
|
||||
|
||||
protected doRequestVariable(variable: string, customData: any) {
|
||||
this.broadcastChannel.postMessage({
|
||||
this.sendIpcMessage({
|
||||
type: "query",
|
||||
variable,
|
||||
customData,
|
||||
});
|
||||
}
|
||||
|
||||
private handleIpcMessage(message: any, _source: MessageEventSource | null) {
|
||||
private handleIpcMessage(message: any, source: MessageEventSource | null) {
|
||||
if(message.type === "notify") {
|
||||
this.notifyRemoteVariable(message.variable, message.customData, message.value);
|
||||
} else if(message.type === "edit-result") {
|
||||
|
@ -159,7 +190,36 @@ class IpcUiVariableConsumer<Variables extends UiVariableMap> extends UiVariableC
|
|||
} else {
|
||||
payload.resolve();
|
||||
}
|
||||
} else if(message.type === "bundled") {
|
||||
ReactDOM.unstable_batchedUpdates(() => {
|
||||
for(const bundledMessage of message.messages) {
|
||||
this.handleIpcMessage(bundledMessage, source);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an IPC message.
|
||||
* We bundle messages to improve performance when doing a lot of combined requests.
|
||||
* The response will most likely also be bundled. This means that we're only updating react once.
|
||||
* @param message IPC message to send
|
||||
* @private
|
||||
*/
|
||||
private sendIpcMessage(message: any) {
|
||||
if(Array.isArray(this.enqueuedMessages)) {
|
||||
this.enqueuedMessages.push(message);
|
||||
return;
|
||||
}
|
||||
|
||||
this.enqueuedMessages = [ message ];
|
||||
setImmediate(() => {
|
||||
this.broadcastChannel.postMessage({
|
||||
type: "bundled",
|
||||
messages: this.enqueuedMessages
|
||||
});
|
||||
this.enqueuedMessages = undefined;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -145,7 +145,8 @@ export abstract class UiVariableProvider<Variables extends UiVariableMap> {
|
|||
|
||||
private handleEditResult(variable: string, customData: any, result: any) {
|
||||
if(typeof result === "undefined") {
|
||||
/* change succeeded, no need to notify any variable since the consumer already has the newest value */
|
||||
/* Send the new variable since may other listeners needs to be notified */
|
||||
this.sendVariable(variable, customData, true);
|
||||
} else if(result === true || result === false) {
|
||||
/* The new variable has been accepted/rejected and the variable should be updated on the remote side. */
|
||||
/* TODO: Use cached value if the result is `false` */
|
||||
|
|
|
@ -26,6 +26,9 @@ export interface WindowSpawnOptions {
|
|||
|
||||
appParameters?: {[key: string]: string},
|
||||
defaultSize?: { width: number, height: number },
|
||||
|
||||
/* Don't share the same JavaScript environment (Default behaviour is currently "no" but this will change within the feature! */
|
||||
noOpener?: boolean,
|
||||
}
|
||||
|
||||
export interface WindowManager {
|
||||
|
|
|
@ -150,8 +150,8 @@ export class WebWindowManager implements WindowManager {
|
|||
});
|
||||
|
||||
const features = {
|
||||
/* TODO: Configureable and enabled by default! */
|
||||
noopener: "no",
|
||||
/* We can't enable this yet since we're loosing control over the Window that way. */
|
||||
//noopener: options.noOpener ? "yes" : "no",
|
||||
|
||||
status: "no",
|
||||
location: "no",
|
||||
|
|
Loading…
Reference in New Issue