From 55a3608efb1a436b613f22a54e369dcf12fd98bb Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 26 Sep 2020 21:35:11 +0200 Subject: [PATCH] Reworked the channel tree renderer (preparation for the popoutable channel tree) --- shared/js/ui/tree/Channel.tsx | 377 ----------- shared/js/ui/tree/Client.tsx | 397 ----------- shared/js/ui/tree/Controller.tsx | 745 +++++++++++++++++++++ shared/js/ui/tree/Definitions.ts | 81 +++ shared/js/ui/tree/Renderer.tsx | 13 + shared/js/ui/tree/RendererChannel.tsx | 167 +++++ shared/js/ui/tree/RendererClient.tsx | 192 ++++++ shared/js/ui/tree/RendererDataProvider.tsx | 528 +++++++++++++++ shared/js/ui/tree/RendererModal.tsx | 28 + shared/js/ui/tree/RendererServer.tsx | 69 ++ shared/js/ui/tree/RendererTreeEntry.tsx | 17 + shared/js/ui/tree/RendererView.tsx | 276 ++++++++ shared/js/ui/tree/Server.tsx | 128 ---- shared/js/ui/tree/TreeEntry.tsx | 26 - shared/js/ui/tree/TreeEntryMove.tsx | 2 +- shared/js/ui/tree/View.tsx | 383 ----------- 16 files changed, 2117 insertions(+), 1312 deletions(-) delete mode 100644 shared/js/ui/tree/Channel.tsx delete mode 100644 shared/js/ui/tree/Client.tsx create mode 100644 shared/js/ui/tree/Controller.tsx create mode 100644 shared/js/ui/tree/Definitions.ts create mode 100644 shared/js/ui/tree/Renderer.tsx create mode 100644 shared/js/ui/tree/RendererChannel.tsx create mode 100644 shared/js/ui/tree/RendererClient.tsx create mode 100644 shared/js/ui/tree/RendererDataProvider.tsx create mode 100644 shared/js/ui/tree/RendererModal.tsx create mode 100644 shared/js/ui/tree/RendererServer.tsx create mode 100644 shared/js/ui/tree/RendererTreeEntry.tsx create mode 100644 shared/js/ui/tree/RendererView.tsx delete mode 100644 shared/js/ui/tree/Server.tsx delete mode 100644 shared/js/ui/tree/TreeEntry.tsx delete mode 100644 shared/js/ui/tree/View.tsx diff --git a/shared/js/ui/tree/Channel.tsx b/shared/js/ui/tree/Channel.tsx deleted file mode 100644 index 4d9e118c..00000000 --- a/shared/js/ui/tree/Channel.tsx +++ /dev/null @@ -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(e => e.props.channel.events) -@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) -class ChannelEntryIcons extends ReactComponentBase { - 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(); - } - - if (this.state.is_password_protected) { - icons.push(); - } - - if (this.state.is_music_quality) { - icons.push(); - } - - if (this.state.is_moderated) { - icons.push(); - } - - if (this.state.custom_icon_id) { - const connection = this.props.channel.channelTree.client; - - icons.push(); - } - - if (!this.state.is_codec_supported) { - icons.push(
-
-
-
); - } - - return ( - - {icons} - - ); - } - - @EventHandler("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(e => e.props.channel.events) -@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) -class ChannelEntryIcon extends ReactComponentBase { - 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 ; - } - - @EventHandler("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("notify_clients_changed") - private handleClientsUpdated() { - this.forceUpdate(); - } - - @EventHandler("notify_cached_password_updated") - private handleCachedPasswordUpdate() { - this.forceUpdate(); - } - - @EventHandler("notify_subscribe_state_changed") - private handleSubscribeModeChanges() { - this.forceUpdate(); - } -} - -interface ChannelEntryNameProperties { - channel: ChannelEntryController; -} - -@ReactEventHandler(e => e.props.channel.events) -@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) -class ChannelEntryName extends ReactComponentBase { - - 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
- {text} -
; - } - - @EventHandler("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
-
{ - event.preventDefault(); - props.onToggle(); - }}/> -
-}; - -@ReactEventHandler(e => e.props.channel.events) -@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) -export class ChannelEntryView extends TreeEntry { - shouldComponentUpdate(nextProps: Readonly, 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
this.onMouseUp(e)} - onDoubleClick={() => this.onDoubleClick()} - onContextMenu={e => this.onContextMenu(e)} - onMouseDown={e => this.onMouseDown(e)} - > - - {collapsed_indicator && - this.onCollapsedToggle()} - collapsed={this.props.channel.collapsed}/>} - - - -
; - } - - 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("notify_select_state_change") - private handleSelectStateChange() { - this.forceUpdate(); - } -} \ No newline at end of file diff --git a/shared/js/ui/tree/Client.tsx b/shared/js/ui/tree/Client.tsx deleted file mode 100644 index 0d7b129d..00000000 --- a/shared/js/ui/tree/Client.tsx +++ /dev/null @@ -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(props.client.getStatusIcon()); - props.client.events.reactUse("notify_status_icon_changed", event => setIcon(event.newIcon)); - - return ; -} - -interface ClientServerGroupIconsProperties { - client: ClientEntryController; -} - -@ReactEventHandler(e => e.props.client.events) -@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) -class ClientServerGroupIcons extends ReactComponentBase { - 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 - }) - ]; - } - - @EventHandler("notify_properties_updated") - private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) { - if (typeof event.updated_properties.client_servergroups) - this.forceUpdate(); - } -} - -interface ClientChannelGroupIconProperties { - client: ClientEntryController; -} - -@ReactEventHandler(e => e.props.client.events) -@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) -class ClientChannelGroupIcon extends ReactComponentBase { - 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 ; - } - - @EventHandler("notify_properties_updated") - private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) { - if (typeof event.updated_properties.client_servergroups) { - this.forceUpdate(); - } - } -} - -interface ClientIconsProperties { - client: ClientEntryController; -} - -@ReactEventHandler(e => e.props.client.events) -@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) -class ClientIcons extends ReactComponentBase { - 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(
); - - icons.push(); - icons.push(); - if (this.props.client.properties.client_icon_id !== 0) { - const connection = this.props.client.channelTree.client; - icons.push(); - } - - return ( -
- {icons} -
- ) - } - - @EventHandler("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(e => e.props.client.events) -class ClientName extends ReactComponentBase { - /* 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
- {this.state.group_prefix + this.props.client.clientNickName() + this.state.group_suffix + this.state.away_message} -
- } - - 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("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 { - private readonly ref_div: React.RefObject = React.createRef(); - - componentDidMount(): void { - this.ref_div.current.focus(); - } - - render() { - return
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(e => e.props.client.events) -@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) -export class ClientEntry extends TreeEntry { - shouldComponentUpdate(nextProps: Readonly, nextState: Readonly, 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 ( -
this.onDoubleClick()} - onMouseUp={e => this.onMouseUp(e)} - onContextMenu={e => this.onContextMenu(e)} - > - - - {this.state.rename ? - this.onEditFinished(name)} - initialName={this.state.renameInitialName || this.props.client.properties.client_nickname}/> : - [, - ]} -
- ) - } - - 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("notify_select_state_change") - private handleSelectChangeState() { - this.forceUpdate(); - } -} \ No newline at end of file diff --git a/shared/js/ui/tree/Controller.tsx b/shared/js/ui/tree/Controller.tsx new file mode 100644 index 00000000..add9ef1c --- /dev/null +++ b/shared/js/ui/tree/Controller.tsx @@ -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(); + events.enableDebug("channel-tree-view"); + initializeTreeController(events, channelTree); + + ReactDOM.render([ + + // 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; + 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("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("notify_channel_created") + private handleChannelCreated(event: ChannelTreeEvents["notify_channel_created"]) { + if(!this.channelTreeInitialized) { return; } + this.initializeChannelEvents(event.channel); + this.sendChannelTreeEntries(); + } + + @EventHandler("notify_channel_deleted") + private handleChannelDeleted(event: ChannelTreeEvents["notify_channel_deleted"]) { + if(!this.channelTreeInitialized) { return; } + this.finalizeEvents(event.channel); + this.sendChannelTreeEntries(); + } + + @EventHandler("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("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("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(entry: ChannelTreeEntryModel, 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(entry: ChannelTreeEntryModel) { + 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, 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; + } + }); +} \ No newline at end of file diff --git a/shared/js/ui/tree/Definitions.ts b/shared/js/ui/tree/Definitions.ts new file mode 100644 index 00000000..76ab09c5 --- /dev/null +++ b/shared/js/ui/tree/Definitions.ts @@ -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: {} +} \ No newline at end of file diff --git a/shared/js/ui/tree/Renderer.tsx b/shared/js/ui/tree/Renderer.tsx new file mode 100644 index 00000000..17b5c6f8 --- /dev/null +++ b/shared/js/ui/tree/Renderer.tsx @@ -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 }) => { + const dataProvider = new RDPChannelTree(props.events, props.handlerId); + dataProvider.initialize(); + useEffect(() => () => dataProvider.destroy()); + return ; +} \ No newline at end of file diff --git a/shared/js/ui/tree/RendererChannel.tsx b/shared/js/ui/tree/RendererChannel.tsx new file mode 100644 index 00000000..ee43b202 --- /dev/null +++ b/shared/js/ui/tree/RendererChannel.tsx @@ -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 + } +} + +export class ChannelIconsRenderer extends React.Component<{ channel: RDPChannel }, {}> { + render() { + const iconInfo = this.props.channel.icons; + + const icons = []; + if (iconInfo?.default) { + icons.push(); + } + + if (iconInfo?.passwordProtected) { + icons.push(); + } + + if (iconInfo?.musicQuality) { + icons.push(); + } + + if (iconInfo?.moderated) { + icons.push(); + } + + if (iconInfo && iconInfo.channelIcon.iconId !== 0) { + icons.push( + + ); + } + + if (iconInfo?.codecUnsupported) { + icons.push( +
+
+
+
+ ); + } + + return ( + + {icons} + + ); + } +} + +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 ( +
+ {name} +
+ ); +}); + +const ChannelCollapsedIndicator = (props: { collapsed: boolean, onToggle: () => void }) => { + return
+
{ + event.preventDefault(); + props.onToggle(); + }}/> +
+}; + +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 = ; + channelIcons = ; + } + if(info && info.collapsedState !== "unset") { + collapsedIndicator = ( + events.fire("action_set_collapsed_state", { + state: info.collapsedState === "expended" ? "collapsed" : "expended", + treeEntryId: entryId + })} + collapsed={info.collapsedState === "collapsed"} + /> + ); + } + + return ( +
{ + 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 }); + }} + > + + {collapsedIndicator} + {channelIcon} + + {channelIcons} +
+ ); + } +} \ No newline at end of file diff --git a/shared/js/ui/tree/RendererClient.tsx b/shared/js/ui/tree/RendererClient.tsx new file mode 100644 index 00000000..0d539c38 --- /dev/null +++ b/shared/js/ui/tree/RendererClient.tsx @@ -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 + } +} + +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 ( +
+ {prefixString + name.name + suffixString + awayMessage} +
+ ); + } + } +} + +export class ClientTalkStatusIcon extends React.Component<{ client: RDPClient }, {}> { + render() { + switch (this.props.client.talkStatus) { + case "prohibited": + case "requested": + return ; + + case "granted": + return ; + + 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 = [ ]; + + if(iconInfo) { + icons.push(...iconInfo.serverGroupIcons + .map(icon => ( + + ))); + icons.push(...[iconInfo.channelGroupIcon].filter(e => !!e) + .map(icon => ( + + ))); + if(iconInfo.clientIcon) { + icons.push( + + ); + } + } + + return ( +
+ {icons} +
+ ); + } +} + +interface ClientNameEditProps { + editFinished: (new_name?: string) => void; + initialName: string; +} + +class ClientNameEdit extends React.Component { + private readonly refDiv: React.RefObject = React.createRef(); + + componentDidMount(): void { + this.refDiv.current.focus(); + } + + render() { + return
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 ( +
{ + 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 })} + > + + + {...(client.rename ? [ + { + events.fire_async("action_client_name_submit", { treeEntryId: client.entryId, name: value }); + }} key={"rename"} /> + ] : [ + , + + ])} +
+ ); + } +} \ No newline at end of file diff --git a/shared/js/ui/tree/RendererDataProvider.tsx b/shared/js/ui/tree/RendererDataProvider.tsx new file mode 100644 index 00000000..5267e850 --- /dev/null +++ b/shared/js/ui/tree/RendererDataProvider.tsx @@ -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; + readonly handlerId: string; + + private registeredEventHandlers = []; + + readonly refTree = React.createRef(); + + private orderedTree: RDPEntry[] = []; + private treeEntries: {[key: number]: RDPEntry} = {}; + + constructor(events: Registry, 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("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(); + + 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 { 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(); + readonly refIcons = React.createRef(); + readonly refChannel = React.createRef(); + + /* 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 ; + } + + 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(); + readonly refStatus = React.createRef(); + readonly refName = React.createRef(); + readonly refTalkStatus = React.createRef(); + readonly refIcons = React.createRef(); + + 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 ; + } + + 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(); + + 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 ; + } + + 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(); + } +} \ No newline at end of file diff --git a/shared/js/ui/tree/RendererModal.tsx b/shared/js/ui/tree/RendererModal.tsx new file mode 100644 index 00000000..5284bea8 --- /dev/null +++ b/shared/js/ui/tree/RendererModal.tsx @@ -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; + readonly handlerId: string; + + constructor(registry: Registry, userData: any) { + super(); + + this.handlerId = userData.handlerId; + this.events = registry; + } + + renderBody(): React.ReactElement { + return ; + } + + title(): string | React.ReactElement { + return Channel tree; + } +} + +export = ChannelTreeModal; \ No newline at end of file diff --git a/shared/js/ui/tree/RendererServer.tsx b/shared/js/ui/tree/RendererServer.tsx new file mode 100644 index 00000000..52ac3f7b --- /dev/null +++ b/shared/js/ui/tree/RendererServer.tsx @@ -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 = Not connected to any server; + break; + + case "connecting": + name = {server.state.targetAddress}; + break; + + case "connected": + name = {server.state.name}; + icon = ; + break; + } + + return ( +
{ + 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 }); + }} + > + +
+
{name}
+ {icon} +
+ ); + } +} \ No newline at end of file diff --git a/shared/js/ui/tree/RendererTreeEntry.tsx b/shared/js/ui/tree/RendererTreeEntry.tsx new file mode 100644 index 00000000..bc117b20 --- /dev/null +++ b/shared/js/ui/tree/RendererTreeEntry.tsx @@ -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
; + } else { + return null; + } + } +} + +export class RendererTreeEntry extends ReactComponentBase { } \ No newline at end of file diff --git a/shared/js/ui/tree/RendererView.tsx b/shared/js/ui/tree/RendererView.tsx new file mode 100644 index 00000000..ad33b6a5 --- /dev/null +++ b/shared/js/ui/tree/RendererView.tsx @@ -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; + 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 = ; + } else if(entry.type === "client" || entry.type === "client-local") { + entry.rendered = ; + } else { + entry.rendered = ; + } + return entry.rendered; +} + */ + +@ReactEventHandler(e => e.props.events) +@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) +export class ChannelTreeView extends ReactComponentBase { + public static readonly EntryHeight = 18; + + private readonly refContainer = React.createRef(); + 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("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 ( +
this.onScroll()} + ref={this.refContainer} + onMouseDown={e => this.onMouseDown(e)} + onMouseMove={e => this.onMouseMove(e)}> +
+ {elements} +
+
+ ) + } + + 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; + } +} \ No newline at end of file diff --git a/shared/js/ui/tree/Server.tsx b/shared/js/ui/tree/Server.tsx deleted file mode 100644 index cd8ff4f7..00000000 --- a/shared/js/ui/tree/Server.tsx +++ /dev/null @@ -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(e => e.props.server.events) -@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) -export class ServerEntry extends TreeEntry { - 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, nextState: Readonly, 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
this.onMouseUp(e)} - onContextMenu={e => this.onContextMenu(e)} - > - -
-
{name}
- -
- } - - 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("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("notify_select_state_change") - private handleServerSelectStateChange() { - this.forceUpdate(); - } -} \ No newline at end of file diff --git a/shared/js/ui/tree/TreeEntry.tsx b/shared/js/ui/tree/TreeEntry.tsx deleted file mode 100644 index 25d32f2a..00000000 --- a/shared/js/ui/tree/TreeEntry.tsx +++ /dev/null @@ -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; -} - -@ReactEventHandler(e => e.props.entry.events) -export class UnreadMarker extends ReactComponentBase { - render() { - if (!this.props.entry.isUnread()) - return null; - return
; - } - - @EventHandler("notify_unread_state_change") - private handleUnreadStateChange() { - this.forceUpdate(); - } -} - -export class TreeEntry extends ReactComponentBase { } \ No newline at end of file diff --git a/shared/js/ui/tree/TreeEntryMove.tsx b/shared/js/ui/tree/TreeEntryMove.tsx index e983b6d2..1297bc0e 100644 --- a/shared/js/ui/tree/TreeEntryMove.tsx +++ b/shared/js/ui/tree/TreeEntryMove.tsx @@ -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"); diff --git a/shared/js/ui/tree/View.tsx b/shared/js/ui/tree/View.tsx deleted file mode 100644 index d27b4688..00000000 --- a/shared/js/ui/tree/View.tsx +++ /dev/null @@ -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(e => e.props.tree.events) -@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) -export class ChannelTreeView extends ReactComponentBase { - private static readonly EntryHeight = 18; - - private readonly ref_container = React.createRef(); - 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 ( -
this.onScroll()} - ref={this.ref_container} - onMouseDown={e => this.onMouseDown(e)} - onMouseMove={e => this.onMouseMove(e)}> -
- {elements} -
-
- ) - } - - 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: - }); - - 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: - }; - })); - 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: - }]; - - for (const channel of tree.rootChannel()) - this.build_sub_tree(channel, 1); - } - - @EventHandler("notify_root_channel_changed") - private handleRootChannelChanged() { - this.handleTreeUpdate(); - } - - @EventHandler("notify_query_view_state_changed") - private handleQueryViewStateChange() { - this.handleTreeUpdate(); - } - - @EventHandler("notify_entry_move_begin") - private handleEntryMoveBegin() { - this.handleTreeUpdate(); - } - - @EventHandler("notify_entry_move_end") - private handleEntryMoveEnd() { - this.handleTreeUpdate(); - } - - @EventHandler("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; - } -} \ No newline at end of file