/* * Jitsi, the OpenSource Java VoIP and Instant Messaging client. * * Copyright @ 2015 Atlassian Pty Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package net.java.sip.communicator.impl.gui.main.chat; import java.awt.*; import java.awt.datatransfer.*; import java.awt.event.*; import java.io.*; import java.net.*; import java.text.*; import java.util.*; import java.util.Map; import java.util.regex.*; import javax.swing.*; import javax.swing.event.*; import javax.swing.text.*; import javax.swing.text.html.*; import javax.swing.text.html.HTML.Attribute; import net.java.sip.communicator.impl.gui.*; import net.java.sip.communicator.impl.gui.main.chat.history.*; import net.java.sip.communicator.impl.gui.main.chat.menus.*; import net.java.sip.communicator.impl.gui.main.chat.replacers.*; import net.java.sip.communicator.impl.gui.utils.*; import net.java.sip.communicator.impl.gui.utils.Constants; import net.java.sip.communicator.plugin.desktoputil.*; import net.java.sip.communicator.plugin.desktoputil.SwingWorker; import net.java.sip.communicator.service.gui.*; import net.java.sip.communicator.service.history.*; import net.java.sip.communicator.service.protocol.*; import net.java.sip.communicator.service.replacement.*; import net.java.sip.communicator.service.replacement.directimage.*; import net.java.sip.communicator.service.replacement.smilies.*; import net.java.sip.communicator.util.*; import net.java.sip.communicator.util.Logger; import net.java.sip.communicator.util.skin.*; import org.apache.commons.lang3.*; import org.jitsi.service.configuration.*; import org.jitsi.service.fileaccess.*; import org.jitsi.util.StringUtils; import org.osgi.framework.*; /** * The ChatConversationPanel is the panel, where all sent and received * messages appear. All data is stored in an HTML document. An external CSS file * is applied to the document to provide the look&feel. All smileys and link * strings are processed and finally replaced by corresponding images and HTML * links. * * @author Yana Stamcheva * @author Lyubomir Marinov * @author Adam Netocny * @author Danny van Heumen */ public class ChatConversationPanel extends SIPCommScrollPane implements HyperlinkListener, MouseListener, ClipboardOwner, Skinnable { /** * The Logger used by the ChatConversationPanel class and * its instances for logging output. */ private static final Logger logger = Logger.getLogger(ChatConversationPanel.class); /** * The regular expression (in the form of compiled Pattern) which * matches URLs for the purposed of turning them into links. * * TODO Current pattern misses tailing '/' (slash) that is sometimes * included in URL's. (Danny) * * TODO Current implementation misses # after ? has been encountered in URL. * (Danny) */ private static final Pattern URL_PATTERN = Pattern.compile( "(" + "(\\bwww\\.[^\\s<>\"]+\\.[^\\s<>\"]+/*[?#]*(\\w+[&=;?]\\w+)*\\b)" // wwwURL + "|" + "(\\bjitsi\\:[^\\s<>\"]+\\.[^\\s<>\"]*\\b)" // internalURL + "|" + "(\\b\\w+://[^\\s<>\"]+/*[?#]*(\\w+[&=;?]\\w+)*\\b)" // protocolURL + ")"); /** * A regular expression that matches a
tag and its contents. * The opening tag is group 1, and the tag contents is group 2 when * a match is found. */ private static final Pattern DIV_PATTERN = Pattern.compile("(]*>)(.*)(
)", Pattern.DOTALL); /** * A regular expression for searching all pieces of plain text within a blob * of HTML text. This expression assumes that the plain text part is * correctly escaped, such that there is no occurrence of the symbols < * and >. * *
     * In essence this regexp pattern works as follows:
     * 1. Find all the text that isn't the start of a tag. (so all chars != '<')
     *    -> This is your actual result: textual content that is not part of a
     *    tag.
     * 2. Then, if you find a '<', find as much chars as you can until you find
     *    '>' (if it is possible at all to find a closing '>')
     *
     *    In depth explanation of 2.:
     *    The text between tags consists mainly of 2 parts:
     *
     *    A) a piece of text
     *    B) some value "between quotes"
     *
     *    So everything up to the "quote" is part of a piece of text (A). Then
     *    if we encounter a "quote" we consider the rest of the text part of the
     *    value (B) until the value section is closed with a closing "quote".
     *    (We tend to be rather greedy, so we even swallow '>' along the way
     *    looking for the closing "quote".)
     *
     *    This subpattern is allowed any number of times, until eventually the
     *    closing '>' is encountered. (Or not if the pattern is incomplete.)
     *
     * 3. And consider that 2. is optional, since it could also be that we only
     *    find plain text, which would all be captured by 1.
     * 
* *

The first group matches any piece of text outside of the < and > * brackets that define the start and end of HTML tags.

