Reworked the channel tree renderer (preparation for the popoutable channel tree)

canary
WolverinDEV 2020-09-26 21:35:11 +02:00 committed by WolverinDEV
parent 5bdc99acb0
commit 2d5743eb52
16 changed files with 2117 additions and 1312 deletions

View File

@ -1,377 +0,0 @@
import {
BatchUpdateAssignment,
BatchUpdateType,
ReactComponentBase
} from "tc-shared/ui/react-elements/ReactComponentBase";
import * as React from "react";
import {ChannelEntry as ChannelEntryController, ChannelEvents, ChannelProperties} from "../../tree/Channel";
import {RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon";
import {EventHandler, ReactEventHandler} from "tc-shared/events";
import {Settings, settings} from "tc-shared/settings";
import {TreeEntry, UnreadMarker} from "tc-shared/ui/tree/TreeEntry";
import {spawnFileTransferModal} from "tc-shared/ui/modal/transfer/ModalFileTransfer";
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
import {ClientIcon} from "svg-sprites/client-icons";
import {VoiceConnectionStatus} from "tc-shared/connection/VoiceConnection";
import {AbstractServerConnection} from "tc-shared/connection/ConnectionBase";
import {getIconManager} from "tc-shared/file/Icons";
const channelStyle = require("./Channel.scss");
const viewStyle = require("./View.scss");
interface ChannelEntryIconsProperties {
channel: ChannelEntryController;
}
interface ChannelEntryIconsState {
icons_shown: boolean;
is_default: boolean;
is_password_protected: boolean;
is_music_quality: boolean;
is_moderated: boolean;
is_codec_supported: boolean;
custom_icon_id: number;
}
@ReactEventHandler<ChannelEntryIcons>(e => e.props.channel.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ChannelEntryIcons extends ReactComponentBase<ChannelEntryIconsProperties, ChannelEntryIconsState> {
private readonly listenerVoiceStatusChange;
private serverConnection: AbstractServerConnection;
constructor(props) {
super(props);
this.listenerVoiceStatusChange = () => {
let stateUpdate = {} as ChannelEntryIconsState;
this.updateVoiceStatus(stateUpdate, this.props.channel.properties.channel_codec);
this.setState(stateUpdate);
}
}
componentDidMount() {
const voiceConnection = this.serverConnection.getVoiceConnection();
voiceConnection.events.on("notify_connection_status_changed", this.listenerVoiceStatusChange);
}
componentWillUnmount() {
const voiceConnection = this.serverConnection.getVoiceConnection();
voiceConnection.events.off("notify_connection_status_changed", this.listenerVoiceStatusChange);
}
protected defaultState(): ChannelEntryIconsState {
this.serverConnection = this.props.channel.channelTree.client.serverConnection;
const properties = this.props.channel.properties;
const status = {
icons_shown: this.props.channel.parsed_channel_name.alignment === "normal",
custom_icon_id: properties.channel_icon_id,
is_music_quality: properties.channel_codec === 3 || properties.channel_codec === 5,
is_codec_supported: false,
is_default: properties.channel_flag_default,
is_password_protected: properties.channel_flag_password,
is_moderated: properties.channel_needed_talk_power !== 0
}
this.updateVoiceStatus(status, this.props.channel.properties.channel_codec);
return status;
}
render() {
let icons = [];
if (!this.state.icons_shown)
return null;
if (this.state.is_default) {
icons.push(<ClientIconRenderer key={"icon-default"} icon={ClientIcon.ChannelDefault}
title={tr("Default channel")}/>);
}
if (this.state.is_password_protected) {
icons.push(<ClientIconRenderer key={"icon-protected"} icon={ClientIcon.Register}
title={tr("The channel is password protected")}/>);
}
if (this.state.is_music_quality) {
icons.push(<ClientIconRenderer key={"icon-music"} icon={ClientIcon.Music} title={tr("Music quality")}/>);
}
if (this.state.is_moderated) {
icons.push(<ClientIconRenderer key={"icon-moderated"} icon={ClientIcon.Moderated}
title={tr("Channel is moderated")}/>);
}
if (this.state.custom_icon_id) {
const connection = this.props.channel.channelTree.client;
icons.push(<RemoteIconRenderer
key={"icon-custom"}
title={tr("Client icon")}
icon={getIconManager().resolveIcon(this.state.custom_icon_id, connection.getCurrentServerUniqueId(), connection.handlerId)} />);
}
if (!this.state.is_codec_supported) {
icons.push(<div key={"icon-unsupported"} className={channelStyle.icon_no_sound}>
<div className={"icon_entry icon client-conflict-icon"}
title={tr("You don't support the channel codec")}/>
<div className={channelStyle.background}/>
</div>);
}
return (
<span className={channelStyle.icons}>
{icons}
</span>
);
}
@EventHandler<ChannelEvents>("notify_properties_updated")
private handlePropertiesUpdate(event: ChannelEvents["notify_properties_updated"]) {
let updates = {} as ChannelEntryIconsState;
if (typeof event.updated_properties.channel_icon_id !== "undefined")
updates.custom_icon_id = event.updated_properties.channel_icon_id;
if (typeof event.updated_properties.channel_codec !== "undefined" || typeof event.updated_properties.channel_codec_quality !== "undefined") {
const codec = event.channel_properties.channel_codec;
updates.is_music_quality = codec === 3 || codec === 5;
}
if (typeof event.updated_properties.channel_codec !== "undefined") {
this.updateVoiceStatus(updates, event.channel_properties.channel_codec);
}
if (typeof event.updated_properties.channel_flag_default !== "undefined")
updates.is_default = event.updated_properties.channel_flag_default;
if (typeof event.updated_properties.channel_flag_password !== "undefined")
updates.is_password_protected = event.updated_properties.channel_flag_password;
if (typeof event.updated_properties.channel_needed_talk_power !== "undefined")
updates.is_moderated = event.updated_properties.channel_needed_talk_power !== 0;
if (typeof event.updated_properties.channel_name !== "undefined")
updates.icons_shown = this.props.channel.parsed_channel_name.alignment === "normal";
this.setState(updates);
}
private updateVoiceStatus(state: ChannelEntryIconsState, currentCodec: number) {
const voiceConnection = this.serverConnection.getVoiceConnection();
const voiceState = voiceConnection.getConnectionState();
switch (voiceState) {
case VoiceConnectionStatus.Connected:
state.is_codec_supported = voiceConnection.decodingSupported(currentCodec);
break;
default:
state.is_codec_supported = false;
}
}
}
interface ChannelEntryIconProperties {
channel: ChannelEntryController;
}
@ReactEventHandler<ChannelEntryIcon>(e => e.props.channel.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ChannelEntryIcon extends ReactComponentBase<ChannelEntryIconProperties, {}> {
private static readonly IconUpdateKeys: (keyof ChannelProperties)[] = [
"channel_name",
"channel_flag_password",
"channel_maxclients",
"channel_flag_maxclients_unlimited",
"channel_maxfamilyclients",
"channel_flag_maxfamilyclients_inherited",
"channel_flag_maxfamilyclients_unlimited",
];
render() {
if (this.props.channel.formattedChannelName() !== this.props.channel.channelName())
return null;
const channel_properties = this.props.channel.properties;
const subscribed = this.props.channel.flag_subscribed;
let channelIcon: ClientIcon;
if (channel_properties.channel_flag_password === true && !this.props.channel.cached_password()) {
channelIcon = subscribed ? ClientIcon.ChannelYellowSubscribed : ClientIcon.ChannelYellow;
} else if (!channel_properties.channel_flag_maxclients_unlimited && this.props.channel.clients().length >= channel_properties.channel_maxclients) {
channelIcon = subscribed ? ClientIcon.ChannelRedSubscribed : ClientIcon.ChannelRed;
} else if (!channel_properties.channel_flag_maxfamilyclients_unlimited && channel_properties.channel_maxfamilyclients >= 0 && this.props.channel.clients(true).length >= channel_properties.channel_maxfamilyclients) {
channelIcon = subscribed ? ClientIcon.ChannelRedSubscribed : ClientIcon.ChannelRed;
} else {
channelIcon = subscribed ? ClientIcon.ChannelGreenSubscribed : ClientIcon.ChannelGreen;
}
return <ClientIconRenderer icon={channelIcon} className={channelStyle.channelType} />;
}
@EventHandler<ChannelEvents>("notify_properties_updated")
private handlePropertiesUpdate(event: ChannelEvents["notify_properties_updated"]) {
for (const key of ChannelEntryIcon.IconUpdateKeys) {
if (key in event.updated_properties) {
this.forceUpdate();
return;
}
}
}
/* A client change may cause the channel to show another flag */
@EventHandler<ChannelEvents>("notify_clients_changed")
private handleClientsUpdated() {
this.forceUpdate();
}
@EventHandler<ChannelEvents>("notify_cached_password_updated")
private handleCachedPasswordUpdate() {
this.forceUpdate();
}
@EventHandler<ChannelEvents>("notify_subscribe_state_changed")
private handleSubscribeModeChanges() {
this.forceUpdate();
}
}
interface ChannelEntryNameProperties {
channel: ChannelEntryController;
}
@ReactEventHandler<ChannelEntryName>(e => e.props.channel.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ChannelEntryName extends ReactComponentBase<ChannelEntryNameProperties, {}> {
render() {
const name = this.props.channel.parsed_channel_name;
let class_name: string;
let text: string;
if (name.repetitive) {
class_name = "align-repetitive";
text = name.text;
if (text.length) {
while (text.length < 8000)
text += text;
}
} else {
text = name.text;
class_name = "align-" + name.alignment;
}
return <div className={this.classList(channelStyle.containerChannelName, channelStyle[class_name])}>
<a className={channelStyle.channelName}>{text}</a>
</div>;
}
@EventHandler<ChannelEvents>("notify_properties_updated")
private handlePropertiesUpdate(event: ChannelEvents["notify_properties_updated"]) {
if (typeof event.updated_properties.channel_name !== "undefined")
this.forceUpdate();
}
}
export interface ChannelEntryViewProperties {
channel: ChannelEntryController;
depth: number;
offset: number;
}
const ChannelCollapsedIndicator = (props: { collapsed: boolean, onToggle: () => void }) => {
return <div className={channelStyle.containerArrow + (!props.collapsed ? " " + channelStyle.down : "")}>
<div className={"arrow " + (props.collapsed ? "right" : "down")} onClick={event => {
event.preventDefault();
props.onToggle();
}}/>
</div>
};
@ReactEventHandler<ChannelEntryView>(e => e.props.channel.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
export class ChannelEntryView extends TreeEntry<ChannelEntryViewProperties, {}> {
shouldComponentUpdate(nextProps: Readonly<ChannelEntryViewProperties>, nextState: Readonly<{}>, nextContext: any): boolean {
if (nextProps.offset !== this.props.offset)
return true;
if (nextProps.depth !== this.props.depth)
return true;
return nextProps.channel !== this.props.channel;
}
render() {
const collapsed_indicator = this.props.channel.child_channel_head || this.props.channel.clients(false).length > 0;
return <div
className={this.classList(viewStyle.treeEntry, channelStyle.channelEntry, this.props.channel.isSelected() && viewStyle.selected)}
style={{paddingLeft: this.props.depth * 16 + 2, top: this.props.offset}}
onMouseUp={e => this.onMouseUp(e)}
onDoubleClick={() => this.onDoubleClick()}
onContextMenu={e => this.onContextMenu(e)}
onMouseDown={e => this.onMouseDown(e)}
>
<UnreadMarker entry={this.props.channel}/>
{collapsed_indicator &&
<ChannelCollapsedIndicator key={"collapsed-indicator"} onToggle={() => this.onCollapsedToggle()}
collapsed={this.props.channel.collapsed}/>}
<ChannelEntryIcon channel={this.props.channel}/>
<ChannelEntryName channel={this.props.channel}/>
<ChannelEntryIcons channel={this.props.channel}/>
</div>;
}
private onCollapsedToggle() {
this.props.channel.collapsed = !this.props.channel.collapsed;
}
private onMouseUp(event: React.MouseEvent) {
if (event.button !== 0) return; /* only left mouse clicks */
const channel = this.props.channel;
if (channel.channelTree.isClientMoveActive()) return;
channel.channelTree.events.fire("action_select_entries", {
entries: [channel],
mode: "auto"
});
}
private onDoubleClick() {
const channel = this.props.channel;
if (channel.channelTree.selection.is_multi_select()) return;
channel.joinChannel();
}
private onMouseDown(event: React.MouseEvent) {
if (event.buttons !== 4)
return;
spawnFileTransferModal(this.props.channel.getChannelId());
}
private onContextMenu(event: React.MouseEvent) {
if (settings.static(Settings.KEY_DISABLE_CONTEXT_MENU))
return;
event.preventDefault();
const channel = this.props.channel;
if (channel.channelTree.selection.is_multi_select() && channel.isSelected())
return;
channel.channelTree.events.fire("action_select_entries", {
entries: [channel],
mode: "exclusive"
});
channel.showContextMenu(event.pageX, event.pageY);
}
@EventHandler<ChannelEvents>("notify_select_state_change")
private handleSelectStateChange() {
this.forceUpdate();
}
}

View File

@ -1,397 +0,0 @@
import {
BatchUpdateAssignment,
BatchUpdateType,
ReactComponentBase
} from "tc-shared/ui/react-elements/ReactComponentBase";
import * as React from "react";
import {
ClientEntry as ClientEntryController,
ClientEvents,
LocalClientEntry,
MusicClientEntry
} from "../../tree/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 {RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon";
import * as DOMPurify from "dompurify";
import {ClientIcon} from "svg-sprites/client-icons";
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
import {useState} from "react";
import {getIconManager} from "tc-shared/file/Icons";
const clientStyle = require("./Client.scss");
const viewStyle = require("./View.scss");
const ClientStatusIndicator = (props: { client: ClientEntryController }) => {
const [ icon, setIcon ] = useState<ClientIcon>(props.client.getStatusIcon());
props.client.events.reactUse("notify_status_icon_changed", event => setIcon(event.newIcon));
return <ClientIconRenderer icon={icon} />;
}
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 (event.updated_properties.indexOf("sort-id") !== -1 || event.updated_properties.indexOf("icon") !== -1)
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.findServerGroup(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;
const connection = this.props.client.channelTree.client;
return [
group_icons.map(e => {
return <RemoteIconRenderer key={"group-icon-" + e.id}
icon={getIconManager().resolveIcon(e.properties.iconid, connection.getCurrentServerUniqueId(), connection.handlerId)} />
})
];
}
@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 (event.updated_properties.indexOf("sort-id") !== -1 || event.updated_properties.indexOf("icon") !== -1)
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.findChannelGroup(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;
const connection = this.props.client.channelTree.client;
return <RemoteIconRenderer icon={getIconManager().resolveIcon(channel_group.properties.iconid, connection.getCurrentServerUniqueId(), connection.handlerId)} key={"cg-icon"} />;
}
@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) {
const connection = this.props.client.channelTree.client;
icons.push(<RemoteIconRenderer key={"client-icon"}
icon={getIconManager().resolveIcon(this.props.client.properties.client_icon_id, connection.getCurrentServerUniqueId(), connection.handlerId)} />);
}
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)
@ReactEventHandler<ClientName>(e => e.props.client.events)
class ClientName extends ReactComponentBase<ClientNameProperties, ClientNameState> {
/* FIXME: Update prefix/suffix if a server/channel group updates! */
protected initialize() {
this.state = {} as any;
this.updateGroups(this.state);
this.updateAwayMessage(this.state);
}
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>
}
private updateGroups(state: ClientNameState) {
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.findServerGroup(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.findChannelGroup(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);
}
state.group_prefix = suffix_groups.map(e => "[" + e + "]").join("");
state.group_suffix = prefix_groups.map(e => "[" + e + "]").join("");
state.group_prefix = state.group_prefix ? " " + state.group_prefix : "";
state.group_suffix = state.group_suffix ? " " + state.group_suffix : "";
}
private updateAwayMessage(state: ClientNameState) {
state.away_message = this.props.client.properties.client_away_message && " [" + this.props.client.properties.client_away_message + "]";
}
@EventHandler<ClientEvents>("notify_properties_updated")
private handlePropertiesChanged(event: ClientEvents["notify_properties_updated"]) {
const updatedState: ClientNameState = {} as any;
if (typeof event.updated_properties.client_away !== "undefined" || typeof event.updated_properties.client_away_message !== "undefined") {
this.updateAwayMessage(updatedState);
}
if (typeof event.updated_properties.client_servergroups !== "undefined" || typeof event.updated_properties.client_channel_group_id !== "undefined") {
this.updateGroups(updatedState);
}
if (Object.keys(updatedState).length > 0)
this.setState(updatedState);
else if (typeof event.updated_properties.client_nickname !== "undefined") {
this.forceUpdate();
}
}
}
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={() => this.onBlur()}
onKeyPress={e => this.onKeyPress(e)}
/>
}
private onBlur() {
this.props.editFinished(this.ref_div.current.textContent);
}
private onKeyPress(event: React.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 + 2) + "px", top: this.props.offset}}
onDoubleClick={() => this.onDoubleClick()}
onMouseUp={e => this.onMouseUp(e)}
onContextMenu={e => this.onContextMenu(e)}
>
<UnreadMarker entry={this.props.client}/>
<ClientStatusIndicator 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 onMouseUp(event: React.MouseEvent) {
if (event.button !== 0) return; /* only left mouse clicks */
const tree = this.props.client.channelTree;
if (tree.isClientMoveActive()) return;
tree.events.fire("action_select_entries", {entries: [this.props.client], mode: "auto"});
}
private onContextMenu(event: React.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();
}
}

