Reworked the server group assignment modal

master
WolverinDEV 2021-03-21 16:51:30 +01:00
parent f3f5fd0618
commit 582b93852b
14 changed files with 1271 additions and 66 deletions

View File

@ -1,6 +1,9 @@
# Changelog:
* **21.03.21**
- Reworked the server group assignment modal. It now better reacts to the user input as well is now popoutable
* **18.03.21**
- Finally got fully rid of the initial backend glue and changes all systems to provider
- Finally, got fully rid of the initial backend glue and changes all systems to provider
* **17.03.21**
- Updated from webpack 4 to webpack 5

62
shared/js/Mutex.ts Normal file
View File

@ -0,0 +1,62 @@
export class Mutex<T> {
private value: T;
private taskExecuting = false;
private taskQueue = [];
constructor(value: T) {
this.value = value;
}
execute<R>(callback: (value: T, setValue: (newValue: T) => void) => R | Promise<R>) : Promise<R> {
return new Promise<R>((resolve, reject) => {
this.taskQueue.push(() => new Promise(taskResolve => {
try {
const result = callback(this.value, newValue => this.value = newValue);
if(result instanceof Promise) {
result.then(result => {
taskResolve();
resolve(result);
}).catch(error => {
taskResolve();
reject(error);
});
} else {
taskResolve();
resolve(result);
}
} catch (error) {
taskResolve();
reject(error);
}
}));
if(!this.taskExecuting) {
this.executeNextTask();
}
});
}
async tryExecute<R>(callback: (value: T, setValue: (newValue: T) => void) => R | Promise<R>) : Promise<{ status: "success", result: R } | { status: "would-block" }> {
if(!this.taskExecuting) {
return {
status: "success",
result: await this.execute(callback)
};
} else {
return {
status: "would-block"
};
}
}
private executeNextTask() {
const task = this.taskQueue.pop_front();
if(typeof task === "undefined") {
this.taskExecuting = false;
return;
}
this.taskExecuting = true;
task().then(() => this.executeNextTask());
}
}

View File

