package com.faforever.client.chat; import com.faforever.client.audio.AudioController; import com.faforever.client.chat.UrlPreviewResolver.Preview; import com.faforever.client.fx.JavaFxUtil; import com.faforever.client.fx.PlatformService; import com.faforever.client.game.PlayerCardTooltipController; import com.faforever.client.i18n.I18n; import com.faforever.client.io.ByteCopier; import com.faforever.client.main.MainController; import com.faforever.client.notification.DismissAction; import com.faforever.client.notification.ImmediateNotification; import com.faforever.client.notification.NotificationService; import com.faforever.client.notification.ReportAction; import com.faforever.client.notification.Severity; import com.faforever.client.notification.TransientNotification; import com.faforever.client.player.PlayerService; import com.faforever.client.preferences.ChatPrefs; import com.faforever.client.preferences.PreferencesService; import com.faforever.client.reporting.ReportingService; import com.faforever.client.theme.ThemeService; import com.faforever.client.uploader.ImageUploadService; import com.faforever.client.user.UserService; import com.faforever.client.util.IdenticonUtil; import com.faforever.client.util.TimeService; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Joiner; import com.google.common.eventbus.EventBus; import com.google.common.io.CharStreams; import com.sun.javafx.scene.control.skin.TabPaneSkin; import javafx.application.Platform; import javafx.beans.property.IntegerProperty; import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.WeakChangeListener; import javafx.concurrent.Worker; import javafx.css.PseudoClass; import javafx.event.EventHandler; import javafx.fxml.FXML; import javafx.scene.Node; import javafx.scene.control.ContentDisplay; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; import javafx.scene.control.TextInputControl; import javafx.scene.control.Tooltip; import javafx.scene.image.Image; import javafx.scene.input.Clipboard; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; import javafx.scene.input.MouseEvent; import javafx.scene.paint.Color; import javafx.scene.web.WebEngine; import javafx.scene.web.WebView; import javafx.stage.Popup; import javafx.stage.PopupWindow; import javafx.stage.Stage; import netscape.javascript.JSObject; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.ClassPathResource; import javax.annotation.PostConstruct; import javax.annotation.Resource; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; import java.lang.invoke.MethodHandles; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; import static com.faforever.client.chat.SocialStatus.FOE; import static com.faforever.client.chat.SocialStatus.FRIEND; import static com.faforever.client.chat.SocialStatus.SELF; import static com.google.common.html.HtmlEscapers.htmlEscaper; import static java.util.regex.Pattern.CASE_INSENSITIVE; import static javafx.scene.AccessibleAttribute.ITEM_AT_INDEX; /** * A chat tab displays messages in a {@link WebView}. The WebView is used since text on a JavaFX canvas isn't * selectable, but text within a WebView is. This comes with some ugly implications; some of the logic has to be * performed in interaction with JavaScript, like when the user clicks a link. */ public abstract class AbstractChatTabController { protected static final String CSS_CLASS_CHAT_ONLY = "chat_only"; protected static final String MESSAGE_CONTAINER_ID = "chat-container"; protected static final String MESSAGE_ITEM_CLASS = "chat-message"; protected static final String CHANNEL_TOPIC_CONTAINER_ID = "channel-topic"; protected static final String CHANNEL_TOPIC_SHADOW_CONTAINER_ID = "channel-topic-shadow"; private static final PseudoClass UNREAD_PSEUDO_STATE = PseudoClass.getPseudoClass("unread"); private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); private static final org.springframework.core.io.Resource CHAT_HTML_RESOURCE = new ClassPathResource("/theme/chat_container.html"); private static final org.springframework.core.io.Resource CHAT_JS_RESOURCE = new ClassPathResource("/js/chat_container.js"); private static final org.springframework.core.io.Resource AUTOLINKER_JS_RESOURCE = new ClassPathResource("/js/Autolinker.min.js"); private static final org.springframework.core.io.Resource JQUERY_JS_RESOURCE = new ClassPathResource("js/jquery-2.1.4.min.js"); private static final org.springframework.core.io.Resource JQUERY_HIGHLIGHT_JS_RESOURCE = new ClassPathResource("js/jquery.highlight-5.closure.js"); private static final org.springframework.core.io.Resource MESSAGE_ITEM_HTML_RESOURCE = new ClassPathResource("/theme/chat_message.html"); /** * This is the member name within the JavaScript code that provides access to this chat tab instance. */ private static final String CHAT_TAB_REFERENCE_IN_JAVASCRIPT = "chatTab"; private static final String ACTION_PREFIX = "/me "; private static final String JOIN_PREFIX = "/join "; private static final String WHOIS_PREFIX = "/whois "; /** * Added if a message is what IRC calls an "action". */ private static final String ACTION_CSS_CLASS = "action"; private static final String MESSAGE_CSS_CLASS = "message"; /** * Messages that arrived before the web view was ready. Those are appended as soon as it is ready. */ private final List<ChatMessage> waitingMessages; private final IntegerProperty unreadMessagesCount; private final ChangeListener<Boolean> resetUnreadMessagesListener; @Resource UserService userService; @Resource ChatService chatService; @Resource PlatformService platformService; @Resource PreferencesService preferencesService; @Resource PlayerService playerService; @Resource AudioController audioController; @Resource TimeService timeService; @Resource PlayerCardTooltipController playerCardTooltipController; @Resource ChatController chatController; @Resource I18n i18n; @Resource ImageUploadService imageUploadService; @Resource UrlPreviewResolver urlPreviewResolver; @Resource NotificationService notificationService; @Resource ReportingService reportingService; @Resource Stage stage; @Resource MainController mainController; @Resource ThemeService themeService; @Resource AutoCompletionHelper autoCompletionHelper; @Resource EventBus eventBus; private boolean isChatReady; private WebEngine engine; private double lastMouseX; private double lastMouseY; private final EventHandler<MouseEvent> moveHandler = (MouseEvent event) -> { lastMouseX = event.getScreenX(); lastMouseY = event.getScreenY(); }; /** * Either a channel like "#aeolus" or a user like "Visionik". */ private String receiver; private Pattern mentionPattern; private Popup playerCardTooltip; private Tooltip linkPreviewTooltip; private ChangeListener<Boolean> stageFocusedListener; public AbstractChatTabController() { waitingMessages = new ArrayList<>(); unreadMessagesCount = new SimpleIntegerProperty(); resetUnreadMessagesListener = (observable, oldValue, newValue) -> { if (hasFocus()) { setUnread(false); } }; } /** * Returns true if this chat tab is currently focused by the user. Returns false if a different tab is selected, the * user is not in "chat" or if the window has no focus. */ protected boolean hasFocus() { if (!getRoot().isSelected()) { return false; } TabPane tabPane = getRoot().getTabPane(); return tabPane != null && JavaFxUtil.isVisibleRecursively(tabPane) && tabPane.getScene().getWindow().isFocused() && tabPane.getScene().getWindow().isShowing(); } protected void setUnread(boolean unread) { TabPane tabPane = getRoot().getTabPane(); if (tabPane == null) { return; } TabPaneSkin skin = (TabPaneSkin) tabPane.getSkin(); if (skin == null) { return; } int tabIndex = tabPane.getTabs().indexOf(getRoot()); if (tabIndex == -1) { // Tab has been closed return; } Node tab = (Node) skin.queryAccessibleAttribute(ITEM_AT_INDEX, tabIndex); tab.pseudoClassStateChanged(UNREAD_PSEUDO_STATE, unread); if (!unread) { synchronized (unreadMessagesCount) { unreadMessagesCount.setValue(0); } } } public abstract Tab getRoot(); protected void incrementUnreadMessagesCount(int delta) { synchronized (unreadMessagesCount) { unreadMessagesCount.set(unreadMessagesCount.get() + delta); } } public String getReceiver() { return receiver; } public void setReceiver(String receiver) { this.receiver = receiver; } @PostConstruct void postConstruct() { mentionPattern = Pattern.compile("\\b(" + Pattern.quote(userService.getUsername()) + ")\\b", CASE_INSENSITIVE); Platform.runLater(this::initChatView); addFocusListeners(); addImagePasteListener(); unreadMessagesCount.addListener((observable, oldValue, newValue) -> chatService.incrementUnreadMessagesCount(newValue.intValue() - oldValue.intValue()) ); stage.focusedProperty().addListener(new WeakChangeListener<>(resetUnreadMessagesListener)); getRoot().selectedProperty().addListener(new WeakChangeListener<>(resetUnreadMessagesListener)); autoCompletionHelper.bindTo(getMessageTextField()); } /** * Registers listeners necessary to focus the message input field when changing to another message tab, changing from * another tab to the "chat" tab or re-focusing the window. */ private void addFocusListeners() { getRoot().selectedProperty().addListener((observable, oldValue, newValue) -> { if (newValue) { // Since a tab is marked as "selected" before it's rendered, the text field can't be selected yet. // So let's schedule the focus to be executed afterwards Platform.runLater(getMessageTextField()::requestFocus); } }); getRoot().tabPaneProperty().addListener((tabPane, oldTabPane, newTabPane) -> { if (newTabPane == null) { return; } stageFocusedListener = (window, windowFocusOld, windowFocusNew) -> { if (newTabPane.isVisible()) { getMessageTextField().requestFocus(); } }; stage.focusedProperty().addListener(new WeakChangeListener<>(stageFocusedListener)); newTabPane.focusedProperty().addListener((focusedTabPane, oldTabPaneFocus, newTabPaneFocus) -> { if (newTabPaneFocus) { getMessageTextField().requestFocus(); } }); }); } private void addImagePasteListener() { TextInputControl messageTextField = getMessageTextField(); messageTextField.setOnKeyReleased(event -> { if (isPaste(event) && Clipboard.getSystemClipboard().hasImage()) { pasteImage(); } }); } protected abstract TextInputControl getMessageTextField(); private boolean isPaste(KeyEvent event) { return (event.getCode() == KeyCode.V && event.isShortcutDown()) || (event.getCode() == KeyCode.INSERT && event.isShiftDown()); } private void pasteImage() { TextInputControl messageTextField = getMessageTextField(); int currentCaretPosition = messageTextField.getCaretPosition(); messageTextField.setDisable(true); Clipboard clipboard = Clipboard.getSystemClipboard(); Image image = clipboard.getImage(); imageUploadService.uploadImageInBackground(image).thenAccept(url -> { messageTextField.insertText(currentCaretPosition, url); messageTextField.setDisable(false); messageTextField.requestFocus(); messageTextField.positionCaret(messageTextField.getLength()); }).exceptionally(throwable -> { messageTextField.setDisable(false); return null; }); } private void initChatView() { WebView messagesWebView = getMessagesWebView(); JavaFxUtil.configureWebView(messagesWebView, preferencesService, themeService); themeService.registerWebView(messagesWebView); messagesWebView.addEventHandler(MouseEvent.MOUSE_MOVED, moveHandler); messagesWebView.zoomProperty().addListener((observable, oldValue, newValue) -> { preferencesService.getPreferences().getChat().setZoom(newValue.doubleValue()); preferencesService.storeInBackground(); }); Double zoom = preferencesService.getPreferences().getChat().getZoom(); if (zoom != null) { messagesWebView.setZoom(zoom); } engine = messagesWebView.getEngine(); getJsObject().setMember(CHAT_TAB_REFERENCE_IN_JAVASCRIPT, this); engine.getLoadWorker().stateProperty().addListener((observable, oldValue, newValue) -> { if (Worker.State.SUCCEEDED == newValue) { synchronized (waitingMessages) { waitingMessages.forEach(AbstractChatTabController.this::appendMessage); waitingMessages.clear(); isChatReady = true; onWebViewLoaded(); } } }); try (InputStream inputStream = CHAT_HTML_RESOURCE.getInputStream()) { ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); ByteCopier.from(inputStream).to(byteArrayOutputStream).copy(); String chatContainerHtml = new String(byteArrayOutputStream.toByteArray(), StandardCharsets.UTF_8) .replace("{chat-container-js}", CHAT_JS_RESOURCE.getURL().toExternalForm()) .replace("{auto-linker-js}", AUTOLINKER_JS_RESOURCE.getURL().toExternalForm()) .replace("{jquery-js}", JQUERY_JS_RESOURCE.getURL().toExternalForm()) .replace("{jquery-highlight-js}", JQUERY_HIGHLIGHT_JS_RESOURCE.getURL().toExternalForm()); engine.loadContent(chatContainerHtml); } catch (IOException e) { throw new RuntimeException(e); } } protected abstract WebView getMessagesWebView(); protected JSObject getJsObject() { return (JSObject) engine.executeScript("window"); } protected void onWebViewLoaded() { // Default implementation does nothing, can be overridden by subclass. } /** * Called from JavaScript when user hovers over a user name. */ public void playerInfo(String username) { PlayerInfoBean playerInfoBean = playerService.getPlayerForUsername(username); if (playerInfoBean == null || playerInfoBean.isChatOnly()) { return; } playerCardTooltipController.setPlayer(playerInfoBean); playerCardTooltip = new Popup(); playerCardTooltip.getContent().setAll(playerCardTooltipController.getRoot()); playerCardTooltip.setAnchorLocation(PopupWindow.AnchorLocation.CONTENT_BOTTOM_LEFT); playerCardTooltip.show(getRoot().getTabPane(), lastMouseX, lastMouseY - 10); } /** * Called from JavaScript when user no longer hovers over a user name. */ public void hidePlayerInfo() { if (playerCardTooltip == null) { return; } playerCardTooltip.hide(); playerCardTooltip = null; } /** * Called from JavaScript when user clicks on user name in chat */ public void openPrivateMessageTab(String username) { eventBus.post(new InitiatePrivateChatEvent(username)); } /** * Called from JavaScript when user clicked a URL. */ public void openUrl(String url) { platformService.showDocument(url); } /** * Called from JavaScript when user hovers over an URL. */ public void previewUrl(String urlString) { Preview preview = urlPreviewResolver.resolvePreview(urlString); if (preview == null) { return; } linkPreviewTooltip = new Tooltip(preview.getDescription()); linkPreviewTooltip.setAutoHide(true); linkPreviewTooltip.setAnchorLocation(PopupWindow.AnchorLocation.CONTENT_BOTTOM_LEFT); linkPreviewTooltip.setGraphic(preview.getNode()); linkPreviewTooltip.setContentDisplay(ContentDisplay.TOP); linkPreviewTooltip.show(getRoot().getTabPane(), lastMouseX + 20, lastMouseY); } /** * Called from JavaScript when user no longer hovers over an URL. */ public void hideUrlPreview() { if (linkPreviewTooltip != null) { linkPreviewTooltip.hide(); linkPreviewTooltip = null; } } @FXML void onSendMessage() { TextInputControl messageTextField = getMessageTextField(); String text = messageTextField.getText(); if (StringUtils.isEmpty(text)) { return; } if (text.startsWith(ACTION_PREFIX)) { sendAction(messageTextField, text); } else if (text.startsWith(JOIN_PREFIX)) { chatService.joinChannel(text.replaceFirst(Pattern.quote(JOIN_PREFIX), "")); messageTextField.clear(); } else if (text.startsWith(WHOIS_PREFIX)) { chatService.whois(text.replaceFirst(Pattern.quote(JOIN_PREFIX), "")); messageTextField.clear(); } else { sendMessage(); } } private String getWordBeforeCaret(TextInputControl messageTextField) { messageTextField.selectPreviousWord(); String selectedText = messageTextField.getSelectedText(); messageTextField.positionCaret(messageTextField.getAnchor()); return selectedText; } private void sendMessage() { TextInputControl messageTextField = getMessageTextField(); messageTextField.setDisable(true); final String text = messageTextField.getText(); chatService.sendMessageInBackground(receiver, text).thenAccept(message -> { messageTextField.clear(); messageTextField.setDisable(false); messageTextField.requestFocus(); onChatMessage(new ChatMessage(null, Instant.now(), userService.getUsername(), message)); }).exceptionally(throwable -> { logger.warn("Message could not be sent: {}", text, throwable); notificationService.addNotification(new ImmediateNotification( i18n.get("errorTitle"), i18n.get("chat.sendFailed"), Severity.ERROR, throwable, Arrays.asList( new ReportAction(i18n, reportingService, throwable), new DismissAction(i18n)) )); messageTextField.setDisable(false); messageTextField.requestFocus(); return null; }); } private void sendAction(final TextInputControl messageTextField, final String text) { messageTextField.setDisable(true); chatService.sendActionInBackground(receiver, text.replaceFirst(Pattern.quote(ACTION_PREFIX), "")) .thenAccept(message -> { messageTextField.clear(); messageTextField.setDisable(false); messageTextField.requestFocus(); onChatMessage(new ChatMessage(null, Instant.now(), userService.getUsername(), message, true)); }).exceptionally(throwable -> { // TODO display error to user somehow logger.warn("Message could not be sent: {}", text, throwable); messageTextField.setDisable(false); return null; }); } protected void onChatMessage(ChatMessage chatMessage) { synchronized (waitingMessages) { if (!isChatReady) { waitingMessages.add(chatMessage); } else { Platform.runLater(() -> { appendMessage(chatMessage); removeTopmostMessages(); scrollToBottomIfDesired(); }); } } } private void scrollToBottomIfDesired() { engine.executeScript("scrollToBottomIfDesired()"); } private void removeTopmostMessages() { int maxMessageItems = preferencesService.getPreferences().getChat().getMaxMessages(); int numberOfMessages = (int) engine.executeScript("document.getElementsByClassName('" + MESSAGE_ITEM_CLASS + "').length"); while (numberOfMessages > maxMessageItems) { engine.executeScript("document.getElementsByClassName('" + MESSAGE_ITEM_CLASS + "')[0].remove()"); numberOfMessages--; } } private void appendMessage(ChatMessage chatMessage) { PlayerInfoBean playerInfoBean = playerService.getPlayerForUsername(chatMessage.getUsername()); try (Reader reader = new InputStreamReader(MESSAGE_ITEM_HTML_RESOURCE.getInputStream())) { String login = chatMessage.getUsername(); String html = CharStreams.toString(reader); String avatarUrl = ""; String clanTag = ""; if (playerInfoBean != null) { avatarUrl = playerInfoBean.getAvatarUrl(); if (StringUtils.isNotEmpty(playerInfoBean.getClan())) { clanTag = i18n.get("chat.clanTagFormat", playerInfoBean.getClan()); } } String text = htmlEscaper().escape(chatMessage.getMessage()).replace("\\", "\\\\"); text = convertUrlsToHyperlinks(text); Matcher matcher = mentionPattern.matcher(text); if (matcher.find()) { text = matcher.replaceAll("<span class='self'>" + matcher.group(1) + "</span>"); onMention(chatMessage); } String timeString = timeService.asShortTime(chatMessage.getTime()); html = html.replace("{time}", timeString) .replace("{avatar}", StringUtils.defaultString(avatarUrl)) .replace("{username}", login) .replace("{clan-tag}", clanTag) .replace("{text}", text); Collection<String> cssClasses = new ArrayList<>(); cssClasses.add(String.format("user-%s", chatMessage.getUsername())); if (chatMessage.isAction()) { cssClasses.add(ACTION_CSS_CLASS); } else { cssClasses.add(MESSAGE_CSS_CLASS); } String messageColorClass = getMessageCssClass(login); if (messageColorClass != null) { cssClasses.add(messageColorClass); } html = html.replace("{css-classes}", Joiner.on(' ').join(cssClasses)); html = html.replace("{inline-style}", getInlineStyle(login)); addToMessageContainer(html); } catch (IOException e) { throw new RuntimeException(e); } } protected void onMention(ChatMessage chatMessage) { // Default implementation does nothing } protected void showNotificationIfNecessary(ChatMessage chatMessage) { if (stage.isFocused() && stage.isShowing()) { return; } PlayerInfoBean player = playerService.getPlayerForUsername(chatMessage.getUsername()); String identiconSource = player != null ? String.valueOf(player.getId()) : chatMessage.getUsername(); notificationService.addNotification(new TransientNotification( chatMessage.getUsername(), chatMessage.getMessage(), IdenticonUtil.createIdenticon(identiconSource), event -> { mainController.selectChatTab(); stage.toFront(); getRoot().getTabPane().getSelectionModel().select(getRoot()); }) ); } protected String getMessageCssClass(String login) { String cssClass; PlayerInfoBean playerInfoBean = playerService.getPlayerForUsername(login); if (playerInfoBean == null) { return CSS_CLASS_CHAT_ONLY; } else { cssClass = playerInfoBean.getSocialStatus().getCssClass(); } if (cssClass.equals("") && playerInfoBean.isChatOnly()) { cssClass = CSS_CLASS_CHAT_ONLY; } return cssClass; } @VisibleForTesting String getInlineStyle(String username) { ChatUser chatUser = chatService.getOrCreateChatUser(username); PlayerInfoBean player = playerService.getPlayerForUsername(username); ChatPrefs chatPrefs = preferencesService.getPreferences().getChat(); String color = ""; String display = ""; if (chatPrefs.getHideFoeMessages() && player != null && player.getSocialStatus() == FOE) { display = "display: none;"; } else if (player != null && (player.getSocialStatus() == SELF || player.getSocialStatus() == FRIEND)) { return ""; } else { ChatColorMode chatColorMode = chatPrefs.getChatColorMode(); if ((chatColorMode == ChatColorMode.CUSTOM || chatColorMode == ChatColorMode.RANDOM) && chatUser.getColor() != null) { color = createInlineStyleFromColor(chatUser.getColor()); } } return String.format("style=\"%s%s\"", color, display); } @VisibleForTesting String createInlineStyleFromColor(Color messageColor) { return String.format("color: %s;", JavaFxUtil.toRgbCode(messageColor)); } protected String convertUrlsToHyperlinks(String text) { return (String) engine.executeScript("link('" + text.replace("'", "\\'") + "')"); } private void addToMessageContainer(String html) { ((JSObject) engine.executeScript("document.getElementById('" + MESSAGE_CONTAINER_ID + "')")) .call("insertAdjacentHTML", "beforeend", html); } }