View File

@ -0,0 +1,745 @@
import {ChannelTree, ChannelTreeEvents} from "tc-shared/tree/ChannelTree";
import {ChannelTreeEntry as ChannelTreeEntryModel, ChannelTreeEntryEvents} from "tc-shared/tree/ChannelTreeEntry";
import {EventHandler, Registry} from "tc-shared/events";
import {
ChannelIcons,
ChannelTreeEntry,
ChannelTreeUIEvents,
ClientTalkIconState,
ServerState
} from "tc-shared/ui/tree/Definitions";
import {ChannelTreeRenderer} from "./Renderer";
import * as React from "react";
import * as ReactDOM from "react-dom";
import {LogCategory, logWarn} from "tc-shared/log";
import {ChannelEntry, ChannelProperties} from "tc-shared/tree/Channel";
import {ClientEntry, ClientProperties, ClientType, LocalClientEntry, MusicClientEntry} from "tc-shared/tree/Client";
import {ConnectionEvents, ConnectionState} from "tc-shared/ConnectionHandler";
import {VoiceConnectionEvents, VoiceConnectionStatus} from "tc-shared/connection/VoiceConnection";
import {spawnFileTransferModal} from "tc-shared/ui/modal/transfer/ModalFileTransfer";
import {GroupManager, GroupManagerEvents} from "tc-shared/permission/GroupManager";
import {ServerEntry} from "tc-shared/tree/Server";
import {spawnExternalModal} from "tc-shared/ui/react-elements/external-modal";
export function renderChannelTree(channelTree: ChannelTree, target: HTMLElement) {
const events = new Registry<ChannelTreeUIEvents>();
events.enableDebug("channel-tree-view");
initializeTreeController(events, channelTree);
ReactDOM.render([
<ChannelTreeRenderer handlerId={channelTree.client.handlerId} events={events} />
//<TreeEntryMove key={"move"} onMoveEnd={(point) => this.onMoveEnd(point.x, point.y)} ref={this.view_move} />
], target);
(window as any).chan_pop = () => {
const modal = spawnExternalModal("channel-tree", events, { handlerId: channelTree.client.handlerId });
modal.show();
}
}
/* FIXME: Client move is not a part of the channel tree, it's part of our own controller here */
const ChannelIconUpdateKeys: (keyof ChannelProperties)[] = [
"channel_name",
"channel_flag_password",
"channel_maxclients",
"channel_flag_maxclients_unlimited",
"channel_maxfamilyclients",
"channel_flag_maxfamilyclients_inherited",
"channel_flag_maxfamilyclients_unlimited",
];
const ChannelIconsUpdateKeys: (keyof ChannelProperties)[] = [
"channel_icon_id",
"channel_codec",
"channel_flag_default",
"channel_flag_password",
"channel_needed_talk_power",
];
const ClientNameInfoUpdateKeys: (keyof ClientProperties)[] = [
"client_nickname",
"client_away_message",
"client_away",
"client_channel_group_id",
"client_servergroups"
];
const ClientTalkStatusUpdateKeys: (keyof ClientProperties)[] = [
"client_is_talker",
"client_talk_power",
"client_talk_request",
"client_talk_request_msg",
"client_talk_power"
]
class ChannelTreeController {
readonly events: Registry<ChannelTreeUIEvents>;
readonly channelTree: ChannelTree;
/* the key here is the unique entry id! */
private eventListeners: {[key: number]: (() => void)[]} = {};
private channelTreeInitialized = false;
private readonly connectionStateListener;
private readonly voiceConnectionStateListener;
private readonly groupUpdatedListener;
private readonly groupsReceivedListener;
constructor(events, channelTree) {
this.events = events;
this.channelTree = channelTree;
this.connectionStateListener = this.handleConnectionStateChanged.bind(this);
this.voiceConnectionStateListener = this.handleVoiceConnectionStateChanged.bind(this);
this.groupUpdatedListener = this.handleGroupsUpdated.bind(this);
this.groupsReceivedListener = this.handleGroupsReceived.bind(this);
}
initialize() {
this.channelTree.client.events().on("notify_connection_state_changed", this.connectionStateListener);
this.channelTree.client.serverConnection.getVoiceConnection().events.on("notify_connection_status_changed", this.voiceConnectionStateListener);
this.channelTree.client.groups.events.on("notify_groups_updated", this.groupUpdatedListener);
this.channelTree.client.groups.events.on("notify_groups_received", this.groupsReceivedListener);
this.initializeServerEvents(this.channelTree.server);
this.channelTree.events.register_handler(this);
this.channelTree.channels.forEach(channel => this.initializeChannelEvents(channel));
}
destroy() {
this.channelTree.client.events().off("notify_connection_state_changed", this.connectionStateListener);
this.channelTree.client.serverConnection.getVoiceConnection().events.off("notify_connection_status_changed", this.voiceConnectionStateListener);
this.channelTree.client.groups.events.off("notify_groups_updated", this.groupUpdatedListener);
this.channelTree.client.groups.events.off("notify_groups_received", this.groupsReceivedListener);
this.finalizeEvents(this.channelTree.server);
this.channelTree.events.unregister_handler(this);
Object.values(this.eventListeners).forEach(callbacks => callbacks.forEach(callback => callback()));
this.eventListeners = {};
}
private handleConnectionStateChanged(event: ConnectionEvents["notify_connection_state_changed"]) {
if(event.new_state !== ConnectionState.CONNECTED) {
this.channelTreeInitialized = false;
this.sendChannelTreeEntries();
}
this.sendServerStatus(this.channelTree.server);
}
private handleVoiceConnectionStateChanged(event: VoiceConnectionEvents["notify_connection_status_changed"]) {
if(event.newStatus !== VoiceConnectionStatus.Connected && event.oldStatus !== VoiceConnectionStatus.Connected) {
return;
}
if(!this.channelTreeInitialized) {
return;
}
this.channelTree.channels.forEach(channel => this.sendChannelIcons(channel));
}
private handleGroupsUpdated(event: GroupManagerEvents["notify_groups_updated"]) {
if(!this.channelTreeInitialized) {
return;
}
for(const update of event.updates) {
if(update.key === "name-mode" || update.key === "name") {
/* TODO: Only test if the client actually has the group (prevent twice updates than as well)? */
this.channelTree.clients.forEach(client => this.sendClientNameInfo(client));
break;
}
}
for(const update of event.updates) {
if(update.key === "icon" || update.key === "sort-id") {
/* TODO: Only test if the client actually has the group (prevent twice updates than as well)? */
this.channelTree.clients.forEach(client => this.sendClientIcons(client));
break;
}
}
}
private handleGroupsReceived() {
if(!this.channelTreeInitialized) {
return;
}
this.channelTree.clients.forEach(channel => this.sendClientNameInfo(channel));
this.channelTree.clients.forEach(client => this.sendClientIcons(client));
}
/* general channel tree event handlers */
@EventHandler<ChannelTreeEvents>("notify_channel_list_received")
private handleChannelListReceived() {
console.error("Channel list received");
this.channelTreeInitialized = true;
this.channelTree.channels.forEach(channel => this.initializeChannelEvents(channel));
this.channelTree.clients.forEach(channel => this.initializeClientEvents(channel));
this.sendChannelTreeEntries();
}
@EventHandler<ChannelTreeEvents>("notify_channel_created")
private handleChannelCreated(event: ChannelTreeEvents["notify_channel_created"]) {
if(!this.channelTreeInitialized) { return; }
this.initializeChannelEvents(event.channel);
this.sendChannelTreeEntries();
}
@EventHandler<ChannelTreeEvents>("notify_channel_deleted")
private handleChannelDeleted(event: ChannelTreeEvents["notify_channel_deleted"]) {
if(!this.channelTreeInitialized) { return; }
this.finalizeEvents(event.channel);
this.sendChannelTreeEntries();
}
@EventHandler<ChannelTreeEvents>("notify_client_enter_view")
private handleClientEnter(event: ChannelTreeEvents["notify_client_enter_view"]) {
if(!this.channelTreeInitialized) { return; }
this.initializeClientEvents(event.client);
this.sendChannelInfo(event.targetChannel);
this.sendChannelStatusIcon(event.targetChannel);
this.sendChannelTreeEntries();
}
@EventHandler<ChannelTreeEvents>("notify_client_leave_view")
private handleClientLeave(event: ChannelTreeEvents["notify_client_leave_view"]) {
if(!this.channelTreeInitialized) { return; }
this.finalizeEvents(event.client);
this.sendChannelInfo(event.sourceChannel);
this.sendChannelStatusIcon(event.sourceChannel);
this.sendChannelTreeEntries();
}
@EventHandler<ChannelTreeEvents>("notify_client_moved")
private handleClientMoved(event: ChannelTreeEvents["notify_client_moved"]) {
if(!this.channelTreeInitialized) { return; }
this.sendChannelInfo(event.oldChannel);
this.sendChannelStatusIcon(event.oldChannel);
this.sendChannelInfo(event.newChannel);
this.sendChannelStatusIcon(event.newChannel);
this.sendChannelTreeEntries();
}
/* entry event handlers */
private initializeTreeEntryEvents<T extends ChannelTreeEntryEvents>(entry: ChannelTreeEntryModel<T>, events: any[]) {
events.push(entry.events.on("notify_unread_state_change", event => {
this.events.fire("notify_unread_state", { unread: event.unread, treeEntryId: entry.uniqueEntryId });
}));
events.push(entry.events.on("notify_select_state_change", event => {
this.events.fire("notify_select_state", { selected: event.selected, treeEntryId: entry.uniqueEntryId });
}));
}
private initializeChannelEvents(channel: ChannelEntry) {
this.finalizeEvents(channel);
const events = this.eventListeners[channel.uniqueEntryId] = [];
this.initializeTreeEntryEvents(channel, events);
events.push(channel.events.on("notify_collapsed_state_changed", () => {
this.sendChannelInfo(channel);
this.sendChannelTreeEntries();
}));
events.push(channel.events.on("notify_properties_updated", event => {
for (const key of ChannelIconUpdateKeys) {
if (key in event.updated_properties) {
this.sendChannelInfo(channel);
break;
}
}
for (const key of ChannelIconsUpdateKeys) {
if (key in event.updated_properties) {
this.sendChannelIcons(channel);
break;
}
}
if("channel_needed_talk_power" in event.updated_properties) {
channel.clients(false).forEach(client => this.sendClientTalkStatus(client));
}
}));
events.push(channel.events.on("notify_cached_password_updated", () => {
this.sendChannelStatusIcon(channel);
}));
events.push(channel.events.on("notify_subscribe_state_changed", () => {
this.sendChannelStatusIcon(channel);
}));
}
private initializeClientEvents(client: ClientEntry) {
this.finalizeEvents(client);
const events = this.eventListeners[client.uniqueEntryId] = [];
this.initializeTreeEntryEvents(client, events);
events.push(client.events.on("notify_status_icon_changed", event => {
this.events.fire("notify_client_status", { treeEntryId: client.uniqueEntryId, status: event.newIcon });
}));
events.push(client.events.on("notify_properties_updated", event => {
for (const key of ClientNameInfoUpdateKeys) {
if (key in event.updated_properties) {
this.sendClientNameInfo(client);
break;
}
}
for (const key of ClientTalkStatusUpdateKeys) {
if (key in event.updated_properties) {
this.sendClientTalkStatus(client);
break;
}
}
if("client_servergroups" in event.updated_properties || "client_channel_group_id" in event.updated_properties || "client_icon_id" in event.updated_properties) {
this.sendClientIcons(client);
}
}));
}
private initializeServerEvents(server: ServerEntry) {
this.finalizeEvents(server);
const events = this.eventListeners[server.uniqueEntryId] = [];
this.initializeTreeEntryEvents(server, events);
events.push(server.events.on("notify_properties_updated", event => {
if("virtualserver_name" in event.updated_properties || "virtualserver_icon_id" in event.updated_properties) {
this.sendServerStatus(server);
}
}));
}
private finalizeEvents<T extends ChannelTreeEntryEvents>(entry: ChannelTreeEntryModel<T>) {
if(this.eventListeners[entry.uniqueEntryId]) {
this.eventListeners[entry.uniqueEntryId].forEach(callback => callback());
}
delete this.eventListeners[entry.uniqueEntryId];
}
/* notify state update methods */
public sendChannelTreeEntries() {
const entries = [] as ChannelTreeEntry[];
/* at first comes the server */
entries.push({ type: "server", entryId: this.channelTree.server.uniqueEntryId, depth: 0 });
const buildSubTree = (channel: ChannelEntry, depth: number) => {
entries.push({ type: "channel", entryId: channel.uniqueEntryId, depth: depth });
if(channel.collapsed) {
return;
}
let clients = channel.channelClientsOrdered();
if(!this.channelTree.areServerQueriesShown()) {
clients = clients.filter(client => client.properties.client_type_exact !== ClientType.CLIENT_QUERY);
}
entries.push(...clients.map(client => { return {
type: client instanceof LocalClientEntry ? "client-local" : "client",
depth: depth + 1,
entryId: client.uniqueEntryId
} as ChannelTreeEntry }));
channel.children(false).forEach(channel => buildSubTree(channel, depth + 1));
};
this.channelTree.rootChannel().forEach(entry => buildSubTree(entry, 1));
this.events.fire_async("notify_tree_entries", { entries: entries });
}
public sendChannelInfo(channel: ChannelEntry) {
this.events.fire_async("notify_channel_info", {
treeEntryId: channel.uniqueEntryId,
info: {
collapsedState: channel.child_channel_head || channel.channelClientsOrdered().length > 0 ? channel.collapsed ? "collapsed" : "expended" : "unset",
name: channel.parsed_channel_name.text,
nameStyle: channel.parsed_channel_name.alignment
}
})
}
public sendChannelStatusIcon(channel: ChannelEntry) {
this.events.fire_async("notify_channel_icon", { icon: channel.getStatusIcon(), treeEntryId: channel.uniqueEntryId });
}
public sendChannelIcons(channel: ChannelEntry) {
let icons: ChannelIcons = {
musicQuality: channel.properties.channel_codec === 3 || channel.properties.channel_codec === 5,
codecUnsupported: true,
default: channel.properties.channel_flag_default,
moderated: channel.properties.channel_needed_talk_power !== 0,
passwordProtected: channel.properties.channel_flag_password,
channelIcon: {
iconId: channel.properties.channel_icon_id,
serverUniqueId: this.channelTree.client.getCurrentServerUniqueId()
}
};
const voiceConnection = this.channelTree.client.serverConnection.getVoiceConnection();
const voiceState = voiceConnection.getConnectionState();
switch (voiceState) {
case VoiceConnectionStatus.Connected:
icons.codecUnsupported = !voiceConnection.decodingSupported(channel.properties.channel_codec);
break;
default:
icons.codecUnsupported = true;
}
this.events.fire_async("notify_channel_icons", { icons: icons, treeEntryId: channel.uniqueEntryId });
}
public sendClientNameInfo(client: ClientEntry) {
let prefix = [];
let suffix = [];
for(const groupId of client.assignedServerGroupIds()) {
const group = this.channelTree.client.groups.findServerGroup(groupId);
if(!group) {
continue;
}
if(group.properties.namemode === 1) {
prefix.push(group.name);
} else if(group.properties.namemode === 2) {
suffix.push(group.name);
}
}
const channelGroup = this.channelTree.client.groups.findChannelGroup(client.assignedChannelGroup());
if(channelGroup) {
if(channelGroup.properties.namemode === 1) {
prefix.push(channelGroup.name);
} else if(channelGroup.properties.namemode === 2) {
suffix.push(channelGroup.name);
}
}
const afkMessage = client.properties.client_away ? client.properties.client_away_message : undefined;
this.events.fire_async("notify_client_name", {
info: {
name: client.clientNickName(),
awayMessage: afkMessage,
prefix: prefix,
suffix: suffix
},
treeEntryId: client.uniqueEntryId
});
}
public sendClientIcons(client: ClientEntry) {
const uniqueServerId = this.channelTree.client.getCurrentServerUniqueId();
const serverGroupIcons = client.assignedServerGroupIds()
.map(groupId => this.channelTree.client.groups.findServerGroup(groupId))
.filter(group => !!group && group.properties.iconid !== 0)
.sort(GroupManager.sorter())
.map(group => { return { iconId: group.properties.iconid, groupName: group.name, groupId: group.id, serverUniqueId: uniqueServerId }; });
const channelGroupIcon = [client.assignedChannelGroup()]
.map(groupId => this.channelTree.client.groups.findChannelGroup(groupId))
.filter(group => !!group && group.properties.iconid !== 0)
.map(group => { return { iconId: group.properties.iconid, groupName: group.name, groupId: group.id, serverUniqueId: uniqueServerId }; });
const clientIcon = client.properties.client_icon_id === 0 ? [] : [client.properties.client_icon_id];
this.events.fire_async("notify_client_icons", {
icons: {
serverGroupIcons: serverGroupIcons,
channelGroupIcon: channelGroupIcon[0],
clientIcon: clientIcon.length > 0 ? { iconId: clientIcon[0], serverUniqueId: uniqueServerId } : undefined
},
treeEntryId: client.uniqueEntryId
});
}
public sendClientTalkStatus(client: ClientEntry) {
let status: ClientTalkIconState = "unset";
if(client.properties.client_is_talker) {
status = "granted";
} else if(client.properties.client_talk_power < client.currentChannel().properties.channel_needed_talk_power) {
status = "prohibited";
if(client.properties.client_talk_request !== 0) {
status = "requested";
}
}
this.events.fire_async("notify_client_talk_status", { treeEntryId: client.uniqueEntryId, requestMessage: client.properties.client_talk_request_msg, status: status });
}
public sendServerStatus(serverEntry: ServerEntry) {
let status: ServerState;
switch (this.channelTree.client.connection_state) {
case ConnectionState.AUTHENTICATING:
case ConnectionState.CONNECTING:
case ConnectionState.INITIALISING:
status = {
state: "connecting",
targetAddress: serverEntry.remote_address.host + (serverEntry.remote_address.port === 9987 ? "" : `:${serverEntry.remote_address.port}`)
};
break;
case ConnectionState.DISCONNECTING:
case ConnectionState.UNCONNECTED:
status = { state: "disconnected" };
break;
case ConnectionState.CONNECTED:
status = {
state: "connected",
name: serverEntry.properties.virtualserver_name,
icon: { iconId: serverEntry.properties.virtualserver_icon_id, serverUniqueId: serverEntry.properties.virtualserver_unique_identifier }
};
break;
}
this.events.fire_async("notify_server_state", { treeEntryId: serverEntry.uniqueEntryId, state: status });
}
}
function initializeTreeController(events: Registry<ChannelTreeUIEvents>, channelTree: ChannelTree) {
/* initialize the general update handler */
const controller = new ChannelTreeController(events, channelTree);
controller.initialize();
events.on("notify_destroy", () => controller.destroy());
/* initialize the query handlers */
events.on("query_unread_state", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry) {
logWarn(LogCategory.CHANNEL, tr("Tried to query the unread state of an invalid tree entry with id %o"), event.treeEntryId);
return;
}
events.fire_async("notify_unread_state", { treeEntryId: event.treeEntryId, unread: entry.isUnread() });
});
events.on("query_select_state", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry) {
logWarn(LogCategory.CHANNEL, tr("Tried to query the select state of an invalid tree entry with id %o"), event.treeEntryId);
return;
}
events.fire_async("notify_select_state", { treeEntryId: event.treeEntryId, selected: entry.isSelected() });
});
events.on("notify_destroy", channelTree.client.events().on("notify_visibility_changed", event => events.fire("notify_visibility_changed", event)));
events.on("query_tree_entries", () => controller.sendChannelTreeEntries());
events.on("query_channel_info", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry || !(entry instanceof ChannelEntry)) {
logWarn(LogCategory.CHANNEL, tr("Tried to query the channel state of an invalid tree entry with id %o"), event.treeEntryId);
return;
}
controller.sendChannelInfo(entry);
});
events.on("query_channel_icon", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry || !(entry instanceof ChannelEntry)) {
logWarn(LogCategory.CHANNEL, tr("Tried to query the channels status icon of an invalid tree entry with id %o"), event.treeEntryId);
return;
}
controller.sendChannelStatusIcon(entry);
});
events.on("query_channel_icons", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry || !(entry instanceof ChannelEntry)) {
logWarn(LogCategory.CHANNEL, tr("Tried to query the channels icons of an invalid tree entry with id %o"), event.treeEntryId);
return;
}
controller.sendChannelIcons(entry);
});
events.on("query_client_status", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry || !(entry instanceof ClientEntry)) {
logWarn(LogCategory.CHANNEL, tr("Tried to query the client status of an invalid tree entry with id %o"), event.treeEntryId);
return;
}
events.fire_async("notify_client_status", { treeEntryId: entry.uniqueEntryId, status: entry.getStatusIcon() });
});
events.on("query_client_name", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry || !(entry instanceof ClientEntry)) {
logWarn(LogCategory.CHANNEL, tr("Tried to query the client name of an invalid tree entry with id %o"), event.treeEntryId);
return;
}
controller.sendClientNameInfo(entry);
});
events.on("query_client_icons", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry || !(entry instanceof ClientEntry)) {
logWarn(LogCategory.CHANNEL, tr("Tried to query the client icons of an invalid tree entry with id %o"), event.treeEntryId);
return;
}
controller.sendClientIcons(entry);
});
events.on("query_client_talk_status", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry || !(entry instanceof ClientEntry)) {
logWarn(LogCategory.CHANNEL, tr("Tried to query the client talk status of an invalid tree entry with id %o"), event.treeEntryId);
return;
}
controller.sendClientTalkStatus(entry);
});
events.on("query_server_state", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry || !(entry instanceof ServerEntry)) {
logWarn(LogCategory.CHANNEL, tr("Tried to query the server state of an invalid tree entry with id %o"), event.treeEntryId);
return;
}
controller.sendServerStatus(entry);
});
events.on("action_set_collapsed_state", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry || !(entry instanceof ChannelEntry)) {
logWarn(LogCategory.CHANNEL, tr("Tried to set the collapsed state state of an invalid tree entry with id %o"), event.treeEntryId);
return;
}
entry.collapsed = event.state === "collapsed";
});
events.on("action_select", event => {
if(!event.ignoreClientMove && channelTree.isClientMoveActive()) {
return;
}
const entries = [];
for(const entryId of event.entryIds) {
const entry = channelTree.findEntryId(entryId);
if(!entry) {
logWarn(LogCategory.CHANNEL, tr("Tried to select an invalid tree entry with id %o. Skipping entry."), entryId);
continue;
}
entries.push(entry);
}
channelTree.events.fire("action_select_entries", {
mode: event.mode,
entries: entries
});
});
events.on("action_show_context_menu", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry) {
logWarn(LogCategory.CHANNEL, tr("Tried to open a context menu for an invalid channel tree entry with id %o"), event.treeEntryId);
return;
}
if (channelTree.selection.is_multi_select() && entry.isSelected()) {
/* TODO: Spawn the context menu! */
return;
}
channelTree.events.fire("action_select_entries", {
entries: [entry],
mode: "exclusive"
});
entry.showContextMenu(event.pageX, event.pageY);
});
events.on("action_channel_join", event => {
if(!event.ignoreMultiSelect && channelTree.selection.is_multi_select()) {
return;
}
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry || !(entry instanceof ChannelEntry)) {
logWarn(LogCategory.CHANNEL, tr("Tried to join an invalid tree entry with id %o"), event.treeEntryId);
return;
}
entry.joinChannel();
});
events.on("action_channel_open_file_browser", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry || !(entry instanceof ChannelEntry)) {
logWarn(LogCategory.CHANNEL, tr("Tried to open the file browser for an invalid tree entry with id %o"), event.treeEntryId);
return;
}
channelTree.events.fire("action_select_entries", {
entries: [entry],
mode: "exclusive"
});
spawnFileTransferModal(entry.channelId);
});
events.on("action_client_double_click", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry || !(entry instanceof ClientEntry)) {
logWarn(LogCategory.CHANNEL, tr("Tried to execute a double click action for an invalid tree entry with id %o"), event.treeEntryId);
return;
}
if(channelTree.selection.is_multi_select()) {
return;
}
if (entry instanceof LocalClientEntry) {
entry.openRename(events);
} else if (entry instanceof MusicClientEntry) {
/* no action defined yet */
} else {
entry.open_text_chat();
}
});
events.on("action_client_name_submit", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry || !(entry instanceof LocalClientEntry)) {
logWarn(LogCategory.CHANNEL, tr("Having a client nickname submit notify for an invalid tree entry with id %o"), event.treeEntryId);
return;
}
events.fire("notify_client_name_edit", { treeEntryId: event.treeEntryId, initialValue: undefined });
if(!event.name || event.name === entry.clientNickName()) { return; }
entry.renameSelf(event.name).then(result => {
if(result) { return; }
events.fire("notify_client_name_edit", { treeEntryId: event.treeEntryId, initialValue: event.name });
})
});
events.on("notify_client_name_edit_failed", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
if(!entry || !(entry instanceof LocalClientEntry)) {
logWarn(LogCategory.CHANNEL, tr("Having a client nickname edit failed notify for an invalid tree entry with id %o"), event.treeEntryId);
return;
}
switch (event.reason) {
case "scroll-to":
entry.openRenameModal();
break;
}
});
}