@ -0,0 +1,259 @@
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {LogCategory, logError, logWarn} from "tc-shared/log";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import {ErrorCode} from "tc-shared/connection/ErrorCode";
export type ClientInfoResult = {
status: "success",
clientName: string,
clientUniqueId: string,
clientDatabaseId: number,
} | {
status: "not-found"
} | {
status: "error",
error: string
};
type PendingInfoRequest = {
promise: Promise<ClientInfoResult>,
resolve: (result: ClientInfoResult) => void,
fullFilled: boolean,
};
type ClientInfo = {
clientName: string,
clientUniqueId: string,
clientDatabaseId: number,
};
export class ClientInfoResolver {
private readonly handler: ConnectionHandler;
private readonly requestDatabaseIds: { [key: number]: PendingInfoRequest };
private readonly requestUniqueIds: { [key: string]: PendingInfoRequest };
private executed: boolean;
constructor(handler: ConnectionHandler) {
this.handler = handler;
this.executed = false;
this.requestDatabaseIds = {};
this.requestUniqueIds = {};
}
private registerRequest(type: "database-id", key: number) : Promise<ClientInfoResult>;
private registerRequest(type: "unique-id", key: string) : Promise<ClientInfoResult>;
private registerRequest(type, key) : Promise<ClientInfoResult> {
let targetObject;
switch (type) {
case "database-id":
targetObject = this.requestDatabaseIds;
break;
case "unique-id":
targetObject = this.requestUniqueIds;
break;
default:
return;
}
if(this.executed) {
throw tr("request already executed");
}
if(typeof targetObject[key] === "undefined") {
const handle: PendingInfoRequest = {
fullFilled: false,
resolve: undefined,
promise: undefined,
};
handle.promise = new Promise<ClientInfoResult>(resolve => handle.resolve = resolve);
targetObject[key] = handle;
}
return targetObject[key].promise;
}
private fullFullAllRequests(type: "database-id" | "unique-id", result: ClientInfoResult) {
let targetObject;
switch (type) {
case "database-id":
targetObject = this.requestDatabaseIds;
break;
case "unique-id":
targetObject = this.requestUniqueIds;
break;
default:
return;
}
Object.keys(targetObject).forEach(key => {
if(targetObject[key].fullFilled) {
return;
}
targetObject[key].fullFilled = true;
targetObject[key].resolve(result);
});
}
private static parseClientInfo(json: any[]) : ClientInfo[] {
const result: ClientInfo[] = [];
let index = 0;
for(const entry of json) {
index++;
if(typeof entry["cluid"] === "undefined") {
logWarn(LogCategory.NETWORKING, tr("Missing client unique id in client info result bulk %d."), index);
continue;
}
if(typeof entry["clname"] === "undefined") {
logWarn(LogCategory.NETWORKING, tr("Missing client name in client info result bulk %d."), index);
continue;
}
if(typeof entry["cldbid"] === "undefined") {
logWarn(LogCategory.NETWORKING, tr("Missing client database id in client info result bulk %d."), index);
continue;
}
const databaseId = parseInt(entry["cldbid"]);
if(isNaN(databaseId)) {
logWarn(LogCategory.NETWORKING, tr("Client database id (%s) in client info isn't parse able as integer in bulk %d."), entry["cldbid"], index);
continue;
}
result.push({ clientName: entry["clname"], clientUniqueId: entry["cluid"], clientDatabaseId: databaseId });
}
return result;
}
getInfoByDatabaseId(databaseId: number) : Promise<ClientInfoResult> {
return this.registerRequest("database-id", databaseId);
}
getInfoByUniqueId(uniqueId: string) : Promise<ClientInfoResult> {
return this.registerRequest("unique-id", uniqueId);
}
async executeQueries() {
this.executed = true;
let promises = [];
let handlers = [];
try {
const requestDatabaseIds = Object.keys(this.requestDatabaseIds);
if(requestDatabaseIds.length > 0) {
handlers.push(this.handler.serverConnection.command_handler_boss().register_explicit_handler("notifyclientgetnamefromdbid", command => {
ClientInfoResolver.parseClientInfo(command.arguments).forEach(info => {
if(this.requestDatabaseIds[info.clientDatabaseId].fullFilled) {
return;
}
this.requestDatabaseIds[info.clientDatabaseId].fullFilled = true;
this.requestDatabaseIds[info.clientDatabaseId].resolve({
status: "success",
clientName: info.clientName,
clientDatabaseId: info.clientDatabaseId,
clientUniqueId: info.clientUniqueId
});
});
}));
promises.push(this.handler.serverConnection.send_command("clientgetnamefromdbid",
requestDatabaseIds.map(entry => ({ cldbid: entry })),
{
process_result: false,
}
).catch(error => {
if(error instanceof CommandResult) {
if(error.id === ErrorCode.DATABASE_EMPTY_RESULT) {
return;
}
error = error.formattedMessage();
} else if(typeof error !== "string") {
logError(LogCategory.NETWORKING, tr("Failed to resolve client info from database id: %o"), error);
error = tr("lookup the console");
}
this.fullFullAllRequests("database-id", {
status: "error",
error: error
});
}));
}
const requestUniqueIds = Object.keys(this.requestUniqueIds);
if(requestUniqueIds.length > 0) {
handlers.push(this.handler.serverConnection.command_handler_boss().register_explicit_handler("notifyclientnamefromuid", command => {
ClientInfoResolver.parseClientInfo(command.arguments).forEach(info => {
if(this.requestUniqueIds[info.clientUniqueId].fullFilled) {
return;
}
this.requestUniqueIds[info.clientUniqueId].fullFilled = true;
this.requestUniqueIds[info.clientUniqueId].resolve({
status: "success",
clientName: info.clientName,
clientDatabaseId: info.clientDatabaseId,
clientUniqueId: info.clientUniqueId
});
});
}));
promises.push(this.handler.serverConnection.send_command("clientgetnamefromuid",
requestUniqueIds.map(entry => ({ cluid: entry })),
{
process_result: false,
}
).catch(error => {
if(error instanceof CommandResult) {
if(error.id === ErrorCode.DATABASE_EMPTY_RESULT) {
return;
}
error = error.formattedMessage();
} else if(typeof error !== "string") {
logError(LogCategory.NETWORKING, tr("Failed to resolve client info from unique id: %o"), error);
error = tr("lookup the console");
}
this.fullFullAllRequests("unique-id", {
status: "error",
error: error
});
}));
}
await Promise.all(promises);
this.fullFullAllRequests("unique-id", { status: "not-found" });
this.fullFullAllRequests("database-id", { status: "not-found" });
} finally {
handlers.forEach(callback => callback());
this.fullFullAllRequests("unique-id", {
status: "error",
error: tr("request failed")
});
this.fullFullAllRequests("database-id", {
status: "error",
error: tr("request failed")
});
}
}
}

View File

