TeaWeb/shared/js/ui/tree/Client.tsx

432 lines
16 KiB
TypeScript

import {
BatchUpdateAssignment,
BatchUpdateType,
ReactComponentBase
} from "tc-shared/ui/react-elements/ReactComponentBase";
import * as React from "react";
import {
ClientEntry as ClientEntryController,
ClientEvents,
ClientProperties,
ClientType,
LocalClientEntry, MusicClientEntry
} from "../client";
import {EventHandler, ReactEventHandler} from "tc-shared/events";
import {Group, GroupEvents} from "tc-shared/permission/GroupManager";
import {Settings, settings} from "tc-shared/settings";
import {TreeEntry, UnreadMarker} from "tc-shared/ui/tree/TreeEntry";
import {LocalIconRenderer} from "tc-shared/ui/react-elements/Icon";
import * as DOMPurify from "dompurify";
const clientStyle = require("./Client.scss");
const viewStyle = require("./View.scss");
interface ClientIconProperties {
client: ClientEntryController;
}
@ReactEventHandler<ClientSpeakIcon>(e => e.props.client.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ClientSpeakIcon extends ReactComponentBase<ClientIconProperties, {}> {
private static readonly IconUpdateKeys: (keyof ClientProperties)[] = [
"client_away",
"client_input_hardware",
"client_output_hardware",
"client_output_muted",
"client_input_muted",
"client_is_channel_commander",
"client_talk_power"
];
render() {
let icon: string = "";
let clicon: string = "";
const client = this.props.client;
const properties = client.properties;
if(properties.client_type_exact == ClientType.CLIENT_QUERY) {
icon = "client-server_query";
} else {
if (properties.client_away) {
icon = "client-away";
} else if (!client.get_audio_handle() && !(this instanceof LocalClientEntry)) {
icon = "client-input_muted_local";
} else if(!properties.client_output_hardware) {
icon = "client-hardware_output_muted";
} else if(properties.client_output_muted) {
icon = "client-output_muted";
} else if(!properties.client_input_hardware) {
icon = "client-hardware_input_muted";
} else if(properties.client_input_muted) {
icon = "client-input_muted";
} else {
if(client.isSpeaking()) {
if(properties.client_is_channel_commander)
clicon = "client_cc_talk";
else
clicon = "client_talk";
} else {
if(properties.client_is_channel_commander)
clicon = "client_cc_idle";
else
clicon = "client_idle";
}
}
}
if(clicon.length > 0)
return <div className={"clicon " + clicon} />;
else if(icon.length > 0)
return <div className={"icon " + icon} />;
else
return null;
}
@EventHandler<ClientEvents>("notify_properties_updated")
private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) {
for(const key of ClientSpeakIcon.IconUpdateKeys)
if(key in event.updated_properties) {
this.forceUpdate();
return;
}
}
@EventHandler<ClientEvents>("notify_mute_state_change")
private handleMuteStateChange() {
this.forceUpdate();
}
@EventHandler<ClientEvents>("notify_speak_state_change")
private handleSpeakStateChange() {
this.forceUpdate();
}
}
interface ClientServerGroupIconsProperties {
client: ClientEntryController;
}
@ReactEventHandler<ClientServerGroupIcons>(e => e.props.client.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ClientServerGroupIcons extends ReactComponentBase<ClientServerGroupIconsProperties, {}> {
private subscribed_groups: Group[] = [];
private group_updated_callback;
protected initialize() {
this.group_updated_callback = (event: GroupEvents["notify_properties_updated"]) => {
if(typeof event.updated_properties.iconid !== "undefined" || typeof event.updated_properties.sortid !== "undefined")
this.forceUpdate();
};
}
private unsubscribeGroupEvents() {
this.subscribed_groups.forEach(e => e.events.off("notify_properties_updated", this.group_updated_callback));
this.subscribed_groups = [];
}
componentWillUnmount(): void {
this.unsubscribeGroupEvents();
}
render() {
this.unsubscribeGroupEvents();
const groups = this.props.client.assignedServerGroupIds()
.map(e => this.props.client.channelTree.client.groups.serverGroup(e)).filter(e => !!e);
if(groups.length === 0) return null;
groups.forEach(e => {
e.events.on("notify_properties_updated", this.group_updated_callback);
this.subscribed_groups.push(e);
});
const group_icons = groups.filter(e => e?.properties.iconid)
.sort((a, b) => a.properties.sortid - b.properties.sortid);
if(group_icons.length === 0) return null;
return [
group_icons.map(e => {
return <LocalIconRenderer key={"group-icon-" + e.id} icon={this.props.client.channelTree.client.fileManager.icons.load_icon(e.properties.iconid)} />;
})
];
}
@EventHandler<ClientEvents>("notify_properties_updated")
private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) {
if(typeof event.updated_properties.client_servergroups)
this.forceUpdate();
}
}
interface ClientChannelGroupIconProperties {
client: ClientEntryController;
}
@ReactEventHandler<ClientChannelGroupIcon>(e => e.props.client.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ClientChannelGroupIcon extends ReactComponentBase<ClientChannelGroupIconProperties, {}> {
private subscribed_group: Group | undefined;
private group_updated_callback;
protected initialize() {
this.group_updated_callback = (event: GroupEvents["notify_properties_updated"]) => {
if(typeof event.updated_properties.iconid !== "undefined" || typeof event.updated_properties.sortid !== "undefined")
this.forceUpdate();
};
}
private unsubscribeGroupEvent() {
this.subscribed_group?.events.off("notify_properties_updated", this.group_updated_callback);
}
componentWillUnmount(): void {
this.unsubscribeGroupEvent();
}
render() {
this.unsubscribeGroupEvent();
const cgid = this.props.client.assignedChannelGroup();
if(cgid === 0) return null;
const channel_group = this.props.client.channelTree.client.groups.channelGroup(cgid);
if(!channel_group) return null;
channel_group.events.on("notify_properties_updated", this.group_updated_callback);
this.subscribed_group = channel_group;
if(channel_group.properties.iconid === 0) return null;
return <LocalIconRenderer key={"cg-icon"} icon={this.props.client.channelTree.client.fileManager.icons.load_icon(channel_group.properties.iconid)} />;
}
@EventHandler<ClientEvents>("notify_properties_updated")
private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) {
if(typeof event.updated_properties.client_servergroups)
this.forceUpdate();
}
}
interface ClientIconsProperties {
client: ClientEntryController;
}
@ReactEventHandler<ClientIcons>(e => e.props.client.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ClientIcons extends ReactComponentBase<ClientIconsProperties, {}> {
render() {
const icons = [];
const talk_power = this.props.client.properties.client_talk_power;
const needed_talk_power = this.props.client.currentChannel()?.properties.channel_needed_talk_power || 0;
if(talk_power !== -1 && needed_talk_power !== 0 && needed_talk_power > talk_power)
icons.push(<div key={"muted"} className={"icon icon_talk_power client-input_muted"} />);
icons.push(<ClientServerGroupIcons key={"sg-icons"} client={this.props.client} />);
icons.push(<ClientChannelGroupIcon key={"channel-icons"} client={this.props.client} />);
if(this.props.client.properties.client_icon_id !== 0)
icons.push(<LocalIconRenderer key={"client-icon"} icon={this.props.client.channelTree.client.fileManager.icons.load_icon(this.props.client.properties.client_icon_id)} />);
return (
<div className={clientStyle.containerIcons}>
{icons}
</div>
)
}
@EventHandler<ClientEvents>("notify_properties_updated")
private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) {
if(typeof event.updated_properties.client_channel_group_id !== "undefined" || typeof event.updated_properties.client_talk_power !== "undefined" || typeof event.updated_properties.client_icon_id !== "undefined")
this.forceUpdate();
}
}
interface ClientNameProperties {
client: ClientEntryController;
}
interface ClientNameState {
group_prefix: string;
group_suffix: string;
away_message: string;
}
/* group prefix & suffix, away message */
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ClientName extends ReactComponentBase<ClientNameProperties, ClientNameState> {
protected defaultState(): ClientNameState {
return {
group_prefix: "",
away_message: "",
group_suffix: ""
}
}
render() {
return <div className={this.classList(clientStyle.clientName, this.props.client instanceof LocalClientEntry && clientStyle.clientNameOwn)}>
{this.state.group_prefix + this.props.client.clientNickName() + this.state.group_suffix + this.state.away_message}
</div>
}
@EventHandler<ClientEvents>("notify_properties_updated")
private handlePropertiesChanged(event: ClientEvents["notify_properties_updated"]) {
if(typeof event.updated_properties.client_away !== "undefined" || typeof event.updated_properties.client_away_message !== "undefined") {
this.setState({
away_message: event.client_properties.client_away_message && " [" + event.client_properties.client_away_message + "]"
});
}
if(typeof event.updated_properties.client_servergroups !== "undefined" || typeof event.updated_properties.client_channel_group_id !== "undefined") {
let prefix_groups: string[] = [];
let suffix_groups: string[] = [];
for(const group_id of this.props.client.assignedServerGroupIds()) {
const group = this.props.client.channelTree.client.groups.serverGroup(group_id);
if(!group) continue;
if(group.properties.namemode == 1)
prefix_groups.push(group.name);
else if(group.properties.namemode == 2)
suffix_groups.push(group.name);
}
const channel_group = this.props.client.channelTree.client.groups.channelGroup(this.props.client.assignedChannelGroup());
if(channel_group) {
if(channel_group.properties.namemode == 1)
prefix_groups.push(channel_group.name);
else if(channel_group.properties.namemode == 2)
suffix_groups.splice(0, 0, channel_group.name);
}
this.setState({
group_suffix: suffix_groups.map(e => "[" + e + "]").join(""),
group_prefix: prefix_groups.map(e => "[" + e + "]").join("")
})
}
}
}
interface ClientNameEditProps {
editFinished: (new_name?: string) => void;
initialName: string;
}
class ClientNameEdit extends ReactComponentBase<ClientNameEditProps, {}> {
private readonly ref_div: React.RefObject<HTMLDivElement> = React.createRef();
componentDidMount(): void {
this.ref_div.current.focus();
}
render() {
return <div
className={this.classList(clientStyle.clientName, clientStyle.edit)}
contentEditable={true}
ref={this.ref_div}
dangerouslySetInnerHTML={{__html: DOMPurify.sanitize(this.props.initialName)}}
onBlur={e => this.onBlur()}
onKeyPress={e => this.onKeyPress(e as any)}
/>
}
private onBlur() {
this.props.editFinished(this.ref_div.current.textContent);
}
private onKeyPress(event: KeyboardEvent) {
if(event.key === "Enter") {
event.preventDefault();
this.onBlur();
}
}
}
export interface ClientEntryProperties {
client: ClientEntryController;
depth: number;
offset: number;
}
export interface ClientEntryState {
rename: boolean;
renameInitialName?: string;
}
@ReactEventHandler<ClientEntry>(e => e.props.client.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
export class ClientEntry extends TreeEntry<ClientEntryProperties, ClientEntryState> {
shouldComponentUpdate(nextProps: Readonly<ClientEntryProperties>, nextState: Readonly<ClientEntryState>, nextContext: any): boolean {
return nextState.rename !== this.state.rename ||
nextProps.offset !== this.props.offset ||
nextProps.client !== this.props.client ||
nextProps.depth !== this.props.depth;
}
render() {
return (
<div className={this.classList(clientStyle.clientEntry, viewStyle.treeEntry, this.props.client.isSelected() && viewStyle.selected)}
style={{ paddingLeft: (this.props.depth * 16) + "px", top: this.props.offset }}
onDoubleClick={() => this.onDoubleClick()}
onMouseDown={e => this.onMouseDown(e as any)}
onContextMenu={e => this.onContextMenu(e as any)}
>
<UnreadMarker entry={this.props.client} />
<ClientSpeakIcon client={this.props.client} />
{this.state.rename ?
<ClientNameEdit key={"rename"} editFinished={name => this.onEditFinished(name)} initialName={this.state.renameInitialName || this.props.client.properties.client_nickname} /> :
[<ClientName key={"name"} client={this.props.client} />, <ClientIcons key={"icons"} client={this.props.client} />] }
</div>
)
}
private onDoubleClick() {
const client = this.props.client;
if(client.channelTree.selection.is_multi_select()) return;
if(this.props.client instanceof LocalClientEntry) {
this.props.client.openRename();
} else if(this.props.client instanceof MusicClientEntry) {
/* no action defined yet */
} else {
this.props.client.open_text_chat();
}
}
private onEditFinished(new_name?: string) {
if(!(this.props.client instanceof LocalClientEntry))
throw "Only local clients could be renamed";
if(new_name && new_name !== this.state.renameInitialName) {
const client = this.props.client;
client.renameSelf(new_name).then(result => {
if(!result)
this.setState({ rename: true, renameInitialName: new_name }); //TODO: Keep last name?
});
}
this.setState({ rename: false });
}
private onMouseDown(event: MouseEvent) {
if(event.button !== 0) return; /* only left mouse clicks */
const tree = this.props.client.channelTree;
tree.events.fire("action_select_entries", { entries: [this.props.client], mode: "auto" });
}
private onContextMenu(event: MouseEvent) {
if(settings.static(Settings.KEY_DISABLE_CONTEXT_MENU))
return;
event.preventDefault();
const client = this.props.client;
if(client.channelTree.selection.is_multi_select() && client.isSelected()) return;
client.channelTree.events.fire("action_select_entries", {
entries: [ client ],
mode: "exclusive"
});
client.showContextMenu(event.pageX, event.pageY);
}
@EventHandler<ClientEvents>("notify_select_state_change")
private handleSelectChangeState() {
this.forceUpdate();
}
}