View File

@ -0,0 +1,81 @@
import {ClientIcon} from "svg-sprites/client-icons";
import {RemoteIconInfo} from "tc-shared/file/Icons";
export type CollapsedState = "collapsed" | "expended" | "unset";
export type ChannelNameAlignment = "left" | "right" | "center" | "repetitive" | "normal";
export type ChannelIcons = {
default: boolean;
passwordProtected: boolean;
musicQuality: boolean;
moderated: boolean;
codecUnsupported: boolean;
channelIcon: RemoteIconInfo;
}
export type ChannelEntryInfo = { name: string, nameStyle: ChannelNameAlignment, collapsedState: CollapsedState };
export type ChannelTreeEntry = { type: "channel" | "server" | "client" | "client-local", entryId: number, depth: number };
export type ClientNameInfo = { name: string, prefix: string[], suffix: string[], awayMessage: string };
export type ClientTalkIconState = "unset" | "prohibited" | "requested" | "granted";
export type ClientIcons = {
serverGroupIcons: (RemoteIconInfo & { groupName: string, groupId: number })[],
channelGroupIcon: (RemoteIconInfo & { groupName: string, groupId: number }) | undefined,
clientIcon: RemoteIconInfo | undefined
};
export type ServerState = { state: "disconnected" } | { state: "connecting", targetAddress: string } | { state: "connected", name: string, icon: RemoteIconInfo };
export interface ChannelTreeUIEvents {
/* actions */
action_show_context_menu: { treeEntryId: number, pageX: number, pageY: number },
action_start_entry_move: { start: { x: number, y: number }, current: { x: number, y: number } },
action_set_collapsed_state: { treeEntryId: number, state: "collapsed" | "expended" },
action_select: {
entryIds: number[],
mode: "auto" | "exclusive" | "append" | "remove",
ignoreClientMove: boolean
},
action_channel_join: { treeEntryId: number, ignoreMultiSelect: boolean },
action_channel_open_file_browser: { treeEntryId: number },
action_client_double_click: { treeEntryId: number },
action_client_name_submit: { treeEntryId: number, name: string },
/* queries */
query_tree_entries: {},
query_unread_state: { treeEntryId: number },
query_select_state: { treeEntryId: number },
query_channel_info: { treeEntryId: number },
query_channel_icon: { treeEntryId: number },
query_channel_icons: { treeEntryId: number },
query_client_status: { treeEntryId: number },
query_client_name: { treeEntryId: number },
query_client_icons: { treeEntryId: number },
query_client_talk_status: { treeEntryId: number },
query_server_state: { treeEntryId: number },
/* notifies */
notify_tree_entries: { entries: ChannelTreeEntry[] },
notify_channel_info: { treeEntryId: number, info: ChannelEntryInfo },
notify_channel_icon: { treeEntryId: number, icon: ClientIcon },
notify_channel_icons: { treeEntryId: number, icons: ChannelIcons },
notify_client_status: { treeEntryId: number, status: ClientIcon },
notify_client_name: { treeEntryId: number, info: ClientNameInfo },
notify_client_icons: { treeEntryId: number, icons: ClientIcons },
notify_client_talk_status: { treeEntryId: number, status: ClientTalkIconState, requestMessage?: string },
notify_client_name_edit: { treeEntryId: number, initialValue: string | undefined },
notify_client_name_edit_failed: { treeEntryId: number, reason: "scroll-to" }
notify_server_state: { treeEntryId: number, state: ServerState },
notify_unread_state: { treeEntryId: number, unread: boolean },
notify_select_state: { treeEntryId: number, selected: boolean },
notify_visibility_changed: { visible: boolean },
notify_destroy: {}
}

