Reworked the icon upload and select modal and fixed some minor css issues

master
WolverinDEV 2021-05-05 16:28:10 +02:00
parent f2ab9800d4
commit 7a351893ab
31 changed files with 1744 additions and 829 deletions

View File

@ -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

View File

@ -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%;

View File

@ -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">

View File

@ -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 => {
@ -556,7 +560,7 @@ export class FileManager {
"clientftfid": transfer.clientTransferId,
"seekpos": 0,
"proto": 1
}, {process_result: false});
}, { process_result: false });
if(transfer.transferState() === FileTransferState.INITIALIZING) {
throw tr("missing transfer start notify");
@ -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 */
@ -743,7 +774,7 @@ export class FileManager {
if(runningTransfers.length !== 0) {
/* start a new transfer as soon the old has been finished */
Promise.race([runningTransfers.map(e => e.finishPromise)]).then(() => {
Promise.race([ runningTransfers.map(e => e.finishPromise) ]).then(() => {
this.scheduleTransferUpdate();
});
}

View File

@ -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)

View File

@ -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);
}
})
});

View File

@ -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);

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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',

View File

@ -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);
}

View File

@ -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 => {

View File

@ -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"
});

View File

@ -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 = [];

View File

@ -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());
}

View File

@ -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,
}

View File

@ -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;
}
}
}
}

View File

@ -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;

View File

@ -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);
});
});
});
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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"} />;
}

View File

@ -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;
}
}
}

View File

@ -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,20 +21,50 @@ 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);
this.state = { selectedTab: this.props.defaultTab };
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() {
@ -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}>

View File

@ -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
]
}

View File

@ -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
});

View File

@ -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") {

View File

@ -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,8 +190,37 @@ 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;
})
}
}
export function createIpcUiVariableProvider<Variables extends UiVariableMap>() : IpcUiVariableProvider<Variables> {

View File

@ -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` */

View File

@ -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 {

View File

@ -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",