package com.faforever.client.chat; import com.faforever.client.fx.JavaFxUtil; import com.faforever.client.i18n.I18n; import com.faforever.client.preferences.ChatPrefs; import com.google.common.annotations.VisibleForTesting; import com.google.gson.Gson; import javafx.application.Platform; import javafx.beans.value.ChangeListener; import javafx.beans.value.WeakChangeListener; import javafx.collections.FXCollections; import javafx.collections.MapChangeListener; import javafx.collections.ObservableList; import javafx.collections.SetChangeListener; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.geometry.Bounds; import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.Tab; import javafx.scene.control.TextField; import javafx.scene.control.TextInputControl; import javafx.scene.control.TitledPane; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; import javafx.scene.layout.HBox; import javafx.scene.layout.Pane; import javafx.scene.layout.VBox; import javafx.scene.web.WebEngine; import javafx.scene.web.WebView; import javafx.stage.Popup; import javafx.stage.PopupWindow; import netscape.javascript.JSObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ConfigurableApplicationContext; import javax.annotation.PostConstruct; import javax.annotation.Resource; import java.lang.invoke.MethodHandles; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ThreadPoolExecutor; import static com.faforever.client.chat.ChatColorMode.DEFAULT; import static com.faforever.client.chat.SocialStatus.FOE; import static com.faforever.client.chat.SocialStatus.OTHER; import static com.faforever.client.chat.SocialStatus.SELF; public class ChannelTabController extends AbstractChatTabController { @VisibleForTesting static final String CSS_CLASS_MODERATOR = "moderator"; private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); private static final String USER_CSS_CLASS_FORMAT = "user-%s"; /** * Keeps track of which ChatUserControl in which pane belongs to which user. */ private final Map<String, Map<Pane, ChatUserItemController>> userToChatUserControls; @FXML Button advancedUserFilter; @FXML HBox searchFieldContainer; @FXML Button closeSearchFieldButton; @FXML TextField searchField; @FXML VBox channelTabScrollPaneVBox; @FXML TitledPane moderatorsTitlePane; @FXML TitledPane friendsTitlePane; @FXML TitledPane othersTitlePane; @FXML TitledPane chatOnlyTitlePane; @FXML TitledPane foesTitlePane; @FXML Tab channelTabRoot; @FXML WebView messagesWebView; @FXML Pane moderatorsPane; @FXML Pane friendsPane; @FXML Pane foesPane; @FXML Pane othersPane; @FXML Pane chatOnlyPane; @FXML TextField userSearchTextField; @FXML TextField messageTextField; @Resource FilterUserController filterUserController; @Resource ConfigurableApplicationContext applicationContext; @Resource I18n i18n; @Resource ThreadPoolExecutor threadPoolExecutor; private Channel channel; private Popup filterUserPopup; private MapChangeListener<String, ChatUser> usersChangeListener; private ChangeListener<ChatColorMode> chatColorModeChangeListener; public ChannelTabController() { userToChatUserControls = FXCollections.observableMap(new ConcurrentHashMap<>()); } // TODO clean this up public Map<String, Map<Pane, ChatUserItemController>> getUserToChatUserControls() { return userToChatUserControls; } public void setChannel(Channel channel) { if (this.channel != null) { throw new IllegalStateException("channel has already been set"); } this.channel = channel; String channelName = channel.getName(); setReceiver(channelName); channelTabRoot.setId(channelName); channelTabRoot.setText(channelName); usersChangeListener = change -> { if (change.wasAdded()) { onUserJoinedChannel(change.getValueAdded()); } else if (change.wasRemoved()) { onUserLeft(change.getValueRemoved().getUsername()); } updateUserCount(change.getMap().size()); }; updateUserCount(channel.getUsers().size()); chatService.addUsersListener(channelName, usersChangeListener); // Maybe there already were some users; fetch them threadPoolExecutor.execute(() -> channel.getUsers().forEach(ChannelTabController.this::onUserJoinedChannel)); channelTabRoot.setOnCloseRequest(event -> { chatService.leaveChannel(channel.getName()); chatService.removeUsersListener(channelName, usersChangeListener); }); searchFieldContainer.visibleProperty().bind(searchField.visibleProperty()); closeSearchFieldButton.visibleProperty().bind(searchField.visibleProperty()); addSearchFieldListener(); channel.topicProperty().addListener((observable, oldValue, newValue) -> setTopic(newValue)); } private void updateUserCount(int count) { Platform.runLater(() -> userSearchTextField.setPromptText(i18n.get("chat.userCount", count))); } @FXML void initialize() { userSearchTextField.textProperty().addListener((observable, oldValue, newValue) -> { filterChatUserControlsBySearchString(); }); chatColorModeChangeListener = (observable, oldValue, newValue) -> { if (newValue != DEFAULT) { setAllMessageColors(); } else { removeAllMessageColors(); } }; } /** * Hides all chat user controls whose username does not contain the string entered in the {@link * #userSearchTextField}. */ private void filterChatUserControlsBySearchString() { synchronized (userToChatUserControls) { for (Map<Pane, ChatUserItemController> chatUserControlMap : userToChatUserControls.values()) { synchronized (chatUserControlMap) { for (Map.Entry<Pane, ChatUserItemController> chatUserControlEntry : chatUserControlMap.entrySet()) { ChatUserItemController chatUserItemController = chatUserControlEntry.getValue(); chatUserItemController.setVisible(isUsernameMatch(chatUserItemController)); } } } } } private void setAllMessageColors() { Map<String, String> userToColor = new HashMap<>(); channel.getUsers().stream().filter(chatUser -> chatUser.getColor() != null).forEach(chatUser -> userToColor.put(chatUser.getUsername(), JavaFxUtil.toRgbCode(chatUser.getColor()))); getJsObject().call("setAllMessageColors", new Gson().toJson(userToColor)); } private void removeAllMessageColors() { getJsObject().call("removeAllMessageColors"); } //TODO: I don't like how this is public public boolean isUsernameMatch(ChatUserItemController chatUserItemController) { String lowerCaseSearchString = chatUserItemController.getPlayerInfoBean().getUsername().toLowerCase(); return lowerCaseSearchString.contains(userSearchTextField.getText().toLowerCase()); } /** * Inserts the given ChatUserControl into the given Pane such that it is correctly sorted alphabetically. */ private void addChatUserItemSorted(Pane pane, ChatUserItemController chatUserItemController) { ObservableList<Node> children = pane.getChildren(); Pane chatUserItemRoot = chatUserItemController.getRoot(); if (chatUserItemController.getPlayerInfoBean().getSocialStatus() == SELF) { children.add(0, chatUserItemRoot); return; } String thisUsername = chatUserItemController.getPlayerInfoBean().getUsername(); for (Node child : children) { String otherUsername = ((ChatUserItemController) child.getUserData()).getPlayerInfoBean().getUsername(); if (otherUsername.equalsIgnoreCase(userService.getUsername())) { continue; } if (thisUsername.compareToIgnoreCase(otherUsername) < 0) { children.add(children.indexOf(child), chatUserItemRoot); return; } } children.add(chatUserItemRoot); } @Override public Tab getRoot() { return channelTabRoot; } @PostConstruct @Override void postConstruct() { super.postConstruct(); channelTabScrollPaneVBox.setMinWidth(preferencesService.getPreferences().getChat().getChannelTabScrollPaneWidth()); channelTabScrollPaneVBox.setPrefWidth(preferencesService.getPreferences().getChat().getChannelTabScrollPaneWidth()); addChatColorListener(); addUserFilterPopup(); } @Override protected TextInputControl getMessageTextField() { return messageTextField; } @Override protected WebView getMessagesWebView() { return messagesWebView; } @Override protected void onWebViewLoaded() { setTopic(channel.getTopic()); } private void setTopic(String topic) { Platform.runLater(() -> { String value = convertUrlsToHyperlinks(topic); WebEngine engine = getMessagesWebView().getEngine(); ((JSObject) engine.executeScript("document.getElementById('" + CHANNEL_TOPIC_CONTAINER_ID + "')")).setMember("innerHTML", value); ((JSObject) engine.executeScript("document.getElementById('" + CHANNEL_TOPIC_SHADOW_CONTAINER_ID + "')")).setMember("innerHTML", value); } ); } @Override protected void onMention(ChatMessage chatMessage) { if (!hasFocus()) { audioController.playChatMentionSound(); showNotificationIfNecessary(chatMessage); incrementUnreadMessagesCount(1); setUnread(true); } } @Override protected String getMessageCssClass(String login) { PlayerInfoBean playerInfoBean = playerService.getPlayerForUsername(login); if (playerInfoBean != null && !playerInfoBean.equals(playerService.getCurrentPlayer()) && playerInfoBean.getModeratorForChannels().contains(channel.getName())) { return CSS_CLASS_MODERATOR; } return super.getMessageCssClass(login); } private void addChatColorListener() { preferencesService.getPreferences().getChat().chatColorModeProperty().addListener(new WeakChangeListener<>(chatColorModeChangeListener)); } private void addUserFilterPopup() { filterUserPopup = new Popup(); filterUserPopup.setAutoFix(false); filterUserPopup.setAutoHide(true); filterUserPopup.setAnchorLocation(PopupWindow.AnchorLocation.CONTENT_TOP_RIGHT); filterUserPopup.getContent().setAll(filterUserController.getRoot()); filterUserController.setChannelController(this); } public void updateUserMessageColor(ChatUser chatUser) { String color = ""; if (chatUser.getColor() != null) { color = JavaFxUtil.toRgbCode(chatUser.getColor()); } getJsObject().call("updateUserMessageColor", chatUser.getUsername(), color); } private void removeUserMessageClass(PlayerInfoBean playerInfoBean, String cssClass) { //TODO: DOM Exception 12 when cssClass string is empty string, not sure why cause .remove in the js should be able to handle it if (cssClass.isEmpty()) { return; } Platform.runLater(() -> getJsObject().call("removeUserMessageClass", String.format(USER_CSS_CLASS_FORMAT, playerInfoBean.getUsername()), cssClass)); } private void setUserMessageClass(PlayerInfoBean playerInfoBean, String cssClass) { Platform.runLater(() -> getJsObject().call("setUserMessageClass", String.format(USER_CSS_CLASS_FORMAT, playerInfoBean.getUsername()), cssClass)); } private void updateUserMessageDisplay(PlayerInfoBean playerInfoBean, String display) { Platform.runLater(() -> getJsObject().call("updateUserMessageDisplay", String.format(USER_CSS_CLASS_FORMAT, playerInfoBean.getUsername()), display)); } private void onUserJoinedChannel(ChatUser chatUser) { JavaFxUtil.assertBackgroundThread(); ChatPrefs chatPrefs = preferencesService.getPreferences().getChat(); String username = chatUser.getUsername(); PlayerInfoBean player = playerService.createAndGetPlayerForUsername(username); player.moderatorForChannelsProperty().bind(chatUser.moderatorInChannelsProperty()); player.usernameProperty().addListener((observable, oldValue, newValue) -> { for (Map.Entry<Pane, ChatUserItemController> entry : userToChatUserControls.get(oldValue).entrySet()) { Pane pane = entry.getKey(); ChatUserItemController chatUserItemController = entry.getValue(); pane.getChildren().remove(chatUserItemController.getRoot()); addChatUserItemSorted(pane, chatUserItemController); } }); player.usernameProperty().bind(chatUser.usernameProperty()); player.socialStatusProperty().addListener((observable, oldValue, newValue) -> { if (newValue == OTHER && player.isChatOnly()) { addToPane(player, chatOnlyPane); setUserMessageClass(player, CSS_CLASS_CHAT_ONLY); } else { addToPane(player, getPaneForSocialStatus(newValue)); setUserMessageClass(player, newValue.getCssClass()); } if (chatPrefs.getHideFoeMessages() && newValue == FOE) { updateUserMessageDisplay(player, "none"); } if (oldValue == OTHER && player.isChatOnly()) { removeFromPane(player, chatOnlyPane); removeUserMessageClass(player, CSS_CLASS_CHAT_ONLY); } else { removeFromPane(player, getPaneForSocialStatus(oldValue)); removeUserMessageClass(player, oldValue.getCssClass()); } if (chatPrefs.getHideFoeMessages() && oldValue == FOE) { updateUserMessageDisplay(player, ""); } }); player.chatOnlyProperty().addListener((observable, oldValue, newValue) -> { if (player.getSocialStatus() == OTHER && !chatUser.getModeratorInChannels().contains(username)) { if (newValue) { removeFromPane(player, othersPane); addToPane(player, chatOnlyPane); setUserMessageClass(player, CSS_CLASS_CHAT_ONLY); } else { removeFromPane(player, chatOnlyPane); addToPane(player, getPaneForSocialStatus(player.getSocialStatus())); removeUserMessageClass(player, CSS_CLASS_CHAT_ONLY); } } }); player.getModeratorForChannels().addListener((SetChangeListener<String>) change -> { if (change.wasAdded()) { addToPane(player, moderatorsPane); removeFromPane(player, othersPane); removeFromPane(player, chatOnlyPane); setUserMessageClass(player, CSS_CLASS_MODERATOR); } else { removeFromPane(player, moderatorsPane); SocialStatus socialStatus = player.getSocialStatus(); if (socialStatus == OTHER || socialStatus == SELF) { addToPane(player, othersPane); } removeUserMessageClass(player, CSS_CLASS_MODERATOR); } }); chatPrefs.hideFoeMessagesProperty().addListener((observable, oldValue, newValue) -> { if (newValue && player.getSocialStatus() == FOE) { updateUserMessageDisplay(player, "none"); } else { updateUserMessageDisplay(player, ""); } }); chatUser.colorProperty().addListener((observable, oldValue, newValue) -> Platform.runLater(() -> updateUserMessageColor(chatUser)) ); Collection<Pane> targetPanesForUser = getTargetPanesForUser(player); userToChatUserControls.putIfAbsent(username, new HashMap<>(targetPanesForUser.size(), 1)); for (Pane pane : targetPanesForUser) { ChatUserItemController chatUserItemController = createChatUserControlForPlayerIfNecessary(pane, player); // Apply filter if exists if (!userSearchTextField.textProperty().get().isEmpty()) { chatUserItemController.setVisible(isUsernameMatch(chatUserItemController)); } if (filterUserPopup.isShowing()) { filterUserController.filterUser(chatUserItemController); } } } private Pane getPaneForSocialStatus(SocialStatus socialStatus) { switch (socialStatus) { case FRIEND: return friendsPane; case FOE: return foesPane; default: return othersPane; } } private void onUserLeft(String username) { JavaFxUtil.assertBackgroundThread(); Map<Pane, ChatUserItemController> paneToChatUserControlMap = userToChatUserControls.get(username); if (paneToChatUserControlMap == null) { return; } Platform.runLater(() -> { synchronized (paneToChatUserControlMap) { for (Map.Entry<Pane, ChatUserItemController> entry : paneToChatUserControlMap.entrySet()) { entry.getKey().getChildren().remove(entry.getValue().getRoot()); } paneToChatUserControlMap.clear(); } }); userToChatUserControls.remove(username); } private ChatUserItemController addToPane(PlayerInfoBean playerInfoBean, Pane pane) { return createChatUserControlForPlayerIfNecessary(pane, playerInfoBean); } private void removeFromPane(PlayerInfoBean playerInfoBean, Pane pane) { Map<Pane, ChatUserItemController> paneChatUserControlMap = userToChatUserControls.get(playerInfoBean.getUsername()); if (paneChatUserControlMap == null) { // User has not yet been added to this pane; no need to remove him return; } synchronized (paneChatUserControlMap) { ChatUserItemController controller = paneChatUserControlMap.remove(pane); if (controller == null) { return; } Pane root = controller.getRoot(); if (root != null) { Platform.runLater(() -> pane.getChildren().remove(root)); } } } /** * Creates a {@link ChatUserItemController} for the given playerInfoBean and adds it to the given pane if there isn't * already such a control in this pane. After the control has been added, the user search filter is applied. */ private ChatUserItemController createChatUserControlForPlayerIfNecessary(Pane pane, PlayerInfoBean playerInfoBean) { String username = playerInfoBean.getUsername(); synchronized (userToChatUserControls) { if (!userToChatUserControls.containsKey(username)) { userToChatUserControls.put(username, new HashMap<>(1, 1)); } } Map<Pane, ChatUserItemController> paneToChatUserControlMap = userToChatUserControls.get(username); ChatUserItemController existingChatUserItemController = paneToChatUserControlMap.get(pane); if (existingChatUserItemController != null) { return existingChatUserItemController; } if (!applicationContext.isActive()) { logger.warn("Application context has been closed, not creating control for player {}", playerInfoBean.getUsername()); } ChatUserItemController chatUserItemController = applicationContext.getBean(ChatUserItemController.class); chatUserItemController.setPlayerInfoBean(playerInfoBean); paneToChatUserControlMap.put(pane, chatUserItemController); chatUserItemController.setColorsAllowedInPane((pane == othersPane || pane == chatOnlyPane) && playerInfoBean.getSocialStatus() != SELF); Platform.runLater(() -> { addChatUserItemSorted(pane, chatUserItemController); isUsernameMatch(chatUserItemController); }); return chatUserItemController; } private Collection<Pane> getTargetPanesForUser(PlayerInfoBean playerInfoBean) { ArrayList<Pane> panes = new ArrayList<>(3); if (playerInfoBean.getModeratorForChannels().contains(channel.getName())) { panes.add(moderatorsPane); } Pane pane = getPaneForSocialStatus(playerInfoBean.getSocialStatus()); if (pane == othersPane && playerInfoBean.isChatOnly()) { panes.add(chatOnlyPane); } else { panes.add(pane); } return panes; } @FXML void onKeyReleased(KeyEvent event) { if (event.getCode() == KeyCode.ESCAPE) { onSearchFieldClose(); } else if (event.isControlDown() && event.getCode() == KeyCode.F) { searchField.clear(); searchField.setVisible(!searchField.isVisible()); searchField.requestFocus(); } } @FXML void onSearchFieldClose() { searchField.setVisible(false); searchField.clear(); } public void addSearchFieldListener() { searchField.textProperty().addListener((observable, oldValue, newValue) -> { if (newValue.trim().isEmpty()) { getJsObject().call("removeHighlight"); } else { getJsObject().call("highlightText", newValue); } }); } @FXML void onAdvancedUserFilter(ActionEvent actionEvent) { if (filterUserPopup.isShowing()) { filterUserPopup.hide(); return; } Button button = (Button) actionEvent.getSource(); Bounds screenBounds = advancedUserFilter.localToScreen(advancedUserFilter.getBoundsInLocal()); filterUserPopup.show(button.getScene().getWindow(), screenBounds.getMinX(), screenBounds.getMaxY()); } }