@ -12,7 +12,6 @@ import * as htmltags from "../ui/htmltags";
import {CommandResult} from "../connection/ServerConnectionDeclaration";
import {ChannelEntry} from "./Channel";
import {ConnectionHandler, ViewReasonId} from "../ConnectionHandler";
import {createServerGroupAssignmentModal} from "../ui/modal/ModalGroupAssignment";
import {openClientInfo} from "../ui/modal/ModalClientInfo";
import {spawnBanClient} from "../ui/modal/ModalBanClient";
import {spawnChangeLatency} from "../ui/modal/ModalChangeLatency";
@ -31,6 +30,7 @@ import {VideoClient} from "tc-shared/connection/VideoConnection";
import { tr } from "tc-shared/i18n/localize";
import {EventClient} from "tc-shared/connectionlog/Definitions";
import {W2GPluginCmdHandler} from "tc-shared/ui/modal/video-viewer/W2GPlugin";
import {spawnServerGroupAssignments} from "tc-shared/ui/modal/group-assignment/Controller";
export enum ClientType {
CLIENT_VOICE,
@ -472,30 +472,7 @@ export class ClientEntry<Events extends ClientEvents = ClientEvents> extends Cha
}
open_assignment_modal() {
createServerGroupAssignmentModal(this as any, (groups, flag) => {
if(groups.length == 0) return Promise.resolve(true);
if(groups.length == 1) {
if(flag) {
return this.channelTree.client.serverConnection.send_command("servergroupaddclient", {
sgid: groups[0],
cldbid: this.properties.client_database_id
}).then(() => true);
} else
return this.channelTree.client.serverConnection.send_command("servergroupdelclient", {
sgid: groups[0],
cldbid: this.properties.client_database_id
}).then(() => true);
} else {
const data = groups.map(e => { return {sgid: e}; });
data[0]["cldbid"] = this.properties.client_database_id;
if(flag) {
return this.channelTree.client.serverConnection.send_command("clientaddservergroup", data, {flagset: ["continueonerror"]}).then(() => true);
} else
return this.channelTree.client.serverConnection.send_command("clientdelservergroup", data, {flagset: ["continueonerror"]}).then(() => true);
}
});
spawnServerGroupAssignments(this.channelTree.client, this.properties.client_database_id);
}
open_text_chat() {

View File

@ -0,0 +1,409 @@
import {Registry} from "tc-events";
import {
ModalClientGroupAssignmentEvents,
ModalClientGroupAssignmentVariables
} from "tc-shared/ui/modal/group-assignment/Definitions";
import {createIpcUiVariableProvider, IpcUiVariableProvider} from "tc-shared/ui/utils/IpcVariable";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {ClientInfoResolver, ClientInfoResult} from "tc-shared/connection/ClientInfo";
import {GroupManager, GroupType} from "tc-shared/permission/GroupManager";
import PermissionType from "tc-shared/permission/PermissionType";
import {spawnModal} from "tc-shared/ui/react-elements/modal";
import {Mutex} from "tc-shared/Mutex";
import {LogCategory, logError, logWarn} from "tc-shared/log";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import {ErrorCode} from "tc-shared/connection/ErrorCode";
import {ModalController} from "tc-shared/ui/react-elements/modal/Definitions";
type AssignedGroups = {
status: "unloaded"
} | {
status: "error",
error: string
} | {
status: "success",
groups: number[]
};
const generateUniqueId = (handler: ConnectionHandler, clientDatabaseId: number) => "server-group-assignments-" + handler.handlerId + " - " + clientDatabaseId;
class Controller {
readonly events: Registry<ModalClientGroupAssignmentEvents>;
readonly variables: IpcUiVariableProvider<ModalClientGroupAssignmentVariables>;
readonly handler: ConnectionHandler;
readonly clientDatabaseId: number;
private clientInfoTimeout: number;
private clientInfoPromise: Promise<ClientInfoResult>;
private registeredListener: (() => void)[];
private resendAvailableGroups = false;
private assignedGroups: Mutex<AssignedGroups>;
private assignmentLoadEnqueued = false;
constructor(handler: ConnectionHandler, clientDatabaseId: number) {
this.handler = handler;
this.clientDatabaseId = clientDatabaseId;
this.events = new Registry<ModalClientGroupAssignmentEvents>();
this.variables = createIpcUiVariableProvider();
this.assignedGroups = new Mutex<AssignedGroups>({ status: "unloaded" });
this.registeredListener = [];
this.variables.setVariableProvider("handlerId", () => this.handler.handlerId);
this.variables.setVariableProvider("availableGroups", () => {
this.resendAvailableGroups = false;
const addPermissions = this.handler.permissions.neededPermission(PermissionType.I_SERVER_GROUP_MEMBER_ADD_POWER);
const removePermissions = this.handler.permissions.neededPermission(PermissionType.I_SERVER_GROUP_MEMBER_REMOVE_POWER);
const serverGroups = this.handler.groups.serverGroups.slice(0).sort(GroupManager.sorter());
return {
defaultGroup: this.handler.channelTree.server.properties.virtualserver_default_server_group,
groups: serverGroups.filter(entry => entry.type === GroupType.NORMAL).map(entry => ({
name: entry.name,
groupId: entry.id,
saveDB: entry.properties.savedb,
icon: {
iconId: entry.properties.iconid,
serverUniqueId: this.handler.getCurrentServerUniqueId(),
handlerId: this.handler.handlerId
},
addAble: addPermissions.granted(entry.requiredMemberAddPower),
removeAble: removePermissions.granted(entry.requiredMemberRemovePower)
}))
}
});
this.variables.setVariableProviderAsync("targetClient", async () => {
const result = await this.getClientInfo();
switch (result.status) {
case "error":
return { status: "error", message: result.error };
case "not-found":
return { status: "error", message: tr("not found") };
case "success":
return {
status: "success",
clientName: result.clientName,
clientUniqueId: result.clientUniqueId,
clientDatabaseId: result.clientDatabaseId
};
default:
return { status: "error", message: tr("unknown status") };
}
});
this.variables.setVariableProviderAsync("assignedGroupStatus", async () => {
const result = await this.assignedGroups.tryExecute<ModalClientGroupAssignmentVariables["assignedGroupStatus"]>(value => {
switch (value.status) {
case "success":
return { status: "loaded", assignedGroups: value.groups.length };
case "unloaded":
return { status: "loading" };
case "error":
default:
return { status: "error", message: value.error || tr("invalid status") };
}
});
if(result.status === "would-block") {
return { status: "loading" };
} else {
return result.result;
}
});
this.variables.setVariableProviderAsync("groupAssigned", groupId => this.assignedGroups.execute(assignedGroups => {
switch (assignedGroups.status) {
case "success":
return assignedGroups.groups.indexOf(groupId) !== -1;
case "error":
case "unloaded":
default:
return false;
}
}));
this.variables.setVariableEditorAsync("groupAssigned", async (newValue, groupId) => this.assignedGroups.execute(async assignedGroups => {
if(assignedGroups.status !== "success") {
return false;
}
if((assignedGroups.groups.indexOf(groupId) === -1) !== newValue) {
/* No change to propagate but update the local value */
return true;
}
let command, action: "add" | "remove";
if(newValue) {
action = "add";
command = "servergroupaddclient";
} else {
action = "remove";
command = "servergroupdelclient";
}
try {
await this.handler.serverConnection.send_command(command, { sgid: groupId, cldbid: this.clientDatabaseId });
assignedGroups.groups.toggle(groupId, newValue);
this.variables.sendVariable("assignedGroupStatus");
} catch (error) {
if(error instanceof CommandResult) {
if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
this.events.fire("notify_toggle_result", {
action: action,
groupId: groupId,
groupName: "",
result: {
status: "no-permissions",
permission: this.handler.permissions.getFailedPermission(error)
}
});
return false;
} else {
this.events.fire("notify_toggle_result", {
action: action,
groupId: groupId,
groupName: "",
result: {
status: "error",
reason: error.formattedMessage()
}
});
return false;
}
}
logError(LogCategory.NETWORKING, tr("Failed to toggle client server group %d: %o"), groupId, error);
return false;
}
return true;
}));
this.events.on("action_remove_all", () => {
this.assignedGroups.execute(async value => {
let args = [];
switch (value.status) {
case "success":
if(value.groups.length === 0) {
return;
}
args = value.groups.map(entry => ({ sgid: entry }));
break;
default:
return;
}
args[0].cldbid = this.clientDatabaseId;
let result: CommandResult;
try {
result = await this.handler.serverConnection.send_command("servergroupdelclient", args);
} catch (error) {
if(error instanceof CommandResult) {
result = error;
} else {
logError(LogCategory.NETWORKING, tr("Failed to remote all server groups from target client: %o"), error);
return;
}
}
const bulks = result.getBulks();
if(bulks.length !== args.length) {
if(!result.success) {
/* result.bulks.length must be one then */
logError(LogCategory.NETWORKING, tr("Failed to remote all server groups from target client: %o"), result.formattedMessage());
return;
} else {
/* Server does not send a bulked response. We've to do a full refresh */
this.events.fire("action_refresh", { slowMode: false });
}
} else {
let statusUpdated = false;
for(let index = 0; index < args.length; index++) {
if(!bulks[index].success) {
continue;
}
statusUpdated = true;
value.groups.remove(args[index].sgid);
}
if(statusUpdated) {
this.variables.sendVariable("assignedGroupStatus");
this.sendAllGroupStatus();
}
}
}).then(undefined);
});
this.events.on("action_refresh", event => {
this.loadAssignedGroups(event.slowMode);
this.variables.sendVariable("assignedGroupStatus");
});
this.registeredListener.push(
handler.groups.events.on(["notify_groups_created", "notify_groups_deleted", "notify_groups_received", "notify_groups_updated", "notify_reset"], () => this.enqueueGroupResend())
);
this.registeredListener.push(handler.permissions.register_needed_permission(PermissionType.I_SERVER_GROUP_MEMBER_ADD_POWER, () => this.enqueueGroupResend()));
this.registeredListener.push(handler.permissions.register_needed_permission(PermissionType.I_SERVER_GROUP_MEMBER_REMOVE_POWER, () => this.enqueueGroupResend()));
this.loadAssignedGroups(false);
}
destroy() {
this.registeredListener.forEach(callback => callback());
this.registeredListener = [];
this.events.destroy();
this.variables.destroy();
}
private sendAllGroupStatus() {
this.variables.getVariable("availableGroups", undefined).then(groups => {
groups.groups.forEach(group => this.variables.sendVariable("groupAssigned", group.groupId));
});
}
private loadAssignedGroups(slowMode: boolean) {
if(this.assignmentLoadEnqueued) {
return;
}
this.assignmentLoadEnqueued = true;
this.assignedGroups.execute(async (assignedGroups, setAssignedGroups) => {
if(slowMode) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
this.assignmentLoadEnqueued = false;
let resultSet = false;
const unregisterCallback = this.handler.serverConnection.command_handler_boss().register_explicit_handler("notifyservergroupsbyclientid", command => {
const payload = command.arguments;
const clientId = parseInt(payload[0].cldbid);
if(isNaN(clientId) || clientId !== this.clientDatabaseId) {
return;
}
let groups = [];
for(const entry of payload) {
const groupId = parseInt(entry.sgid);
if(isNaN(groupId)) {
logWarn(LogCategory.NETWORKING, tr("Failed to parse sgid as integer of server groups by client id response (%o)."), entry);
continue;
}
groups.push(groupId);
}
resultSet = true;
setAssignedGroups({ status: "success", groups: groups });
});
try {
await this.handler.serverConnection.send_command("servergroupsbyclientid", { cldbid: this.clientDatabaseId });
if(!resultSet) {
setAssignedGroups({ status: "error", error: tr("missing result") });
}
} catch (error) {
if(!resultSet) {
if(error instanceof CommandResult) {
if(error.id === ErrorCode.DATABASE_EMPTY_RESULT) {
setAssignedGroups({ status: "success", groups: [] });
} else if(error.id === ErrorCode.CLIENT_INVALID_ID) {
setAssignedGroups({ status: "error", error: tr("invalid client id") });
} else {
setAssignedGroups({ status: "error", error: error.formattedMessage() });
}
} else {
if(typeof error !== "string") {
logError(LogCategory.NETWORKING, tr("Failed to query client server groups: %o"), error);
error = tr("lookup the console");
}
setAssignedGroups({ status: "error", error: error });
}
}
} finally {
unregisterCallback();
}
}).then(() => {
this.variables.sendVariable("assignedGroupStatus");
this.sendAllGroupStatus();
});
}
private enqueueGroupResend() {
if(this.resendAvailableGroups) {
return;
}
this.resendAvailableGroups = true;
this.variables.sendVariable("availableGroups");
}
private getClientInfo() : Promise<ClientInfoResult> {
if(typeof this.clientInfoTimeout === "number" && Date.now() < this.clientInfoTimeout) {
return this.clientInfoPromise;
}
this.clientInfoTimeout = Date.now() + 5000;
return (this.clientInfoPromise = new Promise<ClientInfoResult>(resolve => {
const resolver = new ClientInfoResolver(this.handler);
resolver.getInfoByDatabaseId(this.clientDatabaseId).then(result => {
resolve(result);
switch (result.status) {
case "success":
case "not-found":
this.clientInfoTimeout = Date.now() + 60 * 1000;
break;
case "error":
default:
this.clientInfoTimeout = Date.now() + 5 * 1000;
break;
}
});
resolver.executeQueries().then(undefined);
}));
}
}
let controllerInstances: {[key: string]: ModalController} = {};
export function spawnServerGroupAssignments(handler: ConnectionHandler, targetClientDatabaseId: number) {
const uniqueId = generateUniqueId(handler, targetClientDatabaseId);
if(typeof controllerInstances[uniqueId] !== "undefined") {
controllerInstances[uniqueId].show().then(undefined);
return;
}
const controller = new Controller(handler, targetClientDatabaseId);
const modal = spawnModal("modal-assign-server-groups", [
controller.events.generateIpcDescription(),
controller.variables.generateConsumerDescription()
], {
popoutable: true,
uniqueId: uniqueId
});
controller.events.on("action_close", () => modal.destroy());
modal.getEvents().on("destroy", () => {
delete controllerInstances[uniqueId];
controller.destroy();
});
modal.show().then(undefined);
controllerInstances[uniqueId] = modal;
}