*/ static final Pattern TEXT_TO_REPLACE_PATTERN = Pattern.compile( "([^<]*+)(?:<(?:[^>\"]*(?:\"[^\"]*+\"?)*)*+>?)?", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); /** * List for observing text messages. */ private Set chatLinkClickedListeners = new HashSet(); /** * The component rendering chat conversation panel text. */ private final JTextPane chatTextPane = new MyTextPane(); /** * The editor kit used by the text component. */ private final ChatConversationEditorKit editorKit; /** * The document used by the text component. */ HTMLDocument document; /** * The parent container. */ private final ChatConversationContainer chatContainer; /** * The menu shown on right button mouse click. */ private ChatRightButtonMenu rightButtonMenu; /** * The currently shown href. */ private String currentHref; /** * The currently shown href, is it an img element. */ private boolean isCurrentHrefImg = false; /** * The copy link item, contained in the right mouse click menu. */ private final JMenuItem copyLinkItem; /** * The copy link item, contained in the right mouse click menu. */ private final JMenuItem configureReplacementItem; /** * The configure replacement item separator. */ private final JSeparator configureReplacementSeparator = new JSeparator(); /** * The open link item, contained in the right mouse click menu. */ private final JMenuItem openLinkItem; /** * The right mouse click menu separator. */ private final JSeparator copyLinkSeparator = new JSeparator(); /** * The timestamp of the last incoming message. */ private Date lastIncomingMsgTimestamp = new Date(0); /** * The timestamp of the last message. */ private Date lastMessageTimestamp = new Date(0); /** * Indicates if this component is rendering a history conversation. */ private final boolean isHistory; /** * The indicator which determines whether an automatic scroll to the bottom * of {@link #chatTextPane} is to be performed. */ private boolean scrollToBottomIsPending = false; private String lastMessageUID = null; private boolean isSimpleTheme = true; private ShowPreviewDialog showPreview = new ShowPreviewDialog(ChatConversationPanel.this); /** * The implementation of the routine which scrolls {@link #chatTextPane} to * its bottom. */ private final Runnable scrollToBottomRunnable = new Runnable() { /* * Implements Runnable#run(). */ public void run() { JScrollBar verticalScrollBar = getVerticalScrollBar(); if (verticalScrollBar != null) { // We need to call both methods in order to be sure to scroll // to the bottom of the text even when the user has selected // something (changed the caret) or when a new tab has been // added or the window has been resized. verticalScrollBar.setValue(verticalScrollBar.getMaximum()); Document doc = chatTextPane.getDocument(); if(doc != null) { int pos = document.getLength(); if (pos >= 0 && pos <= chatTextPane.getDocument().getLength()) { chatTextPane.setCaretPosition(pos); } } } } }; /** * Creates an instance of ChatConversationPanel. * * @param chatContainer The parent ChatConversationContainer. */ public ChatConversationPanel(ChatConversationContainer chatContainer) { editorKit = new ChatConversationEditorKit(this); this.chatContainer = chatContainer; isHistory = (chatContainer instanceof HistoryWindow); this.rightButtonMenu = new ChatRightButtonMenu(this); this.document = (HTMLDocument) editorKit.createDefaultDocument(); this.document.addDocumentListener(editorKit); this.chatTextPane.setEditorKitForContentType("text/html", editorKit); this.chatTextPane.setEditorKit(editorKit); this.chatTextPane.setEditable(false); this.chatTextPane.setDocument(document); this.chatTextPane.setDragEnabled(true); chatTextPane.putClientProperty( JEditorPane.HONOR_DISPLAY_PROPERTIES, Boolean.TRUE); Constants.loadSimpleStyle( document.getStyleSheet(), chatTextPane.getFont()); this.chatTextPane.addHyperlinkListener(this); this.chatTextPane.addMouseListener(this); this.chatTextPane.setCursor( Cursor.getPredefinedCursor(Cursor.TEXT_CURSOR)); this.addChatLinkClickedListener(showPreview); this.setWheelScrollingEnabled(true); this.setViewportView(chatTextPane); this.setBorder(null); this.setHorizontalScrollBarPolicy( JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); ToolTipManager.sharedInstance().registerComponent(chatTextPane); String copyLinkString = GuiActivator.getResources().getI18NString("service.gui.COPY_LINK"); copyLinkItem = new JMenuItem(copyLinkString, new ImageIcon(ImageLoader.getImage(ImageLoader.COPY_ICON))); copyLinkItem.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent e) { StringSelection stringSelection = new StringSelection( currentHref); Clipboard clipboard = Toolkit.getDefaultToolkit() .getSystemClipboard(); clipboard.setContents(stringSelection, ChatConversationPanel.this); } }); String openLinkString = GuiActivator.getResources().getI18NString( "service.gui.OPEN_IN_BROWSER"); openLinkItem = new JMenuItem( openLinkString, new ImageIcon(ImageLoader.getImage(ImageLoader.BROWSER_ICON))); openLinkItem.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent e) { GuiActivator.getBrowserLauncher().openURL(currentHref); // after opening the link remove the currentHref to avoid // clicking on the window to gain focus to open the link again ChatConversationPanel.this.currentHref = ""; } }); openLinkItem.setMnemonic( GuiActivator.getResources().getI18nMnemonic( "service.gui.OPEN_IN_BROWSER")); copyLinkItem.setMnemonic( GuiActivator.getResources().getI18nMnemonic( "service.gui.COPY_LINK")); configureReplacementItem = new JMenuItem( GuiActivator.getResources().getI18NString( "plugin.chatconfig.replacement.CONFIGURE_REPLACEMENT"), GuiActivator.getResources().getImage( "service.gui.icons.CONFIGURE_ICON")); configureReplacementItem.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent e) { final ConfigurationContainer configContainer = GuiActivator.getUIService().getConfigurationContainer(); ConfigurationForm chatConfigForm = getChatConfigForm(); if (chatConfigForm != null) { configContainer.setSelected(chatConfigForm); configContainer.setVisible(true); } } }); this.isSimpleTheme = ConfigurationUtils.isChatSimpleThemeEnabled(); /* * When we append a new message (regardless of whether it is a string or * an UI component), we want to make it visible in the viewport of this * JScrollPane so that the user can see it. */ ComponentListener componentListener = new ComponentAdapter() { @Override public void componentResized(ComponentEvent e) { synchronized (scrollToBottomRunnable) { if (!scrollToBottomIsPending) return; scrollToBottomIsPending = false; /* * Yana Stamcheva, pointed out that Java 5 (on Linux only?) * needs invokeLater for JScrollBar. */ SwingUtilities.invokeLater(scrollToBottomRunnable); } } }; chatTextPane.addComponentListener(componentListener); getViewport().addComponentListener(componentListener); } /** * Overrides Component#setBounds(int, int, int, int) in order to determine * whether an automatic scroll of #chatTextPane to its bottom will be * necessary at a later time in order to keep its vertical scroll bar to its * bottom after the realization of the resize if it is at its bottom before * the resize. */ @Override public void setBounds(int x, int y, int width, int height) { synchronized (scrollToBottomRunnable) { JScrollBar verticalScrollBar = getVerticalScrollBar(); if (verticalScrollBar != null) { BoundedRangeModel verticalScrollBarModel = verticalScrollBar.getModel(); if ((verticalScrollBarModel.getValue() + verticalScrollBarModel.getExtent() >= verticalScrollBarModel.getMaximum()) || !verticalScrollBar.isVisible()) scrollToBottomIsPending = true; } } super.setBounds(x, y, width, height); } /** * Retrieves the contents of the sent message with the given ID. * * @param messageUID The ID of the message to retrieve. * @return The contents of the message, or null if the message is not found. */ public String getMessageContents(String messageUID) { Element root = document.getDefaultRootElement(); Element e = document.getElement( root, Attribute.ID, ChatHtmlUtils.MESSAGE_TEXT_ID + messageUID); if (e == null) { logger.warn("Could not find message with ID " + messageUID); return null; } Object original_message = e.getAttributes().getAttribute( ChatHtmlUtils.ORIGINAL_MESSAGE_ATTRIBUTE); if (original_message == null) { logger.warn("Message with ID " + messageUID + " does not have original_message attribute"); return null; } String res = new String(net.java.sip.communicator.util.Base64 .decode(original_message.toString())); // Remove all newline characters that were inserted to make copying // newlines from the conversation panel work. // They shouldn't be in the write panel, because otherwise a newline // would consist of two chars, one of them invisible (the ), but // both of them have to be deleted in order to remove it. // On the other hand this means that copying newlines from the write // area produces only spaces, but this seems like the better option. res = res.replace(" ", ""); return res; } /** * Processes the message given by the parameters. * * @param chatMessage the message * @param keyword a substring of chatMessage to be highlighted upon * display of chatMessage in the UI * @return the processed message */ public String processMessage( ChatMessage chatMessage, String keyword, ProtocolProviderService protocolProvider, String contactAddress) { // If this is a consecutive message don't go through the initiation // and just append it. if (isConsecutiveMessage(chatMessage)) { appendConsecutiveMessage(chatMessage, keyword); return null; } String contentType = chatMessage.getContentType(); lastMessageTimestamp = chatMessage.getDate(); String contactName = chatMessage.getContactName(); String contactDisplayName = chatMessage.getContactDisplayName(); if (contactDisplayName == null || contactDisplayName.trim().length() <= 0) contactDisplayName = contactName; else { // for some reason ' is not rendered correctly from our ui, // lets use its equivalent. Other similar chars(< > & ") seem ok. contactDisplayName = contactDisplayName.replaceAll("'", "'"); } Date date = chatMessage.getDate(); String messageType = chatMessage.getMessageType(); String messageTitle = chatMessage.getMessageTitle(); String message = chatMessage.getMessage(); String chatString = ""; String endHeaderTag = ""; lastMessageUID = chatMessage.getMessageUID(); if (messageType.equals(Chat.INCOMING_MESSAGE)) { this.lastIncomingMsgTimestamp = new Date(); chatString = ChatHtmlUtils.createIncomingMessageTag( lastMessageUID, contactName, contactDisplayName, getContactAvatar(protocolProvider, contactAddress), date, formatMessageAsHTML(message, contentType, keyword), ChatHtmlUtils.HTML_CONTENT_TYPE, false, isSimpleTheme); } else if (messageType.equals(Chat.OUTGOING_MESSAGE)) { chatString = ChatHtmlUtils.createOutgoingMessageTag( lastMessageUID, contactName, contactDisplayName, getContactAvatar(protocolProvider), date, formatMessageAsHTML(message, contentType, keyword), ChatHtmlUtils.HTML_CONTENT_TYPE, false, isSimpleTheme); } else if (messageType.equals(Chat.HISTORY_INCOMING_MESSAGE)) { chatString = ChatHtmlUtils.createIncomingMessageTag( lastMessageUID, contactName, contactDisplayName, getContactAvatar(protocolProvider, contactAddress), date, formatMessageAsHTML(message, contentType, keyword), ChatHtmlUtils.HTML_CONTENT_TYPE, true, isSimpleTheme); } else if (messageType.equals(Chat.HISTORY_OUTGOING_MESSAGE)) { chatString = ChatHtmlUtils.createOutgoingMessageTag( lastMessageUID, contactName, contactDisplayName, getContactAvatar(protocolProvider), date, formatMessageAsHTML(message, contentType, keyword), ChatHtmlUtils.HTML_CONTENT_TYPE, true, isSimpleTheme); } else if (messageType.equals(Chat.SMS_MESSAGE)) { chatString = ChatHtmlUtils.createIncomingMessageTag( lastMessageUID, contactName, contactDisplayName, getContactAvatar(protocolProvider, contactAddress), date, ConfigurationUtils.isSmsNotifyTextDisabled() ? formatMessageAsHTML(message, contentType, keyword) : formatMessageAsHTML("SMS: " + message, contentType, keyword), ChatHtmlUtils.HTML_CONTENT_TYPE, false, isSimpleTheme); } else if (messageType.equals(Chat.STATUS_MESSAGE)) { chatString = "
"; endHeaderTag = "
"; chatString += GuiUtils.formatTime(date) + " " + StringEscapeUtils.escapeHtml4(contactName) + " " + formatMessageAsHTML(message, contentType, keyword) + endHeaderTag; } else if (messageType.equals(Chat.ACTION_MESSAGE)) { chatString = "

"; endHeaderTag = "

"; chatString += "* " + GuiUtils.formatTime(date) + " " + StringEscapeUtils.escapeHtml4(contactName) + " " + formatMessageAsHTML(message, contentType, keyword) + endHeaderTag; } else if (messageType.equals(Chat.SYSTEM_MESSAGE)) { String startSystemDivTag = "
"; String endDivTag = "
"; chatString += startSystemDivTag + formatMessageAsHTML(message, contentType, keyword) + endDivTag; } else if (messageType.equals(Chat.ERROR_MESSAGE)) { chatString = "
"; endHeaderTag = "
"; String errorIcon = ""; // If the message title is null do not show it and show the error // icon on the same line as the actual error message. if (messageTitle != null) { chatString += errorIcon + StringEscapeUtils.escapeHtml4(messageTitle) + endHeaderTag + "
" + formatMessageAsHTML(message, contentType, keyword) + "
"; } else { chatString += endHeaderTag + "
" + errorIcon + " " + formatMessageAsHTML(message, contentType, keyword) + "
"; } } return chatString; } /** * Processes the message given by the parameters. * * @param chatMessage the message. * @return the formatted message */ public String processMessage( ChatMessage chatMessage, ProtocolProviderService protocolProvider, String contactAddress) { return processMessage( chatMessage, null, protocolProvider, contactAddress); } /** * Appends a consecutive message to the document. * * @param chatMessage the message to append * @param keyword the keywords to highlight */ public void appendConsecutiveMessage(final ChatMessage chatMessage, final String keyword) { String previousMessageUID = lastMessageUID; lastMessageUID = chatMessage.getMessageUID(); if (!SwingUtilities.isEventDispatchThread()) { SwingUtilities.invokeLater(new Runnable() { public void run() { appendConsecutiveMessage(chatMessage, keyword); } }); return; } Element lastMsgElement = document.getElement( ChatHtmlUtils.MESSAGE_TEXT_ID + previousMessageUID); String contactAddress = (String) lastMsgElement.getAttributes() .getAttribute(Attribute.NAME); boolean isHistory = (chatMessage.getMessageType() .equals(Chat.HISTORY_INCOMING_MESSAGE) || chatMessage.getMessageType() .equals(Chat.HISTORY_OUTGOING_MESSAGE)) ? true : false; String newMessage = ChatHtmlUtils.createMessageTag( chatMessage.getMessageUID(), contactAddress, formatMessageAsHTML( chatMessage.getMessage(), chatMessage.getContentType(), keyword), ChatHtmlUtils.HTML_CONTENT_TYPE, chatMessage.getDate(), false, isHistory, isSimpleTheme); synchronized (scrollToBottomRunnable) { try { Element parentElement = lastMsgElement.getParentElement(); document.insertBeforeEnd(parentElement, newMessage); // Need to call explicitly scrollToBottom, because for some // reason the componentResized event isn't fired every time // we add text. SwingUtilities.invokeLater(scrollToBottomRunnable); } catch (BadLocationException ex) { logger.error("Could not replace chat message", ex); } catch (IOException ex) { logger.error("Could not replace chat message", ex); } } finishMessageAdd(newMessage); } /** * Replaces the contents of the message with ID of the corrected message * specified in chatMessage, with this message. * * @param chatMessage A ChatMessage that contains all the required * information to correct the old message. */ public void correctMessage(final ChatMessage chatMessage) { lastMessageUID = chatMessage.getMessageUID(); if (!SwingUtilities.isEventDispatchThread()) { SwingUtilities.invokeLater(new Runnable() { public void run() { correctMessage(chatMessage); } }); return; } String correctedUID = chatMessage.getCorrectedMessageUID(); Element root = document.getDefaultRootElement(); Element correctedMsgElement = document.getElement(root, Attribute.ID, ChatHtmlUtils.MESSAGE_TEXT_ID + correctedUID); if (correctedMsgElement == null) { logger.warn("Could not find message with ID " + correctedUID); return; } String contactAddress = (String) correctedMsgElement.getAttributes() .getAttribute(Attribute.NAME); boolean isHistory = (chatMessage.getMessageType() .equals(Chat.HISTORY_INCOMING_MESSAGE) || chatMessage.getMessageType() .equals(Chat.HISTORY_OUTGOING_MESSAGE)) ? true : false; String newMessage = ChatHtmlUtils.createMessageTag( chatMessage.getMessageUID(), contactAddress, formatMessageAsHTML(chatMessage.getMessage(), chatMessage.getContentType(), ""), ChatHtmlUtils.HTML_CONTENT_TYPE, chatMessage.getDate(), true, isHistory, isSimpleTheme); synchronized (scrollToBottomRunnable) { try { document.setOuterHTML(correctedMsgElement, newMessage); // Need to call explicitly scrollToBottom, because for some // reason the componentResized event isn't fired every time // we add text. SwingUtilities.invokeLater(scrollToBottomRunnable); } catch (BadLocationException ex) { logger.error("Could not replace chat message", ex); } catch (IOException ex) { logger.error("Could not replace chat message", ex); } } finishMessageAdd(newMessage); } /** * Appends the given string at the end of the contained in this panel * document. * * Note: Currently, it looks like appendMessageToEnd is only called for * messages that are already converted to HTML. So It is quite possible that * we can remove the content type without any issues. * * @param original the message string to append * @param contentType the message's content type */ public void appendMessageToEnd(final String original, final String contentType) { if (!SwingUtilities.isEventDispatchThread()) { SwingUtilities.invokeLater(new Runnable() { public void run() { appendMessageToEnd(original, contentType); } }); return; } if (original == null) { return; } final String message; if (ChatHtmlUtils.HTML_CONTENT_TYPE.equalsIgnoreCase(contentType)) { message = original; } else { message = StringEscapeUtils.escapeHtml4(original); } synchronized (scrollToBottomRunnable) { Element root = document.getDefaultRootElement(); try { document.insertBeforeEnd( // the body element root.getElement(root.getElementCount() - 1), // the message to insert message); // Need to call explicitly scrollToBottom, because for some // reason the componentResized event isn't fired every time we // add text. SwingUtilities.invokeLater(scrollToBottomRunnable); } catch (BadLocationException e) { logger.error("Insert in the HTMLDocument failed.", e); } catch (IOException e) { logger.error("Insert in the HTMLDocument failed.", e); } } String lastElemContent = getElementContent(lastMessageUID, message); if (lastElemContent != null) { finishMessageAdd(lastElemContent); } } /** * Performs all operations needed in order to finish the adding of the * message to the document. * * @param message the message string * @param contentType */ private void finishMessageAdd(final String message) { // If we're not in chat history case we need to be sure the document // has not exceeded the required size (number of messages). if (!isHistory) ensureDocumentSize(); /* * Replacements will be processed only if it is enabled in the * property. */ ConfigurationService cfg = GuiActivator.getConfigurationService(); if (cfg.getBoolean(ReplacementProperty.REPLACEMENT_ENABLE, true) ||cfg.getBoolean(ReplacementProperty.REPLACEMENT_PROPOSAL, true) || cfg.getBoolean( ReplacementProperty.getPropertyName("SMILEY"), true)) { processReplacement(ChatHtmlUtils.MESSAGE_TEXT_ID + lastMessageUID, message); } } /** * Formats the given message. Processes the messages and replaces links to * video/image sources with their previews or any other substitution. Spawns * a separate thread for replacement. * * @param messageID the messageID element. * @param chatString the message. */ void processReplacement(final String messageID, final String chatString) { new ReplacementWorker(messageID, chatString).start(); } /** * Ensures that the document won't become too big. When the document reaches * a certain size the first message in the page is removed. */ private void ensureDocumentSize() { if (document.getLength() > Chat.CHAT_BUFFER_SIZE) { String[] ids = new String[] {ChatHtmlUtils.MESSAGE_TEXT_ID, "statusMessage", "systemMessage", "actionMessage"}; Element firstMsgElement = findElement(Attribute.ID, ids); int startIndex = firstMsgElement.getStartOffset(); int endIndex = firstMsgElement.getEndOffset(); try { // Remove the message. this.document.remove(startIndex, endIndex - startIndex); } catch (BadLocationException e) { logger.error("Error removing messages from chat: ", e); } if(firstMsgElement.getName().equals("table")) { // as we have removed a header for maybe several messages, // delete all messages without header deleteAllMessagesWithoutHeader(); } } } /** * Deletes all messages "div"s that are missing their header the table tag. * The method calls itself recursively. */ private void deleteAllMessagesWithoutHeader() { String[] ids = new String[] {ChatHtmlUtils.MESSAGE_TEXT_ID, "statusMessage", "systemMessage", "actionMessage"}; Element firstMsgElement = findElement(Attribute.ID, ids); if(firstMsgElement == null || !firstMsgElement.getName().equals("div")) { return; } int startIndex = firstMsgElement.getStartOffset(); int endIndex = firstMsgElement.getEndOffset(); try { // Remove the message. if(endIndex - startIndex < document.getLength()) this.document.remove(startIndex, endIndex - startIndex); else { // currently there is a problem of deleting the last message // if it is the last message on the view return; } } catch (BadLocationException e) { logger.error("Error removing messages from chat: ", e); return; } deleteAllMessagesWithoutHeader(); } /** * Formats the given message. Processes all smiley chars, new lines and * links. This method expects only the message's body to be * provided. * * @param message the message to be formatted * @param contentType the content type of the message to be formatted * @param keyword the word to be highlighted * @return the formatted message */ private String formatMessageAsHTML(final String original, final String contentType, final String keyword) { if (original == null) { return ""; } // prepare source message String source; if (ChatHtmlUtils.HTML_CONTENT_TYPE.equals(contentType)) { source = original; } else { source = StringEscapeUtils.escapeHtml4(original); } return processReplacers(source, new NewlineReplacer(), new URLReplacer(URL_PATTERN), new KeywordReplacer(keyword), new BrTagReplacer(), new ImgTagReplacer()); } /** * Process provided replacers one by one sequentially. The output of the * first replacer is then fed as input into the second replacer, and so on. *

* {@link Replacer}s that expect HTML content ( * {@link Replacer#expectsPlainText()}) will typically receive the complete * message as an argument. {@linkplain Replacer}s that expect plain text * content will typically receive small pieces that are found in between * HTML tags. The pieces of plain text content cannot be predicted as * results change when they are processed by other replacers. *

* * @param content the original content to process * @param replacers the replacers to call * @return returns the final result message content after it has been * processed by all replacers */ private String processReplacers(final String content, final Replacer... replacers) { StringBuilder source = new StringBuilder(content); for (final Replacer replacer : replacers) { final StringBuilder target = new StringBuilder(); if (replacer.expectsPlainText()) { int startPos = 0; final Matcher plainTextInHtmlMatcher = TEXT_TO_REPLACE_PATTERN.matcher(source); while (plainTextInHtmlMatcher.find()) { final String plainTextAsHtml = plainTextInHtmlMatcher.group(1); final int startMatchPosition = plainTextInHtmlMatcher.start(1); final int endMatchPosition = plainTextInHtmlMatcher.end(1); target.append(source .substring(startPos, startMatchPosition)); final String plaintext = StringEscapeUtils.unescapeHtml4(plainTextAsHtml); // Invoke replacer. try { replacer.replace(target, plaintext); } catch (RuntimeException e) { logger.error("An error occurred in replacer: " + replacer.getClass().getName(), e); } startPos = endMatchPosition; } target.append(source.substring(startPos)); } else { // Invoke replacer. try { replacer.replace(target, source.toString()); } catch (RuntimeException e) { logger.error("An error occurred in replacer: " + replacer.getClass().getName(), e); } } source = target; } return source.toString(); } /** * Opens a link in the default browser when clicked and shows link url in a * popup on mouseover. * * @param e The HyperlinkEvent. */ public void hyperlinkUpdate(HyperlinkEvent e) { if (e.getEventType() == HyperlinkEvent.EventType.ENTERED) { String href = e.getDescription(); this.isCurrentHrefImg = e.getSourceElement().getName().equals("img"); this.currentHref = href; } else if (e.getEventType() == HyperlinkEvent.EventType.EXITED) { this.currentHref = ""; this.isCurrentHrefImg = false; } } /** * Returns the text pane of this conversation panel. * * @return The text pane of this conversation panel. */ public JTextPane getChatTextPane() { return chatTextPane; } /** * Returns the time of the last received message. * * @return The time of the last received message. */ public Date getLastIncomingMsgTimestamp() { return lastIncomingMsgTimestamp; } /** * When a right button click is performed in the editor pane, a popup menu * is opened. * In case of the Scheme being internal, it won't open the Browser but * instead it will trigger the forwarded action. * * @param e The MouseEvent. */ public void mouseClicked(MouseEvent e) { Point p = e.getPoint(); SwingUtilities.convertPointToScreen(p, e.getComponent()); if ((e.getModifiers() & InputEvent.BUTTON3_MASK) != 0 || (e.isControlDown() && !e.isMetaDown())) { openContextMenu(p); } else if ((e.getModifiers() & InputEvent.BUTTON1_MASK) != 0 && currentHref != null && currentHref.length() != 0) { URI uri; try { uri = new URI(currentHref); } catch (URISyntaxException e1) { logger.error("Failed to open hyperlink in chat window. " + "Error was: Invalid URL - " + currentHref); return; } if("jitsi".equals(uri.getScheme())) { for(ChatLinkClickedListener l:chatLinkClickedListeners) { l.chatLinkClicked(uri); } } else GuiActivator.getBrowserLauncher().openURL(currentHref); // after opening the link remove the currentHref to avoid // clicking on the window to gain focus to open the link again this.currentHref = ""; } } /** * Opens this panel context menu at the given point. * * @param p the point where to position the left-top cornet of the context * menu */ private void openContextMenu(Point p) { if (currentHref != null && currentHref.length() != 0 && !currentHref.startsWith("jitsi://")) { rightButtonMenu.insert(openLinkItem, 0); rightButtonMenu.insert(copyLinkItem, 1); rightButtonMenu.insert(copyLinkSeparator, 2); if(isCurrentHrefImg) { rightButtonMenu.insert(configureReplacementItem, 3); rightButtonMenu.insert(configureReplacementSeparator, 4); } } else { rightButtonMenu.remove(openLinkItem); rightButtonMenu.remove(copyLinkItem); rightButtonMenu.remove(copyLinkSeparator); rightButtonMenu.remove(configureReplacementItem); rightButtonMenu.remove(configureReplacementSeparator); } if (chatTextPane.getSelectedText() != null) { rightButtonMenu.enableCopy(); } else { rightButtonMenu.disableCopy(); } rightButtonMenu.setInvoker(chatTextPane); rightButtonMenu.setLocation(p.x, p.y); rightButtonMenu.setVisible(true); } public void mousePressed(MouseEvent e) {} public void mouseReleased(MouseEvent e) {} public void mouseEntered(MouseEvent e) {} public void mouseExited(MouseEvent e) {} public void lostOwnership(Clipboard clipboard, Transferable contents) {} /** * Returns the chat container. * * @return the chat container */ public ChatConversationContainer getChatContainer() { return chatContainer; } /** * Copies the selected conversation panel content to the clipboard. */ public void copyConversation() { this.chatTextPane.copy(); } /** * Creates new document and all the messages that will be processed in the * future will be appended in it. */ public void clear() { this.document = (HTMLDocument) editorKit.createDefaultDocument(); Constants.loadSimpleStyle( document.getStyleSheet(), chatTextPane.getFont()); this.isSimpleTheme = ConfigurationUtils.isChatSimpleThemeEnabled(); } /** * Sets the given document to the editor pane in this panel. * * @param document the document to set */ public void setContent(final HTMLDocument document) { if (!SwingUtilities.isEventDispatchThread()) { SwingUtilities.invokeLater(new Runnable() { public void run() { setContent(document); } }); return; } synchronized (scrollToBottomRunnable) { scrollToBottomIsPending = true; this.document = document; chatTextPane.setDocument(this.document); } } /** * Sets the default document contained in this panel, created on init or * when clear is invoked. */ public void setDefaultContent() { setContent(document); } /** * Returns the document contained in this panel. * * @return the document contained in this panel */ public HTMLDocument getContent() { return (HTMLDocument) this.chatTextPane.getDocument(); } /** * Returns the right button popup menu. * * @return the right button popup menu */ public ChatRightButtonMenu getRightButtonMenu() { return rightButtonMenu; } /** * Returns the date of the first message in the current page. * * @return the date of the first message in the current page */ public Date getPageFirstMsgTimestamp() { Element firstHeaderElement = document.getElement(ChatHtmlUtils.MESSAGE_HEADER_ID); if(firstHeaderElement == null) return new Date(Long.MAX_VALUE); String dateObject = firstHeaderElement .getAttributes().getAttribute(ChatHtmlUtils.DATE_ATTRIBUTE) .toString(); SimpleDateFormat sdf = new SimpleDateFormat(HistoryService.DATE_FORMAT); try { return sdf.parse(dateObject); } catch (ParseException e) { return new Date(0); } } /** * Returns the date of the last message in the current page. * * @return the date of the last message in the current page */ public Date getPageLastMsgTimestamp() { Date timestamp = new Date(0); if (lastMessageUID != null) { Element lastMsgElement = document.getElement( ChatHtmlUtils.MESSAGE_TEXT_ID + lastMessageUID); if (lastMsgElement != null) { Object date = lastMsgElement.getAttributes().getAttribute( ChatHtmlUtils.DATE_ATTRIBUTE); SimpleDateFormat sdf = new SimpleDateFormat(HistoryService.DATE_FORMAT); if (date != null) { try { timestamp = sdf.parse(date.toString()); } catch (ParseException e) {} } } } return timestamp; } /** * Extend Editor pane to add URL tooltips. */ private class MyTextPane extends JTextPane { /** * Returns the string to be used as the tooltip for event. * * @param event the MouseEvent * @return the string to be used as the tooltip for event. */ @Override public String getToolTipText(MouseEvent event) { return ((currentHref != null) && (currentHref.length() != 0)) ? currentHref : null; } } /** * Adds a custom component at the end of the conversation. * * @param component the component to add at the end of the conversation. */ public void addComponent(ChatConversationComponent component) { synchronized (scrollToBottomRunnable) { StyleSheet styleSheet = document.getStyleSheet(); Style style = styleSheet .addStyle( StyleConstants.ComponentElementName, styleSheet.getStyle("body")); // The image must first be wrapped in a style style .addAttribute( AbstractDocument.ElementNameAttribute, StyleConstants.ComponentElementName); TransparentPanel wrapPanel = new TransparentPanel(new BorderLayout()); wrapPanel.add(component, BorderLayout.NORTH); style.addAttribute(StyleConstants.ComponentAttribute, wrapPanel); style.addAttribute(Attribute.ID, ChatHtmlUtils.MESSAGE_TEXT_ID); SimpleDateFormat sdf = new SimpleDateFormat(HistoryService.DATE_FORMAT); style.addAttribute(ChatHtmlUtils.DATE_ATTRIBUTE, sdf.format(component.getDate())); scrollToBottomIsPending = true; // We need to reinitialize the last message ID, because we don't // want components to be taken into account. lastMessageUID = null; // Insert the component style at the end of the text try { document .insertString(document.getLength(), "ignored text", style); } catch (BadLocationException e) { logger.error("Insert in the HTMLDocument failed.", e); } } } /** * Registers a new link click listener. * * @param listener the object that should be notified when an internal * link was clicked. */ public void addChatLinkClickedListener(ChatLinkClickedListener listener) { if(!chatLinkClickedListeners.contains(listener)) chatLinkClickedListeners.add(listener); } /** * Remove a registered link click listener. * * @param listener a registered click listener to remove */ public void removeChatLinkClickedListener(ChatLinkClickedListener listener) { chatLinkClickedListeners.remove(listener); } /** * Reloads images. */ @Override public void loadSkin() { openLinkItem.setIcon( new ImageIcon(ImageLoader.getImage(ImageLoader.BROWSER_ICON))); copyLinkItem.setIcon( new ImageIcon(ImageLoader.getImage(ImageLoader.COPY_ICON))); getRightButtonMenu().loadSkin(); } /** * Processes /me command in group chats. * * @param chatMessage the chat message * @return the newly processed message string */ public String processMeCommand(ChatMessage chatMessage) { String contentType = chatMessage.getContentType(); String message = chatMessage.getMessage(); if (message.length() <= 4 || !message.startsWith("/me ")) { return ""; } String msgID = ChatHtmlUtils.MESSAGE_TEXT_ID + chatMessage.getMessageUID(); String chatString = "
"; String endHeaderTag = "
"; chatString += GuiUtils.escapeHTMLChars("*** " + chatMessage.getContactName() + " " + message.substring(4)) + endHeaderTag; Map listSources = GuiActivator.getReplacementSources(); for (ReplacementService source : listSources.values()) { boolean isSmiley = source instanceof SmiliesReplacementService; if (!isSmiley) { continue; } String sourcePattern = source.getPattern(); Pattern p = Pattern.compile(sourcePattern, Pattern.CASE_INSENSITIVE | Pattern.DOTALL); Matcher m = p.matcher(chatString); chatString = m.replaceAll(ChatHtmlUtils.HTML_CONTENT_TYPE .equalsIgnoreCase(contentType) ? "$0" : StringEscapeUtils .escapeHtml4("$0")); } return chatString; } /** * Returns the avatar corresponding to the account of the given * protocolProvider. * * @param protocolProvider the protocol provider service, which account * avatar we're looking for * @return the avatar corresponding to the account of the given * protocolProvider */ private static String getContactAvatar( ProtocolProviderService protocolProvider, String contactAddress) { String avatarPath = AvatarCacheUtils.getCachedAvatarPath( protocolProvider, contactAddress); File avatarFile; try { avatarFile = GuiActivator.getFileAccessService() .getPrivatePersistentFile(avatarPath, FileCategory.CACHE); } catch (Exception e) { return null; } if(avatarFile.exists() && avatarFile.length() > 0) return "file:" + avatarFile.getAbsolutePath(); else return GuiActivator.getResources().getImageURL( "service.gui.DEFAULT_USER_PHOTO_SMALL").toString(); } /** * Returns the avatar corresponding to the account of the given * protocolProvider. * * @param protocolProvider the protocol provider service, which account * avatar we're looking for * @return the avatar corresponding to the account of the given * protocolProvider */ private static String getContactAvatar( ProtocolProviderService protocolProvider) { String avatarPath = AvatarCacheUtils.getCachedAvatarPath(protocolProvider); File avatarFile; try { avatarFile = GuiActivator.getFileAccessService() .getPrivatePersistentFile(avatarPath, FileCategory.CACHE); } catch (Exception e) { return null; } if(avatarFile.exists() && avatarFile.length() > 0) return "file:" + avatarFile.getAbsolutePath(); else return GuiActivator.getResources().getImageURL( "service.gui.DEFAULT_USER_PHOTO_SMALL").toString(); } /** * Indicates if this is a consecutive message. * * @param chatMessage the message to verify * @return true if the given message is a consecutive message, * false - otherwise */ private boolean isConsecutiveMessage(ChatMessage chatMessage) { if (lastMessageUID == null) return false; Element lastMsgElement = document.getElement( ChatHtmlUtils.MESSAGE_TEXT_ID + lastMessageUID); if (lastMsgElement == null) { // This will happen if the last message is a non-user message, such // as a system message. For these messages we *do* update the // lastMessageUID, however we do *not* include the new UID in the // newly appended message. logger.info("Could not find message with ID " + lastMessageUID); return false; } String contactAddress = (String) lastMsgElement.getAttributes() .getAttribute(Attribute.NAME); if (contactAddress != null && (chatMessage.getMessageType() .equals(Chat.INCOMING_MESSAGE) || chatMessage.getMessageType() .equals(Chat.OUTGOING_MESSAGE) || chatMessage.getMessageType() .equals(Chat.HISTORY_INCOMING_MESSAGE) || chatMessage.getMessageType() .equals(Chat.HISTORY_OUTGOING_MESSAGE)) && contactAddress.equals(chatMessage.getContactName()) // And if the new message is within a minute from the last one. && ((chatMessage.getDate().getTime() - lastMessageTimestamp.getTime()) < 60000)) { lastMessageTimestamp = chatMessage.getDate(); return true; } return false; } /** * Releases the resources allocated by this instance throughout its lifetime * and prepares it for garbage collection. */ @Override public void dispose() { if(editorKit != null) { editorKit.dispose(); } super.dispose(); if(showPreview != null) { showPreview.dispose(); showPreview = null; } if(rightButtonMenu != null) { rightButtonMenu.dispose(); rightButtonMenu = null; } clear(); } /** * * @param attribute * @param matchStrings * @return */ private Element findElement(HTML.Attribute attribute, String[] matchStrings) { return findFirstElement(document.getDefaultRootElement(), attribute, matchStrings); } /** * Finds the first element with name. * @param name the name to search for. * @return the first element with name. */ private Element findFirstElement(String name) { return findFirstElement(document.getDefaultRootElement(), name); } /** * * @param element * @param attrName * @param matchStrings * @return */ private Element findFirstElement( Element element, HTML.Attribute attrName, String[] matchStrings) { String attr = (String) element.getAttributes().getAttribute(attrName); if(attr != null) for (String matchString : matchStrings) if (attr.startsWith(matchString)) return element; Element resultElement = null; // Count how many messages we have in the document. for (int i = 0; i < element.getElementCount(); i++) { resultElement = findFirstElement(element.getElement(i), attrName, matchStrings); if (resultElement != null) return resultElement; } return null; } /** * Finds the first element with name among the child elements of * element. * @param element the element to searh for. * @param name the name to search for. * @return the first element with name. */ private Element findFirstElement( Element element, String name) { if (element.getName().equalsIgnoreCase(name)) return element; Element resultElement = null; // Count how many messages we have in the document. for (int i = 0; i < element.getElementCount(); i++) { resultElement = findFirstElement(element.getElement(i), name); if (resultElement != null) return resultElement; } return null; } /** * * @param elementId * @param message * @return */ private String getElementContent(String elementId, String message) { Pattern p = Pattern.compile( ".*()", Pattern.DOTALL); Matcher m = p.matcher(message); if (m.find()) { return m.group(1); } return null; } /** * Returns the first available advanced configuration form. * * @return the first available advanced configuration form */ public static ConfigurationForm getChatConfigForm() { // General configuration forms only. Collection> cfgFormRefs; String osgiFilter = "(" + ConfigurationForm.FORM_TYPE + "=" + ConfigurationForm.GENERAL_TYPE + ")"; try { cfgFormRefs = GuiActivator.bundleContext.getServiceReferences( ConfigurationForm.class, osgiFilter); } catch (InvalidSyntaxException ex) { cfgFormRefs = null; } if ((cfgFormRefs != null) && !cfgFormRefs.isEmpty()) { String chatCfgFormClassName = "net.java.sip.communicator.plugin.chatconfig.ChatConfigPanel"; for (ServiceReference cfgFormRef : cfgFormRefs) { ConfigurationForm form = GuiActivator.bundleContext.getService(cfgFormRef); if (form instanceof LazyConfigurationForm) { LazyConfigurationForm lazyConfigForm = (LazyConfigurationForm) form; if (chatCfgFormClassName.equals( lazyConfigForm.getFormClassName())) { return form; } } else if (form.getClass().getName().equals(chatCfgFormClassName)) { return form; } } } return null; } /** * Extends SIPCommHTMLEditorKit to keeps track of created ImageView for * the gif images in order to flush them whenever they are no longer visible */ private class ChatConversationEditorKit extends SIPCommHTMLEditorKit implements DocumentListener { /** * List of the image views. */ private java.util.List imageViews = new ArrayList(); /** * Constructs. * @param container */ public ChatConversationEditorKit(JComponent container) { super(container); } /** * Clears any left img view and removes any listener was added. */ public void dispose() { if(document != null) { document.removeDocumentListener(this); } for(ImageView iv : imageViews) { Image img = iv.getImage(); if(img != null) img.flush(); } imageViews.clear(); } /** * Inform view creation. * @param view the newly created view. */ protected void viewCreated(ViewFactory factory, View view) { if(view instanceof ImageView) { Element e = findFirstElement(view.getElement(), "img"); if(e == null) return; Object src = e.getAttributes().getAttribute(Attribute.SRC); if(src != null && src instanceof String && ((String)src).endsWith("gif")) { imageViews.add((ImageView)view); } } } /** * Not used. * @param e */ @Override public void insertUpdate(DocumentEvent e) {} /** * When something is removed from the current document we will check * the stored image views for any element which si no longer visible. * @param e the event. */ @Override public void removeUpdate(DocumentEvent e) { // will check if some image view is no longer visible // will consider not visible when its length is 0 Iterator imageViewIterator = imageViews.iterator(); while(imageViewIterator.hasNext()) { ImageView iv = imageViewIterator.next(); if((iv.getElement().getEndOffset() - iv.getElement().getStartOffset()) != 0) continue; Image img = iv.getImage(); if(img != null) img.flush(); imageViewIterator.remove(); } } /** * Not used. * @param e */ @Override public void changedUpdate(DocumentEvent e) {} /** * For debugging purposes, prints the content of the document * in the console. */ public void debug() { try { write(System.out, document, 0, document.getLength()); } catch(Throwable t){} } } /** * Swing worker used by processReplacement. */ private final class ReplacementWorker extends SwingWorker { /** * The messageID element. */ private final String messageID; /** * The message. */ private final String chatString; /** * Counts links while processing. Used to generate unique href. */ private int linkCounter = 0; /** * Is image replacement enabled. */ private final boolean isEnabled; /** * Is replacement proposal enabled. */ private final boolean isProposalEnabled; /** * Constructs worker. * * @param messageID the messageID element. * @param chatString the messages. */ private ReplacementWorker(final String messageID, final String chatString) { this.messageID = messageID; this.chatString = chatString; ConfigurationService cfg = GuiActivator.getConfigurationService(); isEnabled = cfg.getBoolean( ReplacementProperty.REPLACEMENT_ENABLE, true); isProposalEnabled = cfg.getBoolean( ReplacementProperty.REPLACEMENT_PROPOSAL, true); } /** * Called on the event dispatching thread (not on the worker thread) * after the construct method has returned. */ @Override public void finished() { ShowPreviewDialog previewDialog = showPreview; // There is a race between the replacement worker and the // ChatConversationPanel when it is (being) disposed of. Make sure // we have an instance before continuing. if (previewDialog == null) { // Abort if dialog has been disposed of. return; } String newMessage = (String) get(); if (newMessage != null && !newMessage.equals(chatString)) { previewDialog.getMsgIDToChatString().put( messageID, newMessage); synchronized (scrollToBottomRunnable) { scrollToBottomIsPending = true; try { Element elem = document.getElement(messageID); document.setOuterHTML(elem, newMessage); } catch (BadLocationException ex) { logger.error("Could not replace chat message", ex); } catch (IOException ex) { logger.error("Could not replace chat message", ex); } } } } @Override public Object construct() throws Exception { Matcher divMatcher = DIV_PATTERN.matcher(chatString); String openingTag = ""; String msgStore = chatString; String closingTag = ""; if (divMatcher.find()) { openingTag = divMatcher.group(1); msgStore = divMatcher.group(2); closingTag = divMatcher.group(3); } StringBuilder msgBuff; for (Map.Entry entry : GuiActivator .getReplacementSources().entrySet()) { msgBuff = new StringBuilder(); processReplacementService(entry.getValue(), msgStore, msgBuff); msgStore = msgBuff.toString(); } return openingTag + msgStore + closingTag; } /** * Process message for a ReplacementService. * * @param service the service. * @param msg the message. * @param buff current accumulated buffer. */ private void processReplacementService(final ReplacementService service, final String msg, final StringBuilder buff) { String sourcePattern = service.getPattern(); Pattern pattern = Pattern.compile(sourcePattern, Pattern.CASE_INSENSITIVE | Pattern.DOTALL); int startPos = 0; Matcher plainTextInHtmlMatcher = TEXT_TO_REPLACE_PATTERN.matcher(msg); while (plainTextInHtmlMatcher.find()) { String plainTextAsHtml = plainTextInHtmlMatcher.group(1); int startMatchPosition = plainTextInHtmlMatcher.start(1); int endMatchPosition = plainTextInHtmlMatcher.end(1); // don't process nothing // or don't process already processed links content if (!StringUtils.isNullOrEmpty(plainTextAsHtml)) { // always add from the end of previous match, to current one // or from the start to the first match buff.append(msg.substring(startPos, startMatchPosition)); final String plaintext = StringEscapeUtils.unescapeHtml4(plainTextAsHtml); // Test whether this piece of content (exactly) matches a // URL pattern. We should find at most a full URL text if it // exists, since links have already been processed, so any // URL is already wrapped in A-tags. final boolean isURL = URL_PATTERN.matcher(plaintext).matches(); processText(plaintext, buff, pattern, service, isURL); startPos = endMatchPosition; } } // add end from startPos to end buff.append(msg.substring(startPos)); } /** * Process plain text content. * * @param plainText the nodes text. * @param msgBuff the currently accumulated buffer. * @param pattern the pattern for current replacement service, created * earlier so we don't create it for every text we check. * @param rService the replacement service. * @param isURL whether this content matches the URL pattern */ private void processText(final String plainText, final StringBuilder msgBuff, final Pattern pattern, final ReplacementService rService, final boolean isURL) { final ShowPreviewDialog previewDialog = showPreview; // There is a race between the replacement worker and the // ChatConversationPanel when it is (being) disposed of. Make sure // we have an instance before continuing. if (previewDialog == null) { // Abort if dialog has been disposed of. return; } Matcher m = pattern.matcher(plainText); ConfigurationService cfg = GuiActivator.getConfigurationService(); boolean isSmiley = rService instanceof SmiliesReplacementService; boolean isDirectImage = rService instanceof DirectImageReplacementService; boolean isEnabledForSource = cfg.getBoolean( ReplacementProperty.getPropertyName( rService.getSourceName()), true); int startPos = 0; while (m.find()) { msgBuff.append(StringEscapeUtils.escapeHtml4(plainText .substring(startPos, m.start()))); startPos = m.end(); String group = m.group(); String temp = rService.getReplacement(group); String group0 = m.group(0); if (!temp.equals(group0) || isDirectImage) { if (isSmiley) { if (cfg.getBoolean(ReplacementProperty. getPropertyName("SMILEY"), true) && !isURL) { msgBuff.append("\"");"); } else { msgBuff .append(StringEscapeUtils.escapeHtml4(group)); } } else if (isProposalEnabled) { msgBuff.append(StringEscapeUtils.escapeHtml4(group)); msgBuff.append(" " + StringEscapeUtils.escapeHtml4(GuiActivator .getResources().getI18NString( "service.gui.SHOW_PREVIEW"))); previewDialog.getMsgIDandPositionToLink() .put(messageID + "#" + linkCounter++, group); previewDialog.getLinkToReplacement() .put(group, temp); } else if (isEnabled && isEnabledForSource) { if (isDirectImage) { DirectImageReplacementService service = (DirectImageReplacementService) rService; if (service.isDirectImage(group) && service.getImageSize(group) != -1) { msgBuff.append( "\"");"); } else { msgBuff.append(StringEscapeUtils .escapeHtml4(group)); } } else { msgBuff.append( "\"");"); } } else { msgBuff.append(StringEscapeUtils.escapeHtml4(group)); } } else { msgBuff.append(StringEscapeUtils.escapeHtml4(group)); } } msgBuff.append(StringEscapeUtils.escapeHtml4(plainText .substring(startPos))); } } }