package org.limewire.ui.swing.friends.chat; import java.awt.BorderLayout; import java.awt.Color; import java.awt.Component; import java.awt.Container; import java.awt.Font; import java.awt.GradientPaint; import java.awt.Rectangle; import java.awt.event.ActionEvent; import java.awt.event.AdjustmentEvent; import java.awt.event.AdjustmentListener; import java.awt.event.ComponentEvent; import java.awt.event.ComponentListener; import java.net.URI; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledExecutorService; import javax.swing.AbstractAction; import javax.swing.BorderFactory; import javax.swing.Icon; import javax.swing.JButton; import javax.swing.JEditorPane; import javax.swing.JPanel; import javax.swing.JScrollBar; import javax.swing.JScrollPane; import javax.swing.OverlayLayout; import javax.swing.SwingUtilities; import javax.swing.text.html.HTMLEditorKit; import net.miginfocom.swing.MigLayout; import org.jdesktop.application.Resource; import org.jdesktop.swingx.JXPanel; import org.limewire.concurrent.FutureEvent; import org.limewire.concurrent.ListeningFuture; import org.limewire.core.api.library.LocalFileItem; import org.limewire.core.api.library.SharedFileListManager; import org.limewire.friend.api.ChatState; import org.limewire.friend.api.FileMetaData; import org.limewire.friend.api.Friend; import org.limewire.friend.api.FriendEvent; import org.limewire.friend.api.FriendException; import org.limewire.friend.api.FriendPresence; import org.limewire.friend.api.MessageWriter; import org.limewire.friend.api.feature.Feature; import org.limewire.friend.api.feature.FeatureEvent; import org.limewire.friend.api.feature.FeatureTransport; import org.limewire.friend.api.feature.FileOfferFeature; import org.limewire.friend.api.feature.LimewireFeature; import org.limewire.friend.impl.feature.NoSave; import org.limewire.friend.impl.feature.NoSaveFeature; import org.limewire.friend.impl.feature.NoSaveStatus; import org.limewire.listener.EventListener; import org.limewire.listener.ListenerSupport; import org.limewire.listener.SwingEDTEvent; import org.limewire.logging.Log; import org.limewire.logging.LogFactory; import org.limewire.ui.swing.action.CopyAction; import org.limewire.ui.swing.action.CopyAllAction; import org.limewire.ui.swing.action.PopupUtil; import org.limewire.ui.swing.components.HyperlinkButton; import org.limewire.ui.swing.components.IconButton; import org.limewire.ui.swing.event.EventAnnotationProcessor; import org.limewire.ui.swing.event.RuntimeTopicEventSubscriber; import org.limewire.ui.swing.friends.chat.Message.Type; import org.limewire.ui.swing.painter.GenericBarPainter; import org.limewire.ui.swing.search.FriendPresenceActions; import org.limewire.ui.swing.util.GuiUtils; import org.limewire.ui.swing.util.I18n; import org.limewire.ui.swing.util.IconManager; import org.limewire.ui.swing.util.PainterUtils; import org.limewire.ui.swing.util.ResizeUtils; import org.limewire.util.FileUtils; import com.google.inject.Inject; import com.google.inject.Provider; import com.google.inject.assistedinject.Assisted; import com.google.inject.name.Named; /** * Class representing the chat window. */ public class ConversationPane extends JPanel implements Displayable, Conversation { private static final int PADDING = 5; private static final Log LOG = LogFactory.getLog(ConversationPane.class); private static final Color DEFAULT_BACKGROUND = new Color(224, 224, 224); private static final Color BACKGROUND_COLOR = Color.WHITE; private final List<Message> messages = new ArrayList<Message>(); private final Map<String, MessageFileOffer> idToMessageWithFileOffer = new ConcurrentHashMap<String, MessageFileOffer>(); private final JEditorPane editor; private final String conversationName; private final String friendId; private final MessageWriter writer; private final ChatFriend chatFriend; private final Provider<IconManager> iconManager; private final FriendPresenceActions remoteHostActions; private HyperlinkButton downloadlink; private HyperlinkButton nosaveLink; private JXPanel toolbar; private ResizingInputPanel inputPanel; private ChatState currentChatState; private NoSave noSaveState; private ListenerSupport<FriendEvent> friendSupport; private ListenerSupport<FeatureEvent> featureSupport; private EventListener<FeatureEvent> featureListener; private EventListener<FriendEvent> friendListener; @Resource(key="ChatConversation.toolbarTopColor") private Color toolbarTopColor; @Resource(key="ChatConversation.toolbarBottomColor") private Color toolbarBottomColor; @Resource(key="ChatConversation.toolbarBorderColor") private Color toolbarBorderColor; @Resource(key="ChatConversation.linkFont") private Font linkFont; @Resource private Icon endChat; private final JScrollPane conversationScroll; private final JPanel chatWrapper; @Inject public ConversationPane(@Assisted MessageWriter writer, final @Assisted ChatFriend chatFriend, SharedFileListManager libraryManager, Provider<IconManager> iconManager, ChatHyperlinkListenerFactory chatHyperlinkListenerFactory, CloseChatMessage closeChatMessage, FriendPresenceActions remoteHostActions, @Named("backgroundExecutor")ScheduledExecutorService schedExecService) { this.writer = writer; this.chatFriend = chatFriend; this.conversationName = chatFriend.getName(); this.friendId = chatFriend.getID(); this.iconManager = iconManager; this.noSaveState = null; this.remoteHostActions = remoteHostActions; GuiUtils.assignResources(this); setLayout(new BorderLayout()); editor = new JEditorPane(); editor.setEditable(false); editor.setContentType("text/html"); editor.setBorder(BorderFactory.createEmptyBorder(PADDING, PADDING, PADDING, PADDING)); HTMLEditorKit editorKit = (HTMLEditorKit) editor.getEditorKit(); editorKit.setAutoFormSubmission(false); conversationScroll = new JScrollPane(editor, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED); conversationScroll.setOpaque(false); conversationScroll.setBorder(BorderFactory.createEmptyBorder()); final JButton closeConversation = new IconButton(closeChatMessage); closeConversation.setIcon(endChat); chatWrapper = new JPanel(); chatWrapper.setLayout(new OverlayLayout(chatWrapper)); JPanel closePanel = new JPanel(); closePanel.setLayout(null); closePanel.setOpaque(false); final Rectangle closeBounds = new Rectangle(268,5,6,6); final Rectangle closeBoundsSlider = new Rectangle(250,5,6,6); closeConversation.setBounds(closeBounds); closePanel.add(closeConversation); JPanel conversationPanel = new JPanel(new BorderLayout()); conversationPanel.setOpaque(false); conversationPanel.add(conversationScroll, BorderLayout.CENTER); chatWrapper.add(closePanel); chatWrapper.add(conversationPanel); conversationScroll.getVerticalScrollBar().addComponentListener(new ComponentListener() { @Override public void componentHidden(ComponentEvent e) { closeConversation.setBounds(closeBounds); } @Override public void componentMoved(ComponentEvent e) { } @Override public void componentResized(ComponentEvent e) { } @Override public void componentShown(ComponentEvent e) { closeConversation.setBounds(closeBoundsSlider); } }); AdjustmentListener adjustmentListener = new AdjustmentListener() { @Override public void adjustmentValueChanged(AdjustmentEvent e) { chatWrapper.repaint(); } }; conversationScroll.getVerticalScrollBar().addAdjustmentListener(adjustmentListener); conversationScroll.getHorizontalScrollBar().addAdjustmentListener(adjustmentListener); add(chatWrapper, BorderLayout.CENTER); PopupUtil.addPopupMenus(editor, new CopyAction(editor), new CopyAllAction()); add(footerPanel(writer, chatFriend, schedExecService), BorderLayout.SOUTH); setBackground(DEFAULT_BACKGROUND); editor.addHyperlinkListener(chatHyperlinkListenerFactory.create(this)); EventAnnotationProcessor.subscribe(this); } @Inject public void register(@Named("available")ListenerSupport<FriendEvent> friendSupport, ListenerSupport<FeatureEvent> featureSupport) { this.friendSupport = friendSupport; this.featureSupport = featureSupport; featureListener = new EventListener<FeatureEvent>() { @Override @SwingEDTEvent public void handleEvent(FeatureEvent event) { if (event.getSource().getFriend().getId().equals(friendId)) { handleFeatureUpdate(event); } } }; friendListener = new EventListener<FriendEvent>() { @Override @SwingEDTEvent public void handleEvent(FriendEvent event) { if (event.getData().getId().equals(friendId)) { handleFriendEvent(event); } } }; friendSupport.addListener(friendListener); featureSupport.addListener(featureListener); } @RuntimeTopicEventSubscriber(methodName="getMessageReceivedTopicName") public void handleConversationMessage(String topic, MessageReceivedEvent event) { // TODO: Refactor this,ChatDocumentBuilder, etc into cleaner/clearer, way to display msgs Message message = event.getMessage(); LOG.debugf("Message: from {0} text: {1} topic: {2}", message.getSenderName(), message.toString(), topic); messages.add(message); Type type = message.getType(); if (type != Type.SENT) { currentChatState = ChatState.active; } if (message instanceof MessageFileOffer) { MessageFileOffer msgWithFileOffer = (MessageFileOffer)message; addFileOfferMessage(msgWithFileOffer); } else if (message instanceof NoSaveStatusMessage) { updateNoSaveLink(((NoSaveStatusMessage)message).getStatus()); } displayMessages(); } private void addFileOfferMessage(MessageFileOffer msgWithFileOffer) { String fileOfferID = msgWithFileOffer.getFileOffer().getId(); idToMessageWithFileOffer.put(fileOfferID, msgWithFileOffer); } private void updateNoSaveLink(NoSave noSave) { noSaveState = noSave; nosaveLink.setText("<html><u>" + (noSaveState == NoSave.ENABLED ? I18n.tr("On the Record") : I18n.tr("Off the Record")) + "</u></html>"); } @RuntimeTopicEventSubscriber(methodName="getChatStateTopicName") public void handleChatStateUpdate(String topic, ChatStateEvent event) { LOG.debugf("Chat state update for {0} to {1}", event.getFriend().getName(), event.getState()); if (currentChatState != event.getState()) { currentChatState = event.getState(); displayMessages(); } } private void handleFriendEvent(FriendEvent event) { switch(event.getType()) { case ADDED: currentChatState = ChatState.active; displayMessages(false); if ((nosaveLink != null) && hasFeature(NoSaveFeature.ID)) { nosaveLink.setVisible(true); } break; case REMOVED: displayMessages(true); if (nosaveLink != null) { nosaveLink.setVisible(false); } break; } } private void handleFeatureUpdate(FeatureEvent featureEvent) { FeatureEvent.Type featureEventType = featureEvent.getType(); Feature feature = featureEvent.getData(); if (feature.getID().equals(LimewireFeature.ID)) { if (featureEventType == FeatureEvent.Type.ADDED) { downloadlink.setEnabled(true); } else if (featureEventType == FeatureEvent.Type.REMOVED) { downloadlink.setEnabled(chatFriend.isSignedInToLimewire()); } } else if (feature.getID().equals(NoSaveFeature.ID)) { if (featureEventType == FeatureEvent.Type.ADDED) { ensureNoSaveLinkExists(); NoSave status = ((NoSaveStatus)feature.getFeature()).getStatus(); if (status != noSaveState) { NoSaveStatusMessage msg = new NoSaveStatusMessage(friendId, Message.Type.SERVER, status); new MessageReceivedEvent(msg).publish(); } } } } public void setChatStateGone() { try { writer.setChatState(ChatState.gone); } catch (FriendException e) { LOG.error("Could not set chat state while closing the conversation", e); } } public void dispose() { EventAnnotationProcessor.unsubscribe(this); featureSupport.removeListener(featureListener); friendSupport.removeListener(friendListener); inputPanel.dispose(); } public String getMessageReceivedTopicName() { return MessageReceivedEvent.buildTopic(friendId); } public String getChatStateTopicName() { return ChatStateEvent.buildTopic(friendId); } public void displayMessages() { displayMessages(!chatFriend.isSignedIn()); } public ChatFriend getChatFriend() { return chatFriend; } public Map<String, MessageFileOffer> getFileOfferMessages() { return Collections.unmodifiableMap(new HashMap<String, MessageFileOffer>(idToMessageWithFileOffer)); } private void displayMessages(boolean friendSignedOff) { String chatDoc = ChatDocumentBuilder.buildChatText(messages, currentChatState, conversationName, friendSignedOff); LOG.debugf("Chat doc: {0}", chatDoc); final JScrollBar verticalScrollBar = conversationScroll.getVerticalScrollBar(); final int scrollValue = verticalScrollBar.getValue(); editor.setText(chatDoc); //LWC-2262: If the scroll bar was moved above the bottom of the scrollpane, reset the value of //the bar to where it was before the text was updated. This needs to be issued to the end of the //queue because the actual repainting/resizing of the scrollbar happens later in a //task added to the EDT by the plaf listener of the editor's document. //A better fix for this behavior may be possible if (verticalScrollBar.getMaximum() > (scrollValue + verticalScrollBar.getVisibleAmount() + PADDING)) { SwingUtilities.invokeLater(new Runnable() { @Override public void run() { verticalScrollBar.setValue(scrollValue); } }); } else { SwingUtilities.invokeLater(new Runnable() { @Override public void run() { verticalScrollBar.setValue(verticalScrollBar.getMaximum()); } }); } decorateFileOfferButtons(); chatWrapper.repaint(); } private void decorateFileOfferButtons() { //This is a hack to set the file mime-type icon for file offer buttons that may appear in the conversation recursiveButtonSearch(editor); } private void recursiveButtonSearch(Container container) { for(Component component : container.getComponents()) { if (component instanceof Container) { recursiveButtonSearch((Container)component); } if (component instanceof JButton) { JButton button = (JButton)component; String buttonText = button.getText(); // Using end of button text to determine whether button shouild be disabled // then disable it. This is because JEditorPane does not disable buttons // disabled in the form html if (buttonText.endsWith(":disabled")) { buttonText = buttonText.substring(0, buttonText.lastIndexOf(":disabled")); button.setText(buttonText); button.setEnabled(false); } String extension = FileUtils.getFileExtension(buttonText); if (!extension.isEmpty()) { Icon icon = iconManager.get().getIconForExtension(extension); button.setIcon(icon); } } } } private JPanel footerPanel(MessageWriter writer, ChatFriend chatFriend, ScheduledExecutorService schedExecService) { JPanel panel = new JPanel(new BorderLayout()); panel.setBackground(BACKGROUND_COLOR); downloadlink = new HyperlinkButton(new DownloadFromFriendLibraryAction()); downloadlink.setFont(linkFont); downloadlink.setEnabled(chatFriend.isSignedInToLimewire()); toolbar = new JXPanel(new MigLayout("insets 0 0 0 5, gap 10, alignx right, aligny 50%")); ResizeUtils.forceHeight(toolbar, 22); toolbar.setBackgroundPainter(new GenericBarPainter<JXPanel>( new GradientPaint(0, 0, toolbarTopColor, 0, 1, toolbarBottomColor), toolbarBorderColor, PainterUtils.TRASPARENT, toolbarBorderColor, PainterUtils.TRASPARENT)); if (hasFeature(NoSaveFeature.ID)) { ensureNoSaveLinkExists(); } toolbar.add(downloadlink); inputPanel = new ResizingInputPanel(writer, schedExecService); inputPanel.setBorder(BorderFactory.createEmptyBorder()); panel.add(toolbar, BorderLayout.NORTH); panel.add(inputPanel, BorderLayout.CENTER); return panel; } private boolean hasFeature(URI feature) { return getFeature(feature) != null; } /** * Returns the feature identified by the method parameter. The * feature can be in any of the presences of the friend. * <p> * This was done to get around implementing adding/removing features on * objects (such as xmpp connections and friends) other than FriendPresences * * @param feature desired feature's URI * @return {@link Feature} */ private Feature getFeature(URI feature) { for (FriendPresence presence : chatFriend.getFriend().getPresences().values()) { if (presence.hasFeatures(feature)) { return presence.getFeature(feature); } } return null; } private void ensureNoSaveLinkExists() { if (nosaveLink == null) { NoSaveToggleAction action = new NoSaveToggleAction(); nosaveLink = new HyperlinkButton(action); nosaveLink.setFont(linkFont); // initialize nosave state NoSaveFeature nosaveFeature = (NoSaveFeature)getFeature(NoSaveFeature.ID); updateNoSaveLink(nosaveFeature.getFeature().getStatus()); toolbar.add(nosaveLink, 0); } } @Override public void handleDisplay() { invalidate(); repaint(); inputPanel.handleDisplay(); } private class DownloadFromFriendLibraryAction extends AbstractAction { public DownloadFromFriendLibraryAction() { super("<html><u>" + I18n.tr("Browse Files") + "</u></html>"); } @Override public void actionPerformed(ActionEvent e) { remoteHostActions.viewFriendLibrary(chatFriend.getFriend()); } } /** * Sets the nosave state to be the opposite of what it currently is. * Sends a nosave IQ msg to the server. * */ private class NoSaveToggleAction extends AbstractAction { @Override public void actionPerformed(ActionEvent e) { NoSaveStatus status = ((NoSaveFeature)getFeature(NoSaveFeature.ID)).getFeature(); try { status.toggleStatus(); } catch (FriendException e1) { LOG.warn("Error occurred while toggling nosave status", e1); } } } public void offerFolder(ListeningFuture<List<ListeningFuture<LocalFileItem>>> future) { // TODO: Change this to show event immediately & update as status changes. future.addFutureListener(new EventListener<FutureEvent<List<ListeningFuture<LocalFileItem>>>>() { @Override public void handleEvent(FutureEvent<List<ListeningFuture<LocalFileItem>>> event) { if(event.getResult() != null) { for(ListeningFuture<LocalFileItem> future : event.getResult()) { offerFile(future); } } } }); } public void offerFile(ListeningFuture<LocalFileItem> future) { // TODO: Change this to show event immediately & update as status changes. future.addFutureListener(new EventListener<FutureEvent<LocalFileItem>>() { @SwingEDTEvent @Override public void handleEvent(FutureEvent<LocalFileItem> event) { if(event.getResult() != null) { FileMetaData metadata = event.getResult().toMetadata(); boolean sentFileOffer = false; Friend friend = chatFriend.getFriend(); // do not send file offer if user not signed in. if (!friend.isSignedIn()) { String errorMsg = I18n.tr("File offer not sent because friend signed off."); Message msg = new MessageFileOfferImpl(I18n.tr("me"), friendId, Message.Type.SENT, metadata, null); new MessageReceivedEvent(new ErrorMessage(errorMsg, msg)).publish(); return; } // if active presence exists, send file offer to it, // otherwise broadcast to every presence with FileOfferFeature.ID feature FriendPresence activePresence = friend.getActivePresence(); if ((activePresence != null) && activePresence.hasFeatures(FileOfferFeature.ID)) { sentFileOffer = performFileOffer(metadata, activePresence); } else { for (FriendPresence presence : friend.getPresences().values()) { sentFileOffer |= performFileOffer(metadata, presence); } } if (sentFileOffer) { new MessageReceivedEvent(new MessageFileOfferImpl(I18n.tr("me"), friendId, Message.Type.SENT, metadata, null)).publish(); } else { // TODO: handle file offer sending failures (consider using ErrorMessage) } } } private boolean performFileOffer(FileMetaData metadata, FriendPresence presence) { Feature fileOfferFeature = presence.getFeature(FileOfferFeature.ID); boolean fileOfferSent = false; if (fileOfferFeature != null) { try { writer.setChatState(ChatState.active); FeatureTransport<FileMetaData> fileOfferer = presence.getTransport(FileOfferFeature.class); fileOfferer.sendFeature(presence, metadata); fileOfferSent = true; } catch (FriendException e) { LOG.debug("File offer failed", e); } } return fileOfferSent; } }); } }