View File

@ -0,0 +1,13 @@
import {Registry} from "tc-shared/events";
import {ChannelTreeUIEvents} from "tc-shared/ui/tree/Definitions";
import * as React from "react";
import {ChannelTreeView} from "tc-shared/ui/tree/RendererView";
import {RDPChannelTree} from "./RendererDataProvider";
import {useEffect} from "react";
export const ChannelTreeRenderer = (props: { handlerId: string, events: Registry<ChannelTreeUIEvents> }) => {
const dataProvider = new RDPChannelTree(props.events, props.handlerId);
dataProvider.initialize();
useEffect(() => () => dataProvider.destroy());
return <ChannelTreeView events={props.events} dataProvider={dataProvider} ref={dataProvider.refTree} />;
}

View File

@ -0,0 +1,167 @@
import * as React from "react";
import {ChannelNameAlignment} from "tc-shared/ui/tree/Definitions";
import {ClientIcon} from "svg-sprites/client-icons";
import {IconRenderer, RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon";
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
import {getIconManager} from "tc-shared/file/Icons";
import {Settings, settings} from "tc-shared/settings";
import {RDPChannel} from "tc-shared/ui/tree/RendererDataProvider";
import {UnreadMarkerRenderer} from "tc-shared/ui/tree/RendererTreeEntry";
const channelStyle = require("./Channel.scss");
const viewStyle = require("./View.scss");
export class ChannelIconClass extends React.Component<{ channel: RDPChannel }, {}> {
render() {
return <IconRenderer icon={this.props.channel.icon} className={channelStyle.channelType} />
}
}
export class ChannelIconsRenderer extends React.Component<{ channel: RDPChannel }, {}> {
render() {
const iconInfo = this.props.channel.icons;
const icons = [];
if (iconInfo?.default) {
icons.push(<ClientIconRenderer key={"icon-default"} icon={ClientIcon.ChannelDefault}
title={tr("Default channel")}/>);
}
if (iconInfo?.passwordProtected) {
icons.push(<ClientIconRenderer key={"icon-protected"} icon={ClientIcon.Register}
title={tr("The channel is password protected")}/>);
}
if (iconInfo?.musicQuality) {
icons.push(<ClientIconRenderer key={"icon-music"} icon={ClientIcon.Music} title={tr("Music quality")}/>);
}
if (iconInfo?.moderated) {
icons.push(<ClientIconRenderer key={"icon-moderated"} icon={ClientIcon.Moderated}
title={tr("Channel is moderated")}/>);
}
if (iconInfo && iconInfo.channelIcon.iconId !== 0) {
icons.push(
<RemoteIconRenderer icon={getIconManager().resolveIcon(iconInfo.channelIcon.iconId, iconInfo.channelIcon.serverUniqueId, this.props.channel.getHandlerId())}
title={tr("Channel icon")}
key={"icon-channel"}
/>
);
}
if (iconInfo?.codecUnsupported) {
icons.push(
<div key={"icon-unsupported"} className={channelStyle.icon_no_sound}>
<div className={"icon_entry icon client-conflict-icon"}
title={tr("You don't support the channel codec")}/>
<div className={channelStyle.background}/>
</div>
);
}
return (
<span className={channelStyle.icons}>
{icons}
</span>
);
}
}
const ChannelName = React.memo((props: { channelName: string | undefined, alignment: ChannelNameAlignment }) => {
let name: string;
if(typeof props.channelName === "string") {
name = props.channelName;
if(props.alignment === "repetitive") {
if (name.length) {
while (name.length < 8000) {
name += name;
}
}
}
} else {
name = "";
}
return (
<div className={channelStyle.containerChannelName + " " + channelStyle["align-" + props.alignment]}>
<a className={channelStyle.channelName}>{name}</a>
</div>
);
});
const ChannelCollapsedIndicator = (props: { collapsed: boolean, onToggle: () => void }) => {
return <div className={channelStyle.containerArrow + (!props.collapsed ? " " + channelStyle.down : "")}>
<div className={"arrow " + (props.collapsed ? "right" : "down")} onClick={event => {
event.preventDefault();
props.onToggle();
}}/>
</div>
};
export class RendererChannel extends React.Component<{ channel: RDPChannel }, {}> {
render() {
const channel = this.props.channel;
const info = this.props.channel.info;
const events = this.props.channel.getEvents();
const entryId = this.props.channel.entryId;
let channelIcon, channelIcons, collapsedIndicator;
if(!info || info.nameStyle === "normal") {
channelIcon = <ChannelIconClass channel={this.props.channel} key={"channel-icon"} ref={this.props.channel.refIcon} />;
channelIcons = <ChannelIconsRenderer channel={this.props.channel} key={"channel-icons"} ref={this.props.channel.refIcons} />;
}
if(info && info.collapsedState !== "unset") {
collapsedIndicator = (
<ChannelCollapsedIndicator key={"collapsed-indicator"}
onToggle={() => events.fire("action_set_collapsed_state", {
state: info.collapsedState === "expended" ? "collapsed" : "expended",
treeEntryId: entryId
})}
collapsed={info.collapsedState === "collapsed"}
/>
);
}
return (
<div
className={viewStyle.treeEntry + " " + channelStyle.channelEntry + " " + (channel.selected ? viewStyle.selected : "")}
style={{ paddingLeft: channel.offsetLeft, top: channel.offsetTop }}
onMouseUp={event => {
if (event.button !== 0) {
return; /* only left mouse clicks */
}
events.fire("action_select", {
entryIds: [ entryId ],
mode: "auto",
ignoreClientMove: false
});
}}
onDoubleClick={() => events.fire("action_channel_join", { ignoreMultiSelect: false, treeEntryId: entryId })}
onContextMenu={event => {
if (settings.static(Settings.KEY_DISABLE_CONTEXT_MENU)) {
return;
}
event.preventDefault();
events.fire("action_show_context_menu", { treeEntryId: entryId, pageX: event.pageX, pageY: event.pageY });
}}
onMouseDown={event => {
if (event.buttons !== 4) {
return;
}
event.preventDefault();
events.fire("action_channel_open_file_browser", { treeEntryId: entryId });
}}
>
<UnreadMarkerRenderer entry={this.props.channel} ref={this.props.channel.refUnread} />
{collapsedIndicator}
{channelIcon}
<ChannelName channelName={info?.name} alignment={info?.nameStyle} />
{channelIcons}
</div>
);
}
}

View File

@ -0,0 +1,192 @@
import * as React from "react";
import {ClientIcon} from "svg-sprites/client-icons";
import {IconRenderer, RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon";
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
import {getIconManager} from "tc-shared/file/Icons";
import {Settings, settings} from "tc-shared/settings";
import {UnreadMarkerRenderer} from "tc-shared/ui/tree/RendererTreeEntry";
import {RDPClient} from "tc-shared/ui/tree/RendererDataProvider";
import * as DOMPurify from "dompurify";
const clientStyle = require("./Client.scss");
const viewStyle = require("./View.scss");
/* TODO: Render a talk power request */
export class ClientStatus extends React.Component<{ client: RDPClient }, {}> {
render() {
return <IconRenderer icon={this.props.client.status} />
}
}
export class ClientName extends React.Component<{ client: RDPClient }, {}> {
render() {
const name = this.props.client.name;
if(!name) {
return null;
} else {
let prefixString = "";
let suffixString = "";
let awayMessage = "";
if(name.prefix.length > 0) {
prefixString = `[${name.prefix.join(" ")}] `;
}
if(name.suffix.length > 0) {
suffixString = ` [${name.suffix.join(" ")}]`;
}
if(name.awayMessage) {
awayMessage = " " + name.awayMessage;
}
return (
<div
className={clientStyle.clientName + " " + (this.props.client.localClient ? clientStyle.clientNameOwn : "")}>
{prefixString + name.name + suffixString + awayMessage}
</div>
);
}
}
}
export class ClientTalkStatusIcon extends React.Component<{ client: RDPClient }, {}> {
render() {
switch (this.props.client.talkStatus) {
case "prohibited":
case "requested":
return <ClientIconRenderer icon={ClientIcon.InputMuted} key={"not-granted"} />;
case "granted":
return <ClientIconRenderer icon={ClientIcon.IsTalker} key={"talker"} />;
default:
return null;
}
}
}
export class ClientIconsRenderer extends React.Component<{ client: RDPClient }, {}> {
render() {
const iconInfo = this.props.client.icons;
const handlerId = this.props.client.getHandlerId();
let icons = [ <ClientTalkStatusIcon client={this.props.client} key={"talk-icon"} ref={this.props.client.refTalkStatus} /> ];
if(iconInfo) {
icons.push(...iconInfo.serverGroupIcons
.map(icon => (
<RemoteIconRenderer
icon={getIconManager().resolveIcon(icon.iconId, icon.serverUniqueId, handlerId)}
title={`${icon.groupName} (${icon.groupId})`}
key={"icon-sg-" + icon.groupId}
/>
)));
icons.push(...[iconInfo.channelGroupIcon].filter(e => !!e)
.map(icon => (
<RemoteIconRenderer
icon={getIconManager().resolveIcon(icon.iconId, icon.serverUniqueId, handlerId)}
title={`${icon.groupName} (${icon.groupId})`}
key={"icon-cg-" + icon.groupId}
/>
)));
if(iconInfo.clientIcon) {
icons.push(
<RemoteIconRenderer
icon={getIconManager().resolveIcon(iconInfo.clientIcon.iconId, iconInfo.clientIcon.serverUniqueId, handlerId)}
title={tr("Client icon")}
key={"icon-client"}
/>
);
}
}
return (
<div className={clientStyle.containerIcons}>
{icons}
</div>
);
}
}
interface ClientNameEditProps {
editFinished: (new_name?: string) => void;
initialName: string;
}
class ClientNameEdit extends React.Component<ClientNameEditProps, {}> {
private readonly refDiv: React.RefObject<HTMLDivElement> = React.createRef();
componentDidMount(): void {
this.refDiv.current.focus();
}
render() {
return <div
className={clientStyle.clientName + " " + clientStyle.edit}
contentEditable={true}
ref={this.refDiv}
dangerouslySetInnerHTML={{__html: DOMPurify.sanitize(this.props.initialName)}}
onBlur={() => this.onBlur()}
onKeyPress={e => this.onKeyPress(e)}
/>
}
private onBlur() {
this.props.editFinished(this.refDiv.current.textContent);
}
private onKeyPress(event: React.KeyboardEvent) {
if (event.key === "Enter") {
event.preventDefault();
this.onBlur();
}
}
}
/* TODO: Client rename! */
export class RendererClient extends React.Component<{ client: RDPClient }, {}> {
render() {
const client = this.props.client;
const selected = this.props.client.selected;
const events = this.props.client.getEvents();
return (
<div className={clientStyle.clientEntry + " " + viewStyle.treeEntry + " " + (selected ? viewStyle.selected : "")}
style={{ paddingLeft: client.offsetLeft, top: client.offsetTop }}
onContextMenu={event => {
if (settings.static(Settings.KEY_DISABLE_CONTEXT_MENU)) {
return;
}
event.preventDefault();
events.fire("action_show_context_menu", { treeEntryId: client.entryId, pageX: event.pageX, pageY: event.pageY });
}}
onMouseUp={event => {
if (event.button !== 0) {
return; /* only left mouse clicks */
}
events.fire("action_select", {
entryIds: [ client.entryId ],
mode: "auto",
ignoreClientMove: false
});
}}
onDoubleClick={() => events.fire("action_client_double_click", { treeEntryId: client.entryId })}
>
<UnreadMarkerRenderer entry={client} ref={client.refUnread} />
<ClientStatus client={client} ref={client.refStatus} />
{...(client.rename ? [
<ClientNameEdit initialName={client.renameDefault} editFinished={value => {
events.fire_async("action_client_name_submit", { treeEntryId: client.entryId, name: value });
}} key={"rename"} />
] : [
<ClientName client={client} ref={client.refName} key={"name"} />,
<ClientIconsRenderer client={client} ref={client.refIcons} key={"icons"} />
])}
</div>
);
}
}

View File

@ -0,0 +1,528 @@
import {EventHandler, Registry} from "tc-shared/events";
import {
ChannelEntryInfo,
ChannelIcons,
ChannelTreeUIEvents,
ClientIcons,
ClientNameInfo, ClientTalkIconState, ServerState
} from "tc-shared/ui/tree/Definitions";
import {ChannelTreeView} from "tc-shared/ui/tree/RendererView";
import * as React from "react";
import {ChannelIconClass, ChannelIconsRenderer, RendererChannel} from "tc-shared/ui/tree/RendererChannel";
import {ClientIcon} from "svg-sprites/client-icons";
import {UnreadMarkerRenderer} from "tc-shared/ui/tree/RendererTreeEntry";
import {LogCategory, logError} from "tc-shared/log";
import {
ClientIconsRenderer,
ClientName,
ClientStatus,
ClientTalkStatusIcon,
RendererClient
} from "tc-shared/ui/tree/RendererClient";
import {ServerRenderer} from "tc-shared/ui/tree/RendererServer";
function isEquivalent(a, b) {
const typeA = typeof a;
const typeB = typeof b;
if(typeA !== typeB) { return false; }
if(typeA === "function") {
throw "cant compare function";
} else if(typeA === "object") {
if(Array.isArray(a)) {
if(!Array.isArray(b) || b.length !== a.length) {
return false;
}
for(let index = 0; index < a.length; index++) {
if(!isEquivalent(a[index], b[index])) {
return false;
}
}
return true;
} else {
const keys = Object.keys(a);
for(const key of keys) {
if(!(key in b)) {
return false;
}
if(!isEquivalent(a[key], b[key])) {
return false;
}
}
return true;
}
} else {
return a === b;
}
}
export class RDPChannelTree {
readonly events: Registry<ChannelTreeUIEvents>;
readonly handlerId: string;
private registeredEventHandlers = [];
readonly refTree = React.createRef<ChannelTreeView>();
private orderedTree: RDPEntry[] = [];
private treeEntries: {[key: number]: RDPEntry} = {};
constructor(events: Registry<ChannelTreeUIEvents>, handlerId: string) {
this.events = events;
this.handlerId = handlerId;
}
initialize() {
this.events.register_handler(this);
const events = this.registeredEventHandlers;
events.push(this.events.on("notify_unread_state", event => {
const entry = this.treeEntries[event.treeEntryId];
if(!entry) {
logError(LogCategory.CHANNEL, tr("Received unread notify for invalid tree entry %o."), event.treeEntryId);
return;
}
entry.handleUnreadUpdate(event.unread);
}));
events.push(this.events.on("notify_select_state", event => {
const entry = this.treeEntries[event.treeEntryId];
if(!entry) {
logError(LogCategory.CHANNEL, tr("Received select notify for invalid tree entry %o."), event.treeEntryId);
return;
}
entry.handleSelectUpdate(event.selected);
}));
events.push(this.events.on("notify_channel_info", event => {
const entry = this.treeEntries[event.treeEntryId];
if(!entry || !(entry instanceof RDPChannel)) {
logError(LogCategory.CHANNEL, tr("Received channel info notify for invalid tree entry %o."), event.treeEntryId);
return;
}
entry.handleInfoUpdate(event.info);
}));
events.push(this.events.on("notify_channel_icon", event => {
const entry = this.treeEntries[event.treeEntryId];
if(!entry || !(entry instanceof RDPChannel)) {
logError(LogCategory.CHANNEL, tr("Received channel icon notify for invalid tree entry %o."), event.treeEntryId);
return;
}
entry.handleIconUpdate(event.icon);
}));
events.push(this.events.on("notify_channel_icons", event => {
const entry = this.treeEntries[event.treeEntryId];
if(!entry || !(entry instanceof RDPChannel)) {
logError(LogCategory.CHANNEL, tr("Received channel icons notify for invalid tree entry %o."), event.treeEntryId);
return;
}
entry.handleIconsUpdate(event.icons);
}));
events.push(this.events.on("notify_client_status", event => {
const entry = this.treeEntries[event.treeEntryId];
if(!entry || !(entry instanceof RDPClient)) {
logError(LogCategory.CHANNEL, tr("Received client status notify for invalid tree entry %o."), event.treeEntryId);
return;
}
entry.handleStatusUpdate(event.status);
}));
events.push(this.events.on("notify_client_name", event => {
const entry = this.treeEntries[event.treeEntryId];
if(!entry || !(entry instanceof RDPClient)) {
logError(LogCategory.CHANNEL, tr("Received client name notify for invalid tree entry %o."), event.treeEntryId);
return;
}
entry.handleNameUpdate(event.info);
}));
events.push(this.events.on("notify_client_icons", event => {
const entry = this.treeEntries[event.treeEntryId];
if(!entry || !(entry instanceof RDPClient)) {
logError(LogCategory.CHANNEL, tr("Received client icons notify for invalid tree entry %o."), event.treeEntryId);
return;
}
entry.handleIconsUpdate(event.icons);
}));
events.push(this.events.on("notify_client_talk_status", event => {
const entry = this.treeEntries[event.treeEntryId];
if(!entry || !(entry instanceof RDPClient)) {
logError(LogCategory.CHANNEL, tr("Received client talk notify for invalid tree entry %o."), event.treeEntryId);
return;
}
entry.handleTalkStatusUpdate(event.status, event.requestMessage);
}));
events.push(this.events.on("notify_client_name_edit", event => {
const entry = this.treeEntries[event.treeEntryId];
if(!entry || !(entry instanceof RDPClient)) {
logError(LogCategory.CHANNEL, tr("Received client name edit notify for invalid tree entry %o."), event.treeEntryId);
return;
}
entry.handleOpenRename(event.initialValue);
}));
events.push(this.events.on("notify_server_state", event => {
const entry = this.treeEntries[event.treeEntryId];
if(!entry || !(entry instanceof RDPServer)) {
logError(LogCategory.CHANNEL, tr("Received server state notify for invalid tree entry %o."), event.treeEntryId);
return;
}
entry.handleStateUpdate(event.state);
}));
this.events.fire("query_tree_entries");
}
destroy() {
this.events.unregister_handler(this);
this.registeredEventHandlers.forEach(callback => callback());
this.registeredEventHandlers = [];
}
getTreeEntries() {
return this.orderedTree;
}
@EventHandler<ChannelTreeUIEvents>("notify_tree_entries")
private handleNotifyTreeEntries(event: ChannelTreeUIEvents["notify_tree_entries"]) {
console.error("Having entries");
const oldEntryInstances = this.treeEntries;
this.treeEntries = {};
this.orderedTree = event.entries.map((entry, index) => {
let result: RDPEntry;
if(oldEntryInstances[entry.entryId]) {
result = oldEntryInstances[entry.entryId];
delete oldEntryInstances[entry.entryId];
} else {
switch (entry.type) {
case "channel":
result = new RDPChannel(this, entry.entryId);
break;
case "client":
case "client-local":
result = new RDPClient(this, entry.entryId, entry.type === "client-local");
break;
case "server":
result = new RDPServer(this, entry.entryId);
break;
default:
throw "invalid channel entry type " + entry.type;
}
result.queryState();
}
this.treeEntries[entry.entryId] = result;
result.handlePositionUpdate(index * ChannelTreeView.EntryHeight, entry.depth * 16 + 2);
return result;
}).filter(e => !!e);
console.error("Obsolete entries: %o", oldEntryInstances);
Object.keys(oldEntryInstances).map(key => oldEntryInstances[key]).forEach(entry => {
entry.destroy();
});
this.refTree?.current.setState({
tree: this.orderedTree.slice()
});
}
}
export abstract class RDPEntry {
readonly handle: RDPChannelTree;
readonly entryId: number;
readonly refUnread = React.createRef<UnreadMarkerRenderer>();
offsetTop: number;
offsetLeft: number;
selected: boolean = false;
unread: boolean = false;
private renderedInstance: React.ReactElement;
private destroyed = false;
protected constructor(handle: RDPChannelTree, entryId: number) {
this.handle = handle;
this.entryId = entryId;
}
destroy() {
if(this.destroyed) {
throw "can not destry an entry twice";
}
this.renderedInstance = undefined;
this.destroyed = true;
}
/* returns true if this element does not longer exists, but it's still rendered */
isDestroyed() { return this.destroyed; }
getEvents() : Registry<ChannelTreeUIEvents> { return this.handle.events; }
getHandlerId() : string { return this.handle.handlerId; }
/* do the initial state query */
queryState() {
const events = this.getEvents();
events.fire("query_unread_state", { treeEntryId: this.entryId });
events.fire("query_select_state", { treeEntryId: this.entryId });
}
handleUnreadUpdate(value: boolean) {
if(this.unread === value) { return; }
this.unread = value;
this.refUnread.current?.forceUpdate();
}
handleSelectUpdate(value: boolean) {
if(this.selected === value) { return; }
this.selected = value;
this.renderSelectStateUpdate();
}
handlePositionUpdate(offsetTop: number, offsetLeft: number) {
if(this.offsetLeft === offsetLeft && this.offsetTop === offsetTop) { return; }
this.offsetTop = offsetTop;
this.offsetLeft = offsetLeft;
this.renderPositionUpdate();
}
render() : React.ReactElement {
if(this.renderedInstance) { return this.renderedInstance; }
return this.renderedInstance = this.doRender();
}
protected abstract doRender() : React.ReactElement;
protected abstract renderSelectStateUpdate();
protected abstract renderPositionUpdate();
}
export class RDPChannel extends RDPEntry {
readonly refIcon = React.createRef<ChannelIconClass>();
readonly refIcons = React.createRef<ChannelIconsRenderer>();
readonly refChannel = React.createRef<RendererChannel>();
/* if uninitialized, undefined */
info: ChannelEntryInfo;
/* if uninitialized, undefined */
icon: ClientIcon;
/* if uninitialized, undefined */
icons: ChannelIcons;
constructor(handle: RDPChannelTree, entryId: number) {
super(handle, entryId);
}
doRender(): React.ReactElement {
return <RendererChannel channel={this} key={this.entryId} ref={this.refChannel} />;
}
queryState() {
super.queryState();
const events = this.getEvents();
events.fire("query_channel_info", { treeEntryId: this.entryId });
events.fire("query_channel_icons", { treeEntryId: this.entryId });
events.fire("query_channel_icon", { treeEntryId: this.entryId });
}
renderSelectStateUpdate() {
this.refChannel.current?.forceUpdate();
}
protected renderPositionUpdate() {
this.refChannel.current?.forceUpdate();
}
handleIconUpdate(newIcon: ClientIcon) {
if(newIcon === this.icon) { return; }
this.icon = newIcon;
this.refIcon.current?.forceUpdate();
}
handleIconsUpdate(newIcons: ChannelIcons) {
if(isEquivalent(newIcons, this.icons)) { return; }
this.icons = newIcons;
this.refIcons.current?.forceUpdate();
}
handleInfoUpdate(newInfo: ChannelEntryInfo) {
if(isEquivalent(newInfo, this.info)) { return; }
this.info = newInfo;
this.refChannel.current?.forceUpdate();
}
}
export class RDPClient extends RDPEntry {
readonly refClient = React.createRef<RendererClient>();
readonly refStatus = React.createRef<ClientStatus>();
readonly refName = React.createRef<ClientName>();
readonly refTalkStatus = React.createRef<ClientTalkStatusIcon>();
readonly refIcons = React.createRef<ClientIconsRenderer>();
readonly localClient: boolean;
name: ClientNameInfo;
status: ClientIcon;
info: ClientNameInfo;
icons: ClientIcons;
rename: boolean = false;
renameDefault: string;
talkStatus: ClientTalkIconState;
talkRequestMessage: string;
constructor(handle: RDPChannelTree, entryId: number, localClient: boolean) {
super(handle, entryId);
this.localClient = localClient;
}
doRender(): React.ReactElement {
return <RendererClient client={this} ref={this.refClient} key={this.entryId} />;
}
queryState() {
super.queryState();
const events = this.getEvents();
events.fire("query_client_name", { treeEntryId: this.entryId });
events.fire("query_client_status", { treeEntryId: this.entryId });
events.fire("query_client_talk_status", { treeEntryId: this.entryId });
events.fire("query_client_icons", { treeEntryId: this.entryId });
}
protected renderPositionUpdate() {
this.refClient.current?.forceUpdate();
}
protected renderSelectStateUpdate() {
this.refClient.current?.forceUpdate();
}
handleStatusUpdate(newStatus: ClientIcon) {
if(newStatus === this.status) { return; }
this.status = newStatus;
this.refStatus.current?.forceUpdate();
}
handleNameUpdate(newName: ClientNameInfo) {
if(isEquivalent(newName, this.name)) { return; }
this.name = newName;
this.refName.current?.forceUpdate();
}
handleTalkStatusUpdate(newStatus: ClientTalkIconState, requestMessage: string) {
if(this.talkStatus === newStatus && this.talkRequestMessage === requestMessage) { return; }
this.talkStatus = newStatus;
this.talkRequestMessage = requestMessage;
this.refTalkStatus.current?.forceUpdate();
}
handleIconsUpdate(newIcons: ClientIcons) {
if(isEquivalent(newIcons, this.icons)) { return; }
this.icons = newIcons;
this.refIcons.current?.forceUpdate();
}
handleOpenRename(initialValue: string) {
if(!initialValue) {
this.refClient.current?.forceUpdate();
this.rename = false;
this.renameDefault = undefined;
return;
}
if(!this.handle.refTree.current || !this.refClient.current) {
/* TODO: Send error */
return;
}
this.handle.refTree.current.scrollEntryInView(this.entryId, () => {
this.rename = true;
this.renameDefault = initialValue;
this.refClient.current?.forceUpdate();
});
}
}
export class RDPServer extends RDPEntry {
readonly refServer = React.createRef<ServerRenderer>();
state: ServerState;
constructor(handle: RDPChannelTree, entryId: number) {
super(handle, entryId);
}
queryState() {
super.queryState();
const events = this.getEvents();
events.fire("query_server_state", { treeEntryId: this.entryId });
}
protected doRender(): React.ReactElement {
return <ServerRenderer server={this} ref={this.refServer} key={this.entryId} />;
}
protected renderPositionUpdate() {
this.refServer.current?.forceUpdate();
}
protected renderSelectStateUpdate() {
this.refServer.current?.forceUpdate();
}
handleStateUpdate(newState: ServerState) {
if(isEquivalent(newState, this.state)) { return; }
this.state = newState;
this.refServer.current?.forceUpdate();
}
}

View File

@ -0,0 +1,28 @@
import {AbstractModal} from "tc-shared/ui/react-elements/ModalDefinitions";
import {Registry} from "tc-shared/events";
import {ChannelTreeUIEvents} from "tc-shared/ui/tree/Definitions";
import * as React from "react";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {ChannelTreeRenderer} from "tc-shared/ui/tree/Renderer";
class ChannelTreeModal extends AbstractModal {
readonly events: Registry<ChannelTreeUIEvents>;
readonly handlerId: string;
constructor(registry: Registry<ChannelTreeUIEvents>, userData: any) {
super();
this.handlerId = userData.handlerId;
this.events = registry;
}
renderBody(): React.ReactElement {
return <ChannelTreeRenderer events={this.events} handlerId={this.handlerId} />;
}
title(): string | React.ReactElement<Translatable> {
return <Translatable>Channel tree</Translatable>;
}
}
export = ChannelTreeModal;

View File

@ -0,0 +1,69 @@
import * as React from "react";
import {RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon";
import {Settings, settings} from "tc-shared/settings";
import {UnreadMarkerRenderer} from "./RendererTreeEntry";
import {getIconManager} from "tc-shared/file/Icons";
import {RDPServer} from "tc-shared/ui/tree/RendererDataProvider";
import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n";
const serverStyle = require("./Server.scss");
const viewStyle = require("./View.scss");
export class ServerRenderer extends React.Component<{ server: RDPServer }, {}> {
render() {
const server = this.props.server;
const selected = this.props.server.selected;
const events = server.getEvents();
let name, icon;
switch (server.state?.state) {
case undefined:
name = null;
break;
case "disconnected":
name = <Translatable key={"not-connected"}>Not connected to any server</Translatable>;
break;
case "connecting":
name = <VariadicTranslatable text={"Connecting to {}"} key={"connecting"}>{server.state.targetAddress}</VariadicTranslatable>;
break;
case "connected":
name = <React.Fragment key={"server-name"}>{server.state.name}</React.Fragment>;
icon = <RemoteIconRenderer icon={getIconManager().resolveIcon(server.state.icon.iconId, server.state.icon.serverUniqueId, server.getHandlerId())} key={"server-icon"} />;
break;
}
return (
<div
className={serverStyle.serverEntry + " " + viewStyle.treeEntry + " " + (selected ? viewStyle.selected : "")}
style={{ paddingLeft: server.offsetLeft, top: server.offsetTop }}
onMouseUp={event => {
if (event.button !== 0) {
return; /* only left mouse clicks */
}
events.fire("action_select", {
entryIds: [ server.entryId ],
mode: "auto",
ignoreClientMove: false
});
}}
onContextMenu={event => {
if (settings.static(Settings.KEY_DISABLE_CONTEXT_MENU)) {
return;
}
event.preventDefault();
events.fire("action_show_context_menu", { treeEntryId: server.entryId, pageX: event.pageX, pageY: event.pageY });
}}
>
<UnreadMarkerRenderer entry={server} ref={server.refUnread} />
<div className={"icon client-server_green " + serverStyle.server_type}/>
<div className={serverStyle.name}>{name}</div>
{icon}
</div>
);
}
}

View File

@ -0,0 +1,17 @@
import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
import * as React from "react";
import {RDPEntry} from "tc-shared/ui/tree/RendererDataProvider";
const viewStyle = require("./View.scss");
export class UnreadMarkerRenderer extends React.Component<{ entry: RDPEntry }, {}> {
render() {
if(this.props.entry.unread) {
return <div className={viewStyle.markerUnread} key={"unread-marker"} />;
} else {
return null;
}
}
}
export class RendererTreeEntry<Props, State> extends ReactComponentBase<Props, State> { }

View File

@ -0,0 +1,276 @@
import {
BatchUpdateAssignment,
BatchUpdateType,
ReactComponentBase
} from "tc-shared/ui/react-elements/ReactComponentBase";
import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events";
import * as React from "react";
import {ChannelTreeUIEvents} from "tc-shared/ui/tree/Definitions";
import ResizeObserver from 'resize-observer-polyfill';
import {RDPEntry, RDPChannelTree} from "./RendererDataProvider";
const viewStyle = require("./View.scss");
export interface ChannelTreeViewProperties {
events: Registry<ChannelTreeUIEvents>;
dataProvider: RDPChannelTree;
moveThreshold?: number;
}
export interface ChannelTreeViewState {
element_scroll_offset?: number; /* in px */
scroll_offset: number; /* in px */
view_height: number; /* in px */
tree_version: number;
smoothScroll: boolean;
/* the currently rendered tree */
tree: RDPEntry[];
}
/*
export function renderFlatTreeEntry(entry: FlatTreeEntry) {
if(entry.rendered) { return entry.rendered; }
if(entry.type === "channel") {
entry.rendered = <RendererChannel entryId={entry.entryId} offsetTop={entry.index * ChannelTreeView.EntryHeight} offsetLeft={entry.depth * 16 + 2} key={entry.entryId} />;
} else if(entry.type === "client" || entry.type === "client-local") {
entry.rendered = <RendererClient entryId={entry.entryId} offsetTop={entry.index * ChannelTreeView.EntryHeight} offsetLeft={entry.depth * 16 + 2} key={entry.entryId} localClient={entry.type === "client-local"} />;
} else {
entry.rendered = <div className={viewStyle.treeEntry} style={{ top: entry.index * ChannelTreeView.EntryHeight + "px" }} key={entry.entryId}><a>{entry.type + " - " + entry.entryId}</a></div>;
}
return entry.rendered;
}
*/
@ReactEventHandler<ChannelTreeView>(e => e.props.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewProperties, ChannelTreeViewState> {
public static readonly EntryHeight = 18;
private readonly refContainer = React.createRef<HTMLDivElement>();
private resizeObserver: ResizeObserver;
private scrollFixRequested;
private mouseMove: { x: number, y: number, down: boolean, fired: boolean } = {
x: 0,
y: 0,
down: false,
fired: false
};
private readonly documentMouseListener;
private inViewCallbacks: {
index: number,
callback: () => void,
timeout
}[] = [];
constructor(props) {
super(props);
this.state = {
scroll_offset: 0,
view_height: 0,
tree_version: 0,
smoothScroll: false,
tree: []
};
this.documentMouseListener = (e: MouseEvent) => {
if (e.type !== "mouseleave" && e.button !== 0)
return;
this.mouseMove.down = false;
this.mouseMove.fired = false;
this.removeDocumentMouseListener();
};
}
componentDidMount(): void {
this.resizeObserver = new ResizeObserver(entries => {
if (entries.length !== 1) {
if (entries.length === 0) {
console.warn(tr("Channel resize observer fired resize event with no entries!"));
} else {
console.warn(tr("Channel resize observer fired resize event with more than one entry which should not be possible (%d)!"), entries.length);
}
return;
}
const bounds = entries[0].contentRect;
if (this.state.view_height !== bounds.height) {
this.setState({
view_height: bounds.height
});
}
});
this.resizeObserver.observe(this.refContainer.current);
this.setState({ tree: this.props.dataProvider.getTreeEntries() });
}
componentWillUnmount(): void {
this.resizeObserver.disconnect();
this.resizeObserver = undefined;
}
@EventHandler<ChannelTreeUIEvents>("notify_visibility_changed")
private handleVisibilityChanged(event: ChannelTreeUIEvents["notify_visibility_changed"]) {
if (!event.visible) {
this.setState({smoothScroll: false});
return;
}
if (this.scrollFixRequested) {
return;
}
this.scrollFixRequested = true;
requestAnimationFrame(() => {
this.scrollFixRequested = false;
this.refContainer.current.scrollTop = this.state.scroll_offset;
this.setState({smoothScroll: true});
});
}
private registerDocumentMouseListener() {
document.addEventListener("mouseleave", this.documentMouseListener);
document.addEventListener("mouseup", this.documentMouseListener);
}
private removeDocumentMouseListener() {
document.removeEventListener("mouseleave", this.documentMouseListener);
document.removeEventListener("mouseup", this.documentMouseListener);
}
private visibleEntries() {
let viewEntryCount = Math.ceil(this.state.view_height / ChannelTreeView.EntryHeight);
const viewEntryBegin = Math.floor(this.state.scroll_offset / ChannelTreeView.EntryHeight);
const viewEntryEnd = Math.min(this.state.tree.length, viewEntryBegin + viewEntryCount);
return {
begin: viewEntryBegin,
end: viewEntryEnd
}
}
render() {
const entryPreRenderCount = 5;
const entryPostRenderCount = 5;
const elements = [];
const renderedRange = this.visibleEntries();
const viewEntryBegin = Math.max(0, renderedRange.begin - entryPreRenderCount);
const viewEntryEnd = Math.min(this.state.tree.length, renderedRange.end + entryPostRenderCount);
for (let index = viewEntryBegin; index < viewEntryEnd; index++) {
elements.push(this.state.tree[index].render());
}
for (const callback of this.inViewCallbacks.slice(0)) {
if (callback.index >= renderedRange.begin && callback.index <= renderedRange.end) {
clearTimeout(callback.timeout);
callback.callback();
this.inViewCallbacks.remove(callback);
}
}
/* className={this.classList(viewStyle.channelTree, this.props.tree.isClientMoveActive() && viewStyle.move)} */
return (
<div
className={viewStyle.channelTreeContainer + " " + (this.state.smoothScroll ? viewStyle.smoothScroll : "")}
onScroll={() => this.onScroll()}
ref={this.refContainer}
onMouseDown={e => this.onMouseDown(e)}
onMouseMove={e => this.onMouseMove(e)}>
<div
className={viewStyle.channelTree}
style={{height: (this.state.tree.length * ChannelTreeView.EntryHeight) + "px"}}>
{elements}
</div>
</div>
)
}
private onScroll() {
this.setState({
scroll_offset: this.refContainer.current.scrollTop
});
}
private onMouseDown(e: React.MouseEvent) {
if (e.button !== 0) return; /* left button only */
this.mouseMove.down = true;
this.mouseMove.x = e.pageX;
this.mouseMove.y = e.pageY;
this.registerDocumentMouseListener();
}
private onMouseMove(e: React.MouseEvent) {
if (!this.mouseMove.down || this.mouseMove.fired) return;
if (Math.abs((this.mouseMove.x - e.pageX) * (this.mouseMove.y - e.pageY)) > (this.props.moveThreshold || 9)) {
this.mouseMove.fired = true;
this.props.events.fire("action_start_entry_move", {
current: { x: e.pageX, y: e.pageY },
start: { x: this.mouseMove.x, y: this.mouseMove.y }
});
}
}
scrollEntryInView(entryId: number, callback?: () => void) {
const index = this.state.tree.findIndex(e => e.entryId === entryId);
if (index === -1) {
if (callback) callback();
console.warn(tr("Failed to scroll tree entry in view because its not registered within the view. EntryId: %d"), entryId);
return;
}
let new_index;
const currentRange = this.visibleEntries();
if (index >= currentRange.end - 1) {
new_index = index - (currentRange.end - currentRange.begin) + 2;
} else if (index < currentRange.begin) {
new_index = index;
} else {
if (callback) callback();
return;
}
this.refContainer.current.scrollTop = new_index * ChannelTreeView.EntryHeight;
if (callback) {
let cb = {
index: index,
callback: callback,
timeout: setTimeout(() => {
this.inViewCallbacks.remove(cb);
callback();
}, (Math.abs(new_index - currentRange.begin) / (currentRange.end - currentRange.begin)) * 1500)
};
this.inViewCallbacks.push(cb);
}
}
getEntryFromPoint(pageX: number, pageY: number) : number {
const container = this.refContainer.current;
if (!container) return;
const bounds = container.getBoundingClientRect();
pageY -= bounds.y;
pageX -= bounds.x;
if (pageX < 0 || pageY < 0)
return undefined;
if (pageX > container.clientWidth)
return undefined;
const total_offset = container.scrollTop + pageY;
return this.state.tree[Math.floor(total_offset / ChannelTreeView.EntryHeight)]?.entryId;
}
}

View File

@ -1,128 +0,0 @@
import {BatchUpdateAssignment, BatchUpdateType} from "tc-shared/ui/react-elements/ReactComponentBase";
import {ServerEntry as ServerEntryController, ServerEvents} from "../../tree/Server";
import * as React from "react";
import {RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon";
import {EventHandler, ReactEventHandler} from "tc-shared/events";
import {Settings, settings} from "tc-shared/settings";
import {TreeEntry, UnreadMarker} from "tc-shared/ui/tree/TreeEntry";
import {ConnectionEvents, ConnectionState} from "tc-shared/ConnectionHandler";
import {getIconManager} from "tc-shared/file/Icons";
const serverStyle = require("./Server.scss");
const viewStyle = require("./View.scss");
export interface ServerEntryProperties {
server: ServerEntryController;
offset: number;
}
export interface ServerEntryState {
connection_state: "connected" | "connecting" | "disconnected";
}
@ReactEventHandler<ServerEntry>(e => e.props.server.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
export class ServerEntry extends TreeEntry<ServerEntryProperties, ServerEntryState> {
private handle_connection_state_change;
protected defaultState(): ServerEntryState {
return {connection_state: this.props.server ? ServerEntry.connectionState2String(this.props.server.channelTree.client.connection_state) : "disconnected"};
}
private static connectionState2String(state: ConnectionState) {
switch (state) {
case ConnectionState.AUTHENTICATING:
case ConnectionState.CONNECTING:
case ConnectionState.INITIALISING:
return "connecting";
case ConnectionState.CONNECTED:
return "connected";
case ConnectionState.DISCONNECTING:
case ConnectionState.UNCONNECTED:
return "disconnected";
}
}
protected initialize() {
this.handle_connection_state_change = (event: ConnectionEvents["notify_connection_state_changed"]) => this.setState({
connection_state: ServerEntry.connectionState2String(event.new_state)
});
}
shouldComponentUpdate(nextProps: Readonly<ServerEntryProperties>, nextState: Readonly<ServerEntryState>, nextContext: any): boolean {
return this.state.connection_state !== nextState.connection_state ||
this.props.offset !== nextProps.offset ||
this.props.server !== nextProps.server;
}
componentDidMount(): void {
this.props.server.channelTree.client.events().on("notify_connection_state_changed", this.handle_connection_state_change);
}
componentWillUnmount(): void {
this.props.server.channelTree.client.events().off("notify_connection_state_changed", this.handle_connection_state_change);
}
render() {
let name = this.props.server.properties.virtualserver_name;
if (this.state.connection_state === "disconnected")
name = tr("Not connected to any server");
else if (this.state.connection_state === "connecting")
name = tr("Connecting to ") + this.props.server.remote_address.host + (this.props.server.remote_address.port !== 9987 ? ":" + this.props.server.remote_address.host : "");
const connection = this.props.server.channelTree.client;
return <div
className={this.classList(serverStyle.serverEntry, viewStyle.treeEntry, this.props.server.isSelected() && viewStyle.selected)}
style={{top: this.props.offset}}
onMouseUp={e => this.onMouseUp(e)}
onContextMenu={e => this.onContextMenu(e)}
>
<UnreadMarker entry={this.props.server}/>
<div className={"icon client-server_green " + serverStyle.server_type}/>
<div className={this.classList(serverStyle.name)}>{name}</div>
<RemoteIconRenderer icon={getIconManager().resolveIcon(this.props.server.properties.virtualserver_icon_id, this.props.server.properties.virtualserver_unique_identifier, connection.handlerId)} />
</div>
}
private onMouseUp(event: React.MouseEvent) {
if (event.button !== 0) return; /* only left mouse clicks */
if (this.props.server.channelTree.isClientMoveActive()) return;
this.props.server.channelTree.events.fire("action_select_entries", {
entries: [this.props.server],
mode: "auto"
});
}
private onContextMenu(event: React.MouseEvent) {
if (settings.static(Settings.KEY_DISABLE_CONTEXT_MENU))
return;
event.preventDefault();
const server = this.props.server;
if (server.channelTree.selection.is_multi_select() && server.isSelected())
return;
server.channelTree.events.fire("action_select_entries", {
entries: [server],
mode: "exclusive"
});
server.spawnContextMenu(event.pageX, event.pageY);
}
@EventHandler<ServerEvents>("notify_properties_updated")
private handlePropertiesUpdated(event: ServerEvents["notify_properties_updated"]) {
if (typeof event.updated_properties.virtualserver_name !== "undefined" || typeof event.updated_properties.virtualserver_icon_id !== "undefined") {
this.forceUpdate();
}
}
@EventHandler<ServerEvents>("notify_select_state_change")
private handleServerSelectStateChange() {
this.forceUpdate();
}
}

View File

@ -1,26 +0,0 @@
import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
import {ChannelTreeEntry, ChannelTreeEntryEvents} from "tc-shared/tree/ChannelTreeEntry";
import * as React from "react";
import {EventHandler, ReactEventHandler} from "tc-shared/events";
const viewStyle = require("./View.scss");
export interface UnreadMarkerProperties {
entry: ChannelTreeEntry<any>;
}
@ReactEventHandler<UnreadMarker>(e => e.props.entry.events)
export class UnreadMarker extends ReactComponentBase<UnreadMarkerProperties, {}> {
render() {
if (!this.props.entry.isUnread())
return null;
return <div className={viewStyle.markerUnread}/>;
}
@EventHandler<ChannelTreeEntryEvents>("notify_unread_state_change")
private handleUnreadStateChange() {
this.forceUpdate();
}
}
export class TreeEntry<Props, State> extends ReactComponentBase<Props, State> { }

View File

@ -1,7 +1,7 @@
import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase"; import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
import * as React from "react"; import * as React from "react";
import * as ReactDOM from "react-dom"; import * as ReactDOM from "react-dom";
import {ChannelTreeView} from "tc-shared/ui/tree/View"; import {ChannelTreeView} from "./RendererView.tsx.old";
const moveStyle = require("./TreeEntryMove.scss"); const moveStyle = require("./TreeEntryMove.scss");

View File

@ -1,383 +0,0 @@
import {
BatchUpdateAssignment,
BatchUpdateType,
ReactComponentBase
} from "tc-shared/ui/react-elements/ReactComponentBase";
import {ChannelTree, ChannelTreeEvents} from "tc-shared/tree/ChannelTree";
import ResizeObserver from 'resize-observer-polyfill';
import * as React from "react";
import {EventHandler, ReactEventHandler} from "tc-shared/events";
import {ChannelEntryView as ChannelEntryView} from "./Channel";
import {ServerEntry as ServerEntryView} from "./Server";
import {ClientEntry as ClientEntryView} from "./Client";
import {ChannelEntry, ChannelEvents} from "tc-shared/tree/Channel";
import {ServerEntry} from "tc-shared/tree/Server";
import {ClientEntry, ClientType} from "tc-shared/tree/Client";
import * as log from "tc-shared/log";
import {LogCategory} from "tc-shared/log";
import {ConnectionEvents} from "tc-shared/ConnectionHandler";
const viewStyle = require("./View.scss");
export interface ChannelTreeViewProperties {
tree: ChannelTree;
onMoveStart: (start: { x: number, y: number }, current: { x: number, y: number }) => void;
moveThreshold?: number;
}
export interface ChannelTreeViewState {
element_scroll_offset?: number; /* in px */
scroll_offset: number; /* in px */
view_height: number; /* in px */
tree_version: number;
smoothScroll: boolean;
}
export type TreeEntry = ChannelEntry | ServerEntry | ClientEntry;
type FlatTreeEntry = {
rendered: any;
entry: TreeEntry;
}
//TODO: Only register listeners when channel is in view ;)
@ReactEventHandler<ChannelTreeView>(e => e.props.tree.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewProperties, ChannelTreeViewState> {
private static readonly EntryHeight = 18;
private readonly ref_container = React.createRef<HTMLDivElement>();
private resize_observer: ResizeObserver;
private flat_tree: FlatTreeEntry[] = [];
private listener_client_change;
private listener_channel_change;
private listener_state_collapsed;
private listener_channel_properties;
private listener_tree_visibility_changed;
private update_timeout;
private scrollFixRequested;
private mouse_move: { x: number, y: number, down: boolean, fired: boolean } = {
x: 0,
y: 0,
down: false,
fired: false
};
private document_mouse_listener;
private in_view_callbacks: {
index: number,
callback: () => void,
timeout
}[] = [];
protected defaultState(): ChannelTreeViewState {
return {
scroll_offset: 0,
view_height: 0,
tree_version: 0,
smoothScroll: false
};
}
componentDidMount(): void {
(window as any).channelTrees = (window as any).channelTrees || [];
(window as any).channelTrees.push(this);
this.resize_observer = new ResizeObserver(entries => {
if (entries.length !== 1) {
if (entries.length === 0)
console.warn(tr("Channel resize observer fired resize event with no entries!"));
else
console.warn(tr("Channel resize observer fired resize event with more than one entry which should not be possible (%d)!"), entries.length);
return;
}
const bounds = entries[0].contentRect;
if (this.state.view_height !== bounds.height) {
this.setState({
view_height: bounds.height
});
}
});
this.resize_observer.observe(this.ref_container.current);
this.props.tree.client.events().on("notify_visibility_changed", this.listener_tree_visibility_changed);
}
componentWillUnmount(): void {
(window as any).channelTrees?.remove(this);
this.resize_observer.disconnect();
this.resize_observer = undefined;
this.props.tree.client.events().off("notify_visibility_changed", this.listener_tree_visibility_changed);
}
protected initialize() {
this.listener_client_change = () => this.handleTreeUpdate();
this.listener_channel_change = () => this.handleTreeUpdate();
this.listener_state_collapsed = () => this.handleTreeUpdate();
this.listener_channel_properties = (event: ChannelEvents["notify_properties_updated"]) => {
if (typeof event.updated_properties.channel_needed_talk_power !== "undefined") /* talk power flags have changed */
this.handleTreeUpdate();
};
this.document_mouse_listener = (e: MouseEvent) => {
if (e.type !== "mouseleave" && e.button !== 0)
return;
this.mouse_move.down = false;
this.mouse_move.fired = false;
this.removeDocumentMouseListener();
};
this.listener_tree_visibility_changed = (event: ConnectionEvents["notify_visibility_changed"]) => {
if (!event.visible) {
this.setState({smoothScroll: false});
return;
}
if (this.scrollFixRequested)
return;
this.scrollFixRequested = true;
requestAnimationFrame(() => {
this.scrollFixRequested = false;
this.ref_container.current.scrollTop = this.state.scroll_offset;
this.setState({smoothScroll: true});
});
}
}
private registerDocumentMouseListener() {
document.addEventListener("mouseleave", this.document_mouse_listener);
document.addEventListener("mouseup", this.document_mouse_listener);
}
private removeDocumentMouseListener() {
document.removeEventListener("mouseleave", this.document_mouse_listener);
document.removeEventListener("mouseup", this.document_mouse_listener);
}
private handleTreeUpdate() {
clearTimeout(this.update_timeout);
this.update_timeout = setTimeout(() => {
this.rebuild_tree();
this.forceUpdate();
}, 50);
}
private visibleEntries() {
let view_entry_count = Math.ceil(this.state.view_height / ChannelTreeView.EntryHeight);
const view_entry_begin = Math.floor(this.state.scroll_offset / ChannelTreeView.EntryHeight);
const view_entry_end = Math.min(this.flat_tree.length, view_entry_begin + view_entry_count);
return {
begin: view_entry_begin,
end: view_entry_end
}
}
render() {
const entry_prerender_count = 5;
const entry_postrender_count = 5;
const elements = [];
const renderedRange = this.visibleEntries();
const view_entry_begin = Math.max(0, renderedRange.begin - entry_prerender_count);
const view_entry_end = Math.min(this.flat_tree.length, renderedRange.end + entry_postrender_count);
for (let index = view_entry_begin; index < view_entry_end; index++)
elements.push(this.flat_tree[index].rendered);
for (const callback of this.in_view_callbacks.slice(0)) {
if (callback.index >= renderedRange.begin && callback.index <= renderedRange.end) {
clearTimeout(callback.timeout);
callback.callback();
this.in_view_callbacks.remove(callback);
}
}
return (
<div
className={viewStyle.channelTreeContainer + " " + (this.state.smoothScroll ? viewStyle.smoothScroll : "")}
onScroll={() => this.onScroll()}
ref={this.ref_container}
onMouseDown={e => this.onMouseDown(e)}
onMouseMove={e => this.onMouseMove(e)}>
<div
className={this.classList(viewStyle.channelTree, this.props.tree.isClientMoveActive() && viewStyle.move)}
style={{height: (this.flat_tree.length * ChannelTreeView.EntryHeight) + "px"}}>
{elements}
</div>
</div>
)
}
private build_top_offset: number;
private build_sub_tree(entry: ChannelEntry, depth: number) {
entry.events.on("notify_clients_changed", this.listener_client_change);
entry.events.on("notify_children_changed", this.listener_channel_change);
entry.events.on("notify_collapsed_state_changed", this.listener_state_collapsed);
entry.events.on("notify_properties_updated", this.listener_channel_properties);
this.flat_tree.push({
entry: entry,
rendered: <ChannelEntryView key={this.state.tree_version + "-channel-" + entry.channelId} channel={entry}
offset={this.build_top_offset += ChannelTreeView.EntryHeight} depth={depth}
ref={entry.view}/>
});
if (entry.collapsed) return;
let clients = entry.clients(false);
if (!this.props.tree.areServerQueriesShown())
clients = clients.filter(e => e.properties.client_type_exact !== ClientType.CLIENT_QUERY);
this.flat_tree.push(...clients.map(e => {
return {
entry: e,
rendered: <ClientEntryView key={this.state.tree_version + "-client-" + e.clientId()} client={e}
offset={this.build_top_offset += ChannelTreeView.EntryHeight}
depth={depth + 1} ref={e.view}/>
};
}));
for (const channel of entry.children(false))
this.build_sub_tree(channel, depth + 1);
}
private rebuild_tree() {
log.debug(LogCategory.CHANNEL, tr("Rebuilding the channel tree"));
const tree = this.props.tree;
{
let index = this.flat_tree.length;
while (index--) {
const entry = this.flat_tree[index].entry;
if (entry instanceof ChannelEntry) {
entry.events.off("notify_properties_updated", this.listener_client_change);
entry.events.off("notify_clients_changed", this.listener_client_change);
entry.events.off("notify_children_changed", this.listener_channel_change);
entry.events.off("notify_properties_updated", this.listener_channel_properties);
}
}
}
this.build_top_offset = -ChannelTreeView.EntryHeight; /* because of the += */
this.flat_tree = [{
entry: tree.server,
rendered: <ServerEntryView key={this.state.tree_version + "-server"} server={tree.server}
offset={this.build_top_offset += ChannelTreeView.EntryHeight}
ref={tree.server.view}/>
}];
for (const channel of tree.rootChannel())
this.build_sub_tree(channel, 1);
}
@EventHandler<ChannelTreeEvents>("notify_root_channel_changed")
private handleRootChannelChanged() {
this.handleTreeUpdate();
}
@EventHandler<ChannelTreeEvents>("notify_query_view_state_changed")
private handleQueryViewStateChange() {
this.handleTreeUpdate();
}
@EventHandler<ChannelTreeEvents>("notify_entry_move_begin")
private handleEntryMoveBegin() {
this.handleTreeUpdate();
}
@EventHandler<ChannelTreeEvents>("notify_entry_move_end")
private handleEntryMoveEnd() {
this.handleTreeUpdate();
}
@EventHandler<ChannelTreeEvents>("notify_tree_reset")
private handleTreeReset() {
this.rebuild_tree();
this.setState({
tree_version: this.state.tree_version + 1
});
}
private onScroll() {
this.setState({
scroll_offset: this.ref_container.current.scrollTop
});
}
private onMouseDown(e: React.MouseEvent) {
if (e.button !== 0) return; /* left button only */
this.mouse_move.down = true;
this.mouse_move.x = e.pageX;
this.mouse_move.y = e.pageY;
this.registerDocumentMouseListener();
}
private onMouseMove(e: React.MouseEvent) {
if (!this.mouse_move.down || this.mouse_move.fired) return;
if (Math.abs((this.mouse_move.x - e.pageX) * (this.mouse_move.y - e.pageY)) > (this.props.moveThreshold || 9)) {
this.mouse_move.fired = true;
this.props.onMoveStart({x: this.mouse_move.x, y: this.mouse_move.y}, {x: e.pageX, y: e.pageY});
}
}
scrollEntryInView(entry: TreeEntry, callback?: () => void) {
const index = this.flat_tree.findIndex(e => e.entry === entry);
if (index === -1) {
if (callback) callback();
console.warn(tr("Failed to scroll tree entry in view because its not registered within the view. Entry: %o"), entry);
return;
}
let new_index;
const currentRange = this.visibleEntries();
if (index >= currentRange.end - 1) {
new_index = index - (currentRange.end - currentRange.begin) + 2;
} else if (index < currentRange.begin) {
new_index = index;
} else {
if (callback) callback();
return;
}
this.ref_container.current.scrollTop = new_index * ChannelTreeView.EntryHeight;
if (callback) {
let cb = {
index: index,
callback: callback,
timeout: setTimeout(() => {
this.in_view_callbacks.remove(cb);
callback();
}, (Math.abs(new_index - currentRange.begin) / (currentRange.end - currentRange.begin)) * 1500)
};
this.in_view_callbacks.push(cb);
}
}
getEntryFromPoint(pageX: number, pageY: number) {
const container = this.ref_container.current;
if (!container) return;
const bounds = container.getBoundingClientRect();
pageY -= bounds.y;
pageX -= bounds.x;
if (pageX < 0 || pageY < 0)
return undefined;
if (pageX > container.clientWidth)
return undefined;
const total_offset = container.scrollTop + pageY;
return this.flat_tree[Math.floor(total_offset / ChannelTreeView.EntryHeight)]?.entry;
}
}