View File

@ -0,0 +1,57 @@
import {RemoteIconInfo} from "tc-shared/file/Icons";
export type AvailableGroup = {
groupId: number,
saveDB: boolean,
name: string,
icon: RemoteIconInfo | undefined,
addAble: boolean,
removeAble: boolean,
}
export type ClientInfo = {
status: "success",
clientDatabaseId: number,
clientUniqueId: string,
clientName: string
} | {
status: "error",
message: string
};
export interface ModalClientGroupAssignmentVariables {
readonly handlerId: string,
readonly targetClient: ClientInfo,
readonly availableGroups: {
groups: AvailableGroup[],
defaultGroup: number
},
readonly assignedGroupStatus: { status: "loaded", assignedGroups: number } | { status: "loading" } | { status: "error", message: string };
groupAssigned: boolean,
}
export interface ModalClientGroupAssignmentEvents {
action_close: {},
action_remove_all: {},
action_refresh: { slowMode: boolean },
notify_toggle_result: {
action: "add" | "remove",
groupId: number,
groupName: string,
result: {
status: "success"
} | {
status: "error",
reason: string
} | {
status: "no-permissions",
permission: string
},
}
}

View File

@ -0,0 +1,172 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
.container {
user-select: none;
min-width: 20em;
width: 30em;
min-height: 10em;
max-height: calc(100vh - 10rem);
display: flex;
flex-direction: column;
justify-content: stretch;
background-color: #2f2f35;
padding: .5em;
.title {
display: flex;
flex-direction: row;
justify-content: stretch;
.clientInfo {
flex-grow: 1;
flex-shrink: 1;
min-width: 4em;
margin-bottom: .25em;
.textError {
color: #cc0000;
}
}
.refreshButton {
flex-grow: 0;
flex-shrink: 0;
}
}
.containerButtons {
flex-grow: 0;
flex-shrink: 0;
padding-top: 1em;
display: flex;
flex-direction: row;
justify-content: space-between;
}
&.windowed {
width: 100%;
height: 100%;
max-height: 100%;
}
}
.assignmentList {
flex-shrink: 1;
flex-grow: 1;
min-height: 4em;
height: 20em;
padding: 3px;
overflow-y: auto;
border: 1px #161616 solid;
border-radius: $border_radius_middle;
background-color: #28292b;
position: relative;
@include chat-scrollbar-vertical();
.entry {
flex-shrink: 0;
flex-grow: 0;
display: flex;
flex-direction: row;
justify-content: stretch;
height: 1.75em;
> * {
flex-shrink: 0;
flex-grow: 0;
align-self: center;
}
.checkbox {
margin-right: .5em;
}
.icon {
margin-right: .25em;
}
.name {
flex-shrink: 1;
flex-grow: 1;
min-width: 6em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
.text {
align-self: center;
text-align: center;
color: #666;
font-size: 1.2em;
&.error {
color: #a32929;
}
}
}
.iconContainer {
align-self: center;
margin-right: 4px;
margin-left: 2px;
margin-top: -2px;
}
a {
align-self: center;
}
}
.button {
display: flex;
flex-direction: column;
justify-content: center;
align-self: center;
cursor: pointer;
padding: .2em;
border-radius: .2em;
transition: all ease-in-out $button_hover_animation_time;
&:hover {
background-color: #0000004f;
}
}

View File

@ -0,0 +1,222 @@
import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions";
import React, {useContext} from "react";
import {IpcRegistryDescription, Registry} from "tc-events";
import {
AvailableGroup,
ModalClientGroupAssignmentEvents,
ModalClientGroupAssignmentVariables
} from "tc-shared/ui/modal/group-assignment/Definitions";
import {createIpcUiVariableConsumer, IpcVariableDescriptor} from "tc-shared/ui/utils/IpcVariable";
import {UiVariableConsumer} from "tc-shared/ui/utils/Variable";
import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n";
import {ClientTag} from "tc-shared/ui/tree/EntryTags";
import {Checkbox} from "tc-shared/ui/react-elements/Checkbox";
import {RemoteIconInfoRenderer} from "tc-shared/ui/react-elements/Icon";
import {Button} from "tc-shared/ui/react-elements/Button";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
import {ClientIcon} from "svg-sprites/client-icons";
const cssStyle = require("./Renderer.scss");
const VariablesContext = React.createContext<UiVariableConsumer<ModalClientGroupAssignmentVariables>>(undefined);
const EventsContext = React.createContext<Registry<ModalClientGroupAssignmentEvents>>(undefined);
const GroupEntryRenderer = React.memo((props: { entry : AvailableGroup, defaultGroup: boolean, defaultGroupActive: boolean }) => {
const variables = useContext(VariablesContext);
const assigned = variables.useVariable("groupAssigned", props.entry.groupId, false);
let disabled = false;
if(assigned.status === "applying") {
disabled = true;
} else if(assigned.localValue ? !props.entry.removeAble : !props.entry.addAble) {
disabled = true;
} else if(props.defaultGroup && !assigned.localValue) {
/* Only disable it if we haven't the group assigned in order to remove the assignment even though the groups is the default group */
disabled = true;
}
let nameSuffix;
if(props.defaultGroup) {
nameSuffix = " [default]";
} else if(!props.entry.saveDB) {
nameSuffix = " [temp]";
}
return (
<div className={cssStyle.entry}>
<Checkbox
value={(props.defaultGroup && props.defaultGroupActive) || assigned.localValue}
onChange={value => assigned.setValue(value)}
disabled={disabled}
className={cssStyle.checkbox}
/>
<RemoteIconInfoRenderer icon={props.entry.icon} className={cssStyle.icon} />
<div className={cssStyle.name}>{props.entry.name}{nameSuffix}</div>
</div>
);
});
const GroupListRenderer = React.memo(() => {
const variables = useContext(VariablesContext);
const serverGroups = variables.useReadOnly("availableGroups", undefined, { defaultGroup: -1, groups: [] });
const assignmentStatus = variables.useReadOnly("assignedGroupStatus", undefined, { status: "loading" });
let body;
if(assignmentStatus.status === "loaded") {
body = serverGroups.groups.map(entry => (
<GroupEntryRenderer
entry={entry} key={"group-" + entry.groupId}
defaultGroup={serverGroups.defaultGroup === entry.groupId}
defaultGroupActive={assignmentStatus.assignedGroups === 0}
/>
));
} else if(assignmentStatus.status === "loading") {
body = (
<div className={cssStyle.overlay} key={"loading"}>
<div className={cssStyle.text}>
<Translatable>loading</Translatable> <LoadingDots />
</div>
</div>
);
} else if(assignmentStatus.status === "error") {
body = (
<div className={cssStyle.overlay} key={"error"}>
<div className={cssStyle.text + " " + cssStyle.error}>
<Translatable>An error occurred:</Translatable><br />
{assignmentStatus.message}
</div>
</div>
);
}
return (
<div className={cssStyle.assignmentList}>
{body}
</div>
);
});
const Buttons = React.memo(() => {
const events = useContext(EventsContext);
const variables = useContext(VariablesContext);
const assignmentStatus = variables.useReadOnly("assignedGroupStatus", undefined, { status: "loading" });
return (
<div className={cssStyle.containerButtons}>
<Button
color={"red"}
onClick={() => events.fire("action_remove_all")}
disabled={assignmentStatus.status === "loaded" ? assignmentStatus.assignedGroups === 0 : true}
>
<Translatable>Remove all groups</Translatable>
</Button>
<Button color={"green"} onClick={() => events.fire("action_close")}>
<Translatable>Close</Translatable>
</Button>
</div>
)
});
const ClientInfoRenderer = React.memo(() => {
const variables = useContext(VariablesContext);
const handlerId = variables.useReadOnly("handlerId", undefined, undefined);
const user = variables.useReadOnly("targetClient");
let inner;
if(handlerId && user.status === "loaded") {
let clientTag;
if(user.value.status === "success") {
clientTag = <ClientTag key={"client-tag"} clientName={user.value.clientName} clientUniqueId={user.value.clientUniqueId} handlerId={handlerId} />;
} else {
clientTag = <div key={"error"} className={cssStyle.textError}>{user.value.message}</div>;
}
inner = (
<VariadicTranslatable text={"Changing groups of {}"} key={"user-specific"}>
{clientTag}
</VariadicTranslatable>
);
} else {
inner = <Translatable key={"generic"}>Change server groups</Translatable>;
}
return (
<div className={cssStyle.clientInfo}>
{inner}
</div>
);
});
const RefreshButton = React.memo(() => {
const events = useContext(EventsContext);
return (
<div className={cssStyle.refreshButton + " " + cssStyle.button} onClick={() => events.fire("action_refresh", { slowMode: true })}>
<ClientIconRenderer icon={ClientIcon.Refresh} />
</div>
)
})
const TitleRenderer = React.memo(() => {
const variables = useContext(VariablesContext);
const client = variables.useReadOnly("targetClient");
const handlerId = variables.useReadOnly("handlerId", undefined, undefined);
if(client.status === "loaded" && client.value.status === "success" && handlerId) {
return (
<VariadicTranslatable text={"Server group assignments for {}"} key={"client-known"}>
<ClientTag
handlerId={handlerId}
clientName={client.value.clientName}
clientUniqueId={client.value.clientUniqueId}
clientDatabaseId={client.value.clientDatabaseId}
style={"text-only"}
/>
</VariadicTranslatable>
);
} else {
return <Translatable key={"unknown"}>Server group assignments</Translatable>;
}
});
export default class ModalServerGroups extends AbstractModal {
private readonly events: Registry<ModalClientGroupAssignmentEvents>;
private readonly variables: UiVariableConsumer<ModalClientGroupAssignmentVariables>;
constructor(events: IpcRegistryDescription<ModalClientGroupAssignmentEvents>, variables: IpcVariableDescriptor<ModalClientGroupAssignmentVariables>) {
super();
this.events = Registry.fromIpcDescription(events);
this.variables = createIpcUiVariableConsumer(variables);
}
protected onDestroy() {
super.onDestroy();
this.events.destroy();
this.variables.destroy();
}
renderBody(): React.ReactElement {
return (
<EventsContext.Provider value={this.events}>
<VariablesContext.Provider value={this.variables}>
<div className={cssStyle.container + " " + (this.properties.windowed ? cssStyle.windowed : "")}>
<div className={cssStyle.title}>
<ClientInfoRenderer />
<RefreshButton />
</div>
<GroupListRenderer />
<Buttons />
</div>
</VariablesContext.Provider>
</EventsContext.Provider>
);
}
renderTitle(): string | React.ReactElement {
return (
<VariablesContext.Provider value={this.variables}>
<TitleRenderer />
</VariablesContext.Provider>
)
}
}

View File

@ -11,6 +11,7 @@ export interface CheckboxProperties {
value?: boolean;
initialValue?: boolean;
className?: string;
children?: never;
}
@ -32,7 +33,7 @@ export class Checkbox extends React.Component<CheckboxProperties, CheckboxState>
const disabledClass = disabled ? cssStyle.disabled : "";
return (
<label className={cssStyle.labelCheckbox + " " + disabledClass}>
<label className={cssStyle.labelCheckbox + " " + disabledClass + " " + this.props.className}>
<div className={cssStyle.checkbox + " " + disabledClass}>
<input type={"checkbox"} checked={checked} disabled={disabled} onChange={event => this.onStateChange(event)} />
<div className={cssStyle.mark} />

View File

@ -1,9 +1,11 @@
import * as React from "react";
import {useState} from "react";
import {RemoteIcon} from "tc-shared/file/Icons";
import {getIconManager, RemoteIcon, RemoteIconInfo} from "tc-shared/file/Icons";
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 IconRenderer = (props: {
icon: string;
title?: string;
@ -18,7 +20,7 @@ export const IconRenderer = (props: {
}
}
export const RemoteIconRenderer = (props: { icon: RemoteIcon, className?: string, title?: string }) => {
export const RemoteIconRenderer = (props: { icon: RemoteIcon | undefined, className?: string, title?: string }) => {
const [ revision, setRevision ] = useState(0);
props.icon.events.reactUse("notify_state_changed", () => setRevision(revision + 1));
@ -51,4 +53,12 @@ export const RemoteIconRenderer = (props: { icon: RemoteIcon, className?: string
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"} />;
} else {
return <RemoteIconRenderer icon={getIconManager().resolveIconInfo(props.icon)} className={props.className} title={props.title} key={"icon"} />;
}
});

View File

@ -1,5 +1,4 @@
import {IpcRegistryDescription, Registry} from "tc-shared/events";
import {VideoViewerEvents} from "tc-shared/video-viewer/Definitions";
import {ChannelEditEvents} from "tc-shared/ui/modal/channel-edit/Definitions";
import {EchoTestEvents} from "tc-shared/ui/modal/echo-test/Definitions";
import {ModalGlobalSettingsEditorEvents} from "tc-shared/ui/modal/global-settings-editor/Definitions";
@ -12,6 +11,11 @@ import {
ModalBookmarksAddServerVariables
} from "tc-shared/ui/modal/bookmarks-add-server/Definitions";
import {ModalPokeEvents, ModalPokeVariables} from "tc-shared/ui/modal/poke/Definitions";
import {
ModalClientGroupAssignmentEvents,
ModalClientGroupAssignmentVariables
} from "tc-shared/ui/modal/group-assignment/Definitions";
import {VideoViewerEvents} from "tc-shared/ui/modal/video-viewer/Definitions";
export type ModalType = "error" | "warning" | "info" | "none";
export type ModalRenderType = "page" | "dialog";
@ -188,4 +192,8 @@ export interface ModalConstructorArguments {
/* events */ IpcRegistryDescription<ModalPokeEvents>,
/* variables */ IpcVariableDescriptor<ModalPokeVariables>,
],
"modal-assign-server-groups": [
/* events */ IpcRegistryDescription<ModalClientGroupAssignmentEvents>,
/* variables */ IpcVariableDescriptor<ModalClientGroupAssignmentVariables>,
],
}

View File

@ -91,3 +91,10 @@ registerModal({
popoutSupported: true
});
registerModal({
modalId: "modal-assign-server-groups",
classLoader: async () => await import("tc-shared/ui/modal/group-assignment/Renderer"),
popoutSupported: true
});

View File

@ -2,7 +2,6 @@ import * as React from "react";
import * as loader from "tc-loader";
import {Stage} from "tc-loader";
import {getIpcInstance, IPCChannel} from "tc-shared/ipc/BrowserIPC";
import {AppParameters} from "tc-shared/settings";
import {generateDragElement, setupDragData} from "tc-shared/ui/tree/DragHelper";
import {ClientIcon} from "svg-sprites/client-icons";
@ -11,6 +10,7 @@ const cssStyle = require("./EntryTags.scss");
let ipcChannel: IPCChannel;
type EntryTagStyle = "text-only" | "normal";
export const ServerTag = React.memo((props: {
serverName: string,
handlerId: string,
@ -44,42 +44,56 @@ export const ClientTag = React.memo((props: {
handlerId: string,
clientId?: number,
clientDatabaseId?: number,
className?: string
}) => (
<div className={cssStyle.tag + (props.className ? ` ${props.className}` : ``)}
onContextMenu={event => {
event.preventDefault();
className?: string,
ipcChannel.sendMessage("contextmenu-client", {
clientUniqueId: props.clientUniqueId,
handlerId: props.handlerId,
clientId: props.clientId,
clientDatabaseId: props.clientDatabaseId,
style?: EntryTagStyle
}) => {
let style = props.style || "normal";
if(style === "text-only") {
return <React.Fragment key={"text-only"}>{props.clientName}</React.Fragment>;
}
pageX: event.pageX,
pageY: event.pageY
});
}}
draggable={true}
onDragStart={event => {
/* clients only => move */
event.dataTransfer.effectAllowed = "move"; /* prohibit copying */
event.dataTransfer.dropEffect = "move";
event.dataTransfer.setDragImage(generateDragElement([{ icon: ClientIcon.PlayerOn, name: props.clientName }]), 0, 6);
setupDragData(event.dataTransfer, props.handlerId, [
{
type: "client",
clientUniqueId: props.clientUniqueId,
clientId: props.clientId,
clientDatabaseId: props.clientDatabaseId
}
], "client");
event.dataTransfer.setData("text/plain", props.clientName);
}}
>
{props.clientName}
</div>
));
return (
<div
key={"normal"}
className={cssStyle.tag + (props.className ? ` ${props.className}` : ``)}
onContextMenu={event => {
event.preventDefault();
ipcChannel.sendMessage("contextmenu-client", {
clientUniqueId: props.clientUniqueId,
handlerId: props.handlerId,
clientId: props.clientId,
clientDatabaseId: props.clientDatabaseId,
pageX: event.pageX,
pageY: event.pageY
});
}}
draggable={true}
onDragStart={event => {
/* clients only => move */
event.dataTransfer.effectAllowed = "move"; /* prohibit copying */
event.dataTransfer.dropEffect = "move";
event.dataTransfer.setDragImage(generateDragElement([{
icon: ClientIcon.PlayerOn,
name: props.clientName
}]), 0, 6);
setupDragData(event.dataTransfer, props.handlerId, [
{
type: "client",
clientUniqueId: props.clientUniqueId,
clientId: props.clientId,
clientDatabaseId: props.clientDatabaseId
}
], "client");
event.dataTransfer.setData("text/plain", props.clientName);
}}
>
{props.clientName}
</div>
);
});
export const ChannelTag = React.memo((props: {
channelName: string,

View File

@ -46,6 +46,10 @@ export abstract class UiVariableProvider<Variables extends UiVariableMap> {
this.variableProvider[variable as any] = provider;
}
setVariableProviderAsync<T extends keyof Variables>(variable: T, provider: (customData: any) => Promise<Variables[T]>) {
this.variableProvider[variable as any] = provider;
}
/**
* @param variable
* @param editor If the editor returns `false` or a new variable, such variable will be used