Reworked the channel tree renderer (preparation for the popoutable channel tree)
parent
5bdc99acb0
commit
2d5743eb52
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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: {}
|
||||
}
|
|
@ -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} />;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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> { }
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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> { }
|
|
@ -1,7 +1,7 @@
|
|||
import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
|
||||
import * as React from "react";
|
||||
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");
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue