/* * Kontalk Java client * Copyright (C) 2016 Kontalk Devteam <[email protected]> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.kontalk.view; import javax.swing.Icon; import javax.swing.JFileChooser; import javax.swing.JViewport; import javax.swing.SwingUtilities; import java.awt.BorderLayout; import java.awt.Color; import java.awt.Component; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.Image; import java.awt.Toolkit; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.AdjustmentEvent; import java.awt.event.AdjustmentListener; import java.awt.event.FocusAdapter; import java.awt.event.FocusEvent; import java.awt.image.BufferedImage; import java.awt.image.ImageObserver; import java.io.File; import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Observable; import java.util.Optional; import com.alee.extended.panel.GroupPanel; import com.alee.laf.button.WebButton; import com.alee.laf.button.WebToggleButton; import com.alee.laf.filechooser.WebFileChooser; import com.alee.laf.label.WebLabel; import com.alee.laf.panel.WebPanel; import com.alee.laf.scroll.WebScrollPane; import com.alee.laf.viewport.WebViewport; import com.alee.managers.hotkey.Hotkey; import com.alee.managers.hotkey.HotkeyData; import com.alee.managers.language.data.TooltipWay; import com.alee.managers.tooltip.TooltipManager; import com.alee.utils.filefilter.AllFilesFilter; import com.alee.utils.filefilter.CustomFileFilter; import org.apache.commons.io.FileUtils; import org.jivesoftware.smackx.chatstates.ChatState; import org.kontalk.client.FeatureDiscovery; import org.kontalk.model.Contact; import org.kontalk.model.chat.Chat; import org.kontalk.model.chat.GroupChat; import org.kontalk.persistence.Config; import org.kontalk.system.AttachmentManager; import org.kontalk.system.Control; import org.kontalk.util.MediaUtils; import org.kontalk.util.Tr; /** * Panel showing the currently selected chat. * * One view object for all chats. * * @author Alexander Bikadorov {@literal <[email protected]>} */ final class ChatView extends WebPanel implements ObserverTrait { private static final Icon ATT_ICON = Utils.getIcon("ic_ui_attach.png"); private static final Icon SEND_ICON = Utils.getIcon("ic_ui_send.png"); private enum ButtonStatus {Attachment, AttDisabled, Send, Disabled} private final View mView; private final ComponentUtils.AvatarImage mAvatar; private final WebLabel mTitleLabel; private final WebLabel mSubTitleLabel; private final ComponentUtils.EncryptionPanel mEncryptionStatus; private final WebFileChooser mFileChooser; private final WebButton mSendButton; private final WebScrollPane mScrollPane; private final ComposingArea mTextComposingArea; private final Map<Chat, MessageList> mMessageListCache = new HashMap<>(); private Background mDefaultBG; private boolean mScrollDown = false; private boolean mAttSupported = false; ChatView(View view) { mView = view; this.setLayout(new BorderLayout(View.GAP_SMALL, View.GAP_SMALL)); WebPanel titlePanel = new WebPanel(new BorderLayout(View.GAP_DEFAULT, 0)); titlePanel.setMargin(View.MARGIN_SMALL, View.MARGIN_SMALL, 0, View.MARGIN_SMALL); mAvatar = new ComponentUtils.AvatarImage(View.AVATAR_CHAT_SIZE); titlePanel.add(mAvatar, BorderLayout.WEST); mTitleLabel = new WebLabel(); mTitleLabel.setFontSize(View.FONT_SIZE_HUGE); mTitleLabel.setDrawShade(true); mSubTitleLabel = new WebLabel(); mSubTitleLabel.setFontSize(View.FONT_SIZE_TINY); mSubTitleLabel.setForeground(Color.GRAY); titlePanel.add(new GroupPanel(View.GAP_SMALL, false, mTitleLabel, mSubTitleLabel) .setMargin(View.MARGIN_SMALL, 0, 0, 0), BorderLayout.CENTER); // encryption status mEncryptionStatus = new ComponentUtils.EncryptionPanel(); // edit button WebToggleButton editButton = new ComponentUtils.ToggleButton( Utils.getIcon("ic_ui_menu.png"), Tr.tr("Edit this chat")) { @Override Optional<ComponentUtils.PopupPanel> getPanel() { return ChatView.this.getPopupPanel(); } } .setDrawSides(false, false, false, false) .setTopBgColor(titlePanel.getBackground()) .setBottomBgColor(titlePanel.getBackground()); titlePanel.add(new GroupPanel(View.GAP_DEFAULT, mEncryptionStatus, editButton), BorderLayout.EAST); this.add(titlePanel, BorderLayout.NORTH); mScrollPane = new ComponentUtils.ScrollPane(this).setShadeWidth(0); mScrollPane.getVerticalScrollBar().addAdjustmentListener(new AdjustmentListener() { @Override public void adjustmentValueChanged(AdjustmentEvent e) { // this is not perfect at all: after adding all items, they still // dont have any content and so their height is unknown // (== very small). While rendering, content is added and we force // scrolling down WHILE rendering until the final bottom is reached if (e.getValueIsAdjusting()) mScrollDown = false; if (mScrollDown) e.getAdjustable().setValue(e.getAdjustable().getMaximum()); } }); mScrollPane.setViewport(new WebViewport() { @Override public void paintComponent(Graphics g) { super.paintComponent(g); BufferedImage bg = ChatView.this.getCurrentBackground().updateNowOrLater().orElse(null); // if there is something to draw, draw it now even if its old if (bg != null) g.drawImage(bg, 0, 0, this.getWidth(), this.getHeight(), null); } }); this.add(mScrollPane, BorderLayout.CENTER); // bottom panel... WebPanel bottomPanel = new WebPanel(new BorderLayout(View.GAP_SMALL, View.GAP_SMALL)); // file chooser button mFileChooser = new WebFileChooser(); mFileChooser.setMultiSelectionEnabled(false); mFileChooser.setFileSelectionMode(JFileChooser.FILES_ONLY); mFileChooser.setFileFilter(new CustomFileFilter(AllFilesFilter.ICON, Tr.tr("Supported files")) { @Override public boolean accept(File file) { return Utils.isAllowedAttachmentFile(file); } }); // text composing area mTextComposingArea = new ComposingArea(this); bottomPanel.add(mTextComposingArea.getComponent(), BorderLayout.CENTER); // send button mSendButton = new WebButton() .setTopBgColor(titlePanel.getBackground()) .setBottomBgColor(titlePanel.getBackground()) .setDrawSides(false, false, false, false); mSendButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { ChatView.this.sendButtonAction(); } }); bottomPanel.add(mSendButton, BorderLayout.EAST); bottomPanel.setTransferHandler(mTextComposingArea.getDropHandler()); this.add(bottomPanel, BorderLayout.SOUTH); this.addFocusListener(new FocusAdapter() { @Override public void focusGained(FocusEvent e) { mTextComposingArea.focus(); } }); this.loadDefaultBG(); } private MessageList currentMessageListOrNull() { Component view = mScrollPane.getViewport().getView(); if (view == null || !(view instanceof MessageList)) return null; return (MessageList) view; } Optional<Chat> getCurrentChat() { MessageList view = this.currentMessageListOrNull(); return view == null ? Optional.empty() : Optional.of(view.getChat()); } void filterCurrentChat(String searchText) { MessageList view = this.currentMessageListOrNull(); if (view == null) return; view.filterItems(searchText); } void showChat(Chat chat) { Chat oldChat = this.getCurrentChat().orElse(null); if (oldChat != null) oldChat.deleteObserver(this); chat.addObserver(this); if (!mMessageListCache.containsKey(chat)) { MessageList newMessageList = new MessageList(mView, this, chat); chat.addObserver(newMessageList); mMessageListCache.put(chat, newMessageList); } // set to current chat mScrollPane.getViewport().setView(mMessageListCache.get(chat)); this.onChatChange(); chat.setRead(); } void loadDefaultBG() { String imagePath = Config.getInstance().getString(Config.VIEW_CHAT_BG); mDefaultBG = !imagePath.isEmpty() ? new Background(mScrollPane.getViewport(), imagePath) : new Background(mScrollPane.getViewport()); mScrollPane.getViewport().repaint(); } private Background getCurrentBackground() { MessageList view = this.currentMessageListOrNull(); if (view == null) return mDefaultBG; Background bg = view.getBG().orElse(null); return bg == null ? mDefaultBG : bg; } Background createBGOrNull(Chat.ViewSettings s) { JViewport p = mScrollPane.getViewport(); Color c = s.getBGColor().orElse(null); if (c != null) { return new Background(p, c); } else if (!s.getImagePath().isEmpty()) { return new Background(p, s.getImagePath()); } else { return null; } } void setScrollDown() { // does still not work // SwingUtilities.invokeLater(new Runnable() { // @Override // public void run() { // WebScrollBar verticalBar = mScrollPane.getWebVerticalScrollBar(); // verticalBar.setValue(verticalBar.getMaximum()); // } // }); mScrollDown = true; } void setHotkeys(final boolean enterSends) { mTextComposingArea.setHotkeys(enterSends); mSendButton.removeHotkeys(); HotkeyData sendHotkey = enterSends ? Hotkey.ENTER : Hotkey.CTRL_ENTER; mSendButton.addHotkey(sendHotkey, TooltipWay.up); } void onStatusChange(Control.Status status, EnumSet<FeatureDiscovery.Feature> serverFeature) { switch(status) { case CONNECTED: mAttSupported = serverFeature.contains(FeatureDiscovery.Feature.HTTP_FILE_UPLOAD); break; case DISCONNECTED: case ERROR: // don't know, but assume it mAttSupported = true; break; } } @Override public void updateOnEDT(Observable o, Object arg) { if (arg instanceof Chat) { Chat chat = (Chat) arg; if (chat.isDeleted()) { MessageList viewList = mMessageListCache.remove(chat); if (viewList != null) { viewList.clearItems(); chat.deleteObserver(viewList); } } } if (arg == Chat.ViewChange.SUBJECT || arg == Chat.ViewChange.CONTACT || arg == Chat.ViewChange.MEMBERS) { this.onChatChange(); } } void updateMessageLists() { for (MessageList messageList : mMessageListCache.values()) messageList.updateMessageFontSize(); } private void onChatChange() { Chat chat = this.getCurrentChat().orElse(null); if (chat == null) return; // avatar mAvatar.setAvatarImage(chat); TooltipManager.setTooltip(mAvatar, Utils.chatTooltip(chat)); // chat titles mTitleLabel.setText(Utils.chatTitle(chat)); List<Contact> contacts = Utils.contactList(chat); mSubTitleLabel.setText(contacts.isEmpty() ? "(" + Tr.tr("No members") + ")" : chat.isGroupChat() ? Utils.displayNames(contacts, View.MAX_NAME_IN_LIST_LENGTH) : Utils.mainStatus(contacts.iterator().next(), true)); // text area boolean isMember = chat instanceof GroupChat && !((GroupChat) chat).containsMe(); mTextComposingArea.setEnabled(chat.isValid(), isMember); // send button this.updateEnabledButtons(); // encryption status mEncryptionStatus.setStatus(chat.isSendEncrypted(), chat.canSendEncrypted()); } private Optional<ComponentUtils.PopupPanel> getPopupPanel() { Chat chat = ChatView.this.getCurrentChat().orElse(null); return chat == null ? Optional.empty() : Optional.of(new ChatDetails(mView, chat)); } void onKeyTypeEvent(boolean empty) { this.updateEnabledButtons(); Chat chat = this.getCurrentChat().orElse(null); if (chat == null) return; // workaround: clearing the text area is not a key event if (!empty) mView.getControl().handleOwnChatStateEvent(chat, ChatState.composing); } private void updateEnabledButtons() { ButtonStatus status = this.currentButtonStatus(); boolean enabled = !(status == ButtonStatus.AttDisabled || status == ButtonStatus.Disabled); mSendButton.setEnabled(enabled); mTextComposingArea.getDropHandler().setDropEnabled(enabled); String tooltipText; switch (status) { case Attachment: mSendButton.setIcon(ATT_ICON); tooltipText = Tr.tr("Send File") + " - " + Tr.tr("max. size:") + " " + FileUtils.byteCountToDisplaySize(AttachmentManager.MAX_ATT_SIZE); break; case AttDisabled: mSendButton.setIcon(ATT_ICON); tooltipText = Tr.tr("Sending files not supported by server"); break; default: mSendButton.setIcon(SEND_ICON); tooltipText = Tr.tr("Send Message"); } TooltipManager.setTooltip(mSendButton, tooltipText); } private void sendButtonAction() { if (!mTextComposingArea.isFocused() && SwingUtilities.getWindowAncestor(ChatView.this).getFocusOwner() != mSendButton) return; if (this.currentButtonStatus() == ButtonStatus.Attachment) { this.showFileDialog(); } else { this.sendMsg(); mTextComposingArea.focus(); } } private ButtonStatus currentButtonStatus() { Chat chat = this.getCurrentChat().orElse(null); if (chat == null) return ButtonStatus.Disabled; // disable if chat is not valid... if (!chat.isValid() || // or encrypted messages can not be send (chat.isSendEncrypted() && !chat.canSendEncrypted())) { return ButtonStatus.Disabled; } // if there is text to send return mTextComposingArea.getText().trim().isEmpty() ? mAttSupported ? ButtonStatus.Attachment : ButtonStatus.AttDisabled : ButtonStatus.Send; } private void sendMsg() { Chat chat = this.getCurrentChat().orElse(null); if (chat == null) // no current chat return; // TODO sending text AND attachment (?) //List<File> attachments = mAttField.getSelectedFiles(); // if (!attachments.isEmpty()) // mView.getControl().sendAttachment(optChat.get(), attachments.get(0).toPath()); // else mView.getControl().sendText(chat, mTextComposingArea.getText()); mTextComposingArea.reset(); } private void showFileDialog() { int option = mFileChooser.showOpenDialog(ChatView.this); if (option != WebFileChooser.APPROVE_OPTION) return; File file = mFileChooser.getSelectedFile(); mFileChooser.setCurrentDirectory(file.toPath().getParent().toString()); this.sendFile(file); } void sendFile(File file) { Chat chat = this.getCurrentChat().orElse(null); if (chat == null) return; mView.getControl().sendAttachment(chat, file.toPath()); } /** A background image of chat view with efficient async reloading. */ final class Background implements ImageObserver { private final Component mParent; // background image from resource or user selected private final Image mOrigin; // background color, set by user or null private final Color mCustomColor; // cached background with size of viewport private BufferedImage mCached = null; /** Default, no chat specific settings. */ Background(Component parent) { this(parent, (Color) null); } /** Chat specific color. */ Background(Component parent, Color bottomColor) { this(parent, Utils.getImage("chat_bg.png"), bottomColor); } /** Image set by user (global or only for chat). */ Background(Component parent, String imagePath) { // image loaded async! this(parent, Toolkit.getDefaultToolkit().createImage(imagePath), null); } private Background(Component parent, Image origin, Color color) { mParent = parent; mOrigin = origin; mCustomColor = color; } /** * Update the background image for this parent. Returns immediately, but * repaints parent if updating is done asynchronously. * @return if synchronized update is possible the updated image, else an * old image if present */ Optional<BufferedImage> updateNowOrLater() { if (mCached == null || mCached.getWidth() != mParent.getWidth() || mCached.getHeight() != mParent.getHeight()) { if (this.loadOrigin()) { // goto 2 this.scaleOrigin(); } } return Optional.ofNullable(mCached); } // step 1: ensure original image is loaded (if present) private boolean loadOrigin() { if (mOrigin == null) return true; return mOrigin.getWidth(this) != -1; } // step 2: scale image (if present) private boolean scaleOrigin() { if (mOrigin == null) { // goto 3 this.updateCachedBG(null); return true; } Image scaledImage = MediaUtils.scaleMaxAsync(mOrigin, mParent.getWidth(), mParent.getHeight()); if (scaledImage.getWidth(this) != -1) { // goto 3 this.updateCachedBG(scaledImage); return true; } return false; } // step 3: paint cache from scaled image (if present) private void updateCachedBG(Image scaledImage) { int width = mParent.getWidth(); int height = mParent.getHeight(); mCached = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); Graphics2D cachedG = mCached.createGraphics(); if (scaledImage == null) return; // tiling int iw = scaledImage.getWidth(null); int ih = scaledImage.getHeight(null); for (int x = 0; x < width; x += iw) { for (int y = 0; y < height; y += ih) { cachedG.drawImage(scaledImage, x, y, iw, ih, null); } } // gradient background of background if (mCustomColor != null) { Color overlayColor = new Color( mCustomColor.getRed(), mCustomColor.getGreen(), mCustomColor.getBlue(), View.CHAT_BG_ALPHA); cachedG.setPaint(overlayColor); cachedG.fillRect(0, 0, width, ChatView.this.getHeight()); } } @Override public boolean imageUpdate(Image img, int infoflags, int x, int y, int w, int h) { // ignore if image is not completely loaded if ((infoflags & ImageObserver.ALLBITS) == 0) { return true; } if (img.equals(mOrigin)) { // original image done loading, goto 2 boolean sync = this.scaleOrigin(); if (sync) mParent.repaint(); return false; } else { // scaling done, goto 3 this.updateCachedBG(img); mParent.repaint(); return false; } } } }