Merge pull request 'Display Current User Status and Unread Message Amount in Status Tray Icon' (#103) from f/enhanced-status-tray-icon into develop

Reviewed-on: https://git.kske.dev/zdm/envoy/pulls/103
Reviewed-by: delvh <leon@kske.dev>
Reviewed-by: DieGurke <maxi@kske.dev>
This commit is contained in:
Kai S. K. Engelbart 2020-10-23 17:19:43 +02:00
commit 889e9b186f
Signed by: Käfer & Engelbart Git
GPG Key ID: 70F2F9206EDC1FCE
9 changed files with 166 additions and 97 deletions

View File

@ -3,6 +3,7 @@ package envoy.client.data;
import java.io.*; import java.io.*;
import java.util.*; import java.util.*;
import javafx.beans.property.*;
import javafx.collections.*; import javafx.collections.*;
import envoy.data.*; import envoy.data.*;
@ -21,18 +22,20 @@ import envoy.client.net.WriteProxy;
*/ */
public class Chat implements Serializable { public class Chat implements Serializable {
protected transient ObservableList<Message> messages = FXCollections.observableArrayList(); protected boolean disabled;
protected int unreadAmount;
protected boolean disabled;
protected final Contact recipient;
/** /**
* Stores the last time an {@link envoy.event.IsTyping} event has been sent. * Stores the last time an {@link envoy.event.IsTyping} event has been sent.
*/ */
protected transient long lastWritingEvent; protected transient long lastWritingEvent;
protected transient ObservableList<Message> messages = FXCollections.observableArrayList();
protected int unreadAmount;
protected static IntegerProperty totalUnreadAmount = new SimpleIntegerProperty();
protected final Contact recipient;
private static final long serialVersionUID = 2L; private static final long serialVersionUID = 2L;
/** /**
@ -50,6 +53,7 @@ public class Chat implements Serializable {
private void readObject(ObjectInputStream stream) throws ClassNotFoundException, IOException { private void readObject(ObjectInputStream stream) throws ClassNotFoundException, IOException {
stream.defaultReadObject(); stream.defaultReadObject();
messages = FXCollections.observableList((List<Message>) stream.readObject()); messages = FXCollections.observableList((List<Message>) stream.readObject());
totalUnreadAmount.set(totalUnreadAmount.get() + unreadAmount);
} }
private void writeObject(ObjectOutputStream stream) throws IOException { private void writeObject(ObjectOutputStream stream) throws IOException {
@ -111,6 +115,7 @@ public class Chat implements Serializable {
writeProxy.writeMessageStatusChange(new MessageStatusChange(m)); writeProxy.writeMessageStatusChange(new MessageStatusChange(m));
} }
} }
totalUnreadAmount.set(totalUnreadAmount.get() - unreadAmount);
unreadAmount = 0; unreadAmount = 0;
} }
@ -150,6 +155,12 @@ public class Chat implements Serializable {
return messages.removeIf(m -> m.getID() == messageID); return messages.removeIf(m -> m.getID() == messageID);
} }
/**
* @return an integer property storing the total amount of unread messages
* @since Envoy Client v0.3-beta
*/
public static IntegerProperty getTotalUnreadAmount() { return totalUnreadAmount; }
/** /**
* Increments the amount of unread messages. * Increments the amount of unread messages.
* *
@ -157,6 +168,7 @@ public class Chat implements Serializable {
*/ */
public void incrementUnreadAmount() { public void incrementUnreadAmount() {
++unreadAmount; ++unreadAmount;
totalUnreadAmount.set(totalUnreadAmount.get() + 1);
} }
/** /**

View File

@ -147,6 +147,7 @@ public final class LocalDB implements EventListener {
throw new IllegalStateException("Client user is null, cannot initialize user storage"); throw new IllegalStateException("Client user is null, cannot initialize user storage");
userFile = new File(dbDir, user.getID() + ".db"); userFile = new File(dbDir, user.getID() + ".db");
try (var in = new ObjectInputStream(new FileInputStream(userFile))) { try (var in = new ObjectInputStream(new FileInputStream(userFile))) {
Chat.getTotalUnreadAmount().set(0);
chats = FXCollections.observableList((List<Chat>) in.readObject()); chats = FXCollections.observableList((List<Chat>) in.readObject());
// Some chats have changed and should not be overwritten by the saved values // Some chats have changed and should not be overwritten by the saved values

View File

@ -1,7 +1,6 @@
package envoy.client.data; package envoy.client.data;
import java.io.Serializable; import java.io.Serializable;
import java.util.function.Consumer;
import javax.swing.JComponent; import javax.swing.JComponent;
@ -17,8 +16,6 @@ public final class SettingsItem<T> implements Serializable {
private T value; private T value;
private String userFriendlyName, description; private String userFriendlyName, description;
private transient Consumer<T> changeHandler;
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** /**
@ -52,8 +49,6 @@ public final class SettingsItem<T> implements Serializable {
* @since Envoy Client v0.3-alpha * @since Envoy Client v0.3-alpha
*/ */
public void set(T value) { public void set(T value) {
if (changeHandler != null && value != this.value)
changeHandler.accept(value);
this.value = value; this.value = value;
} }
@ -82,16 +77,4 @@ public final class SettingsItem<T> implements Serializable {
* @since Envoy Client v0.3-alpha * @since Envoy Client v0.3-alpha
*/ */
public void setDescription(String description) { this.description = description; } public void setDescription(String description) { this.description = description; }
/**
* Sets a {@code ChangeHandler} for this {@link SettingsItem}. It will be invoked with the
* current value once during the registration and every time when the value changes.
*
* @param changeHandler the changeHandler to set
* @since Envoy Client v0.3-alpha
*/
public void setChangeHandler(Consumer<T> changeHandler) {
this.changeHandler = changeHandler;
changeHandler.accept(value);
}
} }

View File

@ -237,17 +237,9 @@ public final class Startup extends Application {
e.consume(); e.consume();
}); });
if (StatusTrayIcon.isSupported()) { // Initialize status tray icon
if (StatusTrayIcon.isSupported())
// Initialize status tray icon new StatusTrayIcon(stage).show();
final var trayIcon = new StatusTrayIcon(stage);
Settings.getInstance().getItems().get("hideOnClose").setChangeHandler(c -> {
if ((Boolean) c)
trayIcon.show();
else
trayIcon.hide();
});
}
// Start auto save thread // Start auto save thread
localDB.initAutoSave(); localDB.initAutoSave();

View File

@ -1,7 +1,10 @@
package envoy.client.ui; package envoy.client.ui;
import static java.awt.Image.SCALE_SMOOTH;
import java.awt.*; import java.awt.*;
import java.awt.TrayIcon.MessageType; import java.awt.TrayIcon.MessageType;
import java.awt.image.BufferedImage;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.stage.Stage; import javafx.stage.Stage;
@ -12,11 +15,19 @@ import dev.kske.eventbus.Event;
import envoy.data.Message; import envoy.data.Message;
import envoy.data.User.UserStatus; import envoy.data.User.UserStatus;
import envoy.client.event.OwnStatusChange; import envoy.client.data.*;
import envoy.client.event.*;
import envoy.client.helper.ShutdownHelper; import envoy.client.helper.ShutdownHelper;
import envoy.client.util.*; import envoy.client.util.*;
/** /**
* A tray icon with the Envoy logo, an "Envoy" tool tip and a pop-up menu with menu items for
* <ul>
* <li>Changing the user status</li>
* <li>Logging out</li>
* <li>Quitting Envoy</li>
* </ul>
*
* @author Kai S. K. Engelbart * @author Kai S. K. Engelbart
* @since Envoy Client v0.2-alpha * @since Envoy Client v0.2-alpha
*/ */
@ -32,7 +43,20 @@ public final class StatusTrayIcon implements EventListener {
* A received {@link Message} is only displayed as a system tray notification if this variable * A received {@link Message} is only displayed as a system tray notification if this variable
* is set to {@code true}. * is set to {@code true}.
*/ */
private boolean displayMessages; private boolean displayMessageNotification;
/**
* The size of the tray icon's image.
*/
private final Dimension size;
/**
* The Envoy logo on which the current user status and unread message count will be drawn to
* compose the tray icon.
*/
private final Image logo;
private static final Font unreadMessageFont = new Font("sans-serif", Font.PLAIN, 8);
/** /**
* @return {@code true} if the status tray icon is supported on this platform * @return {@code true} if the status tray icon is supported on this platform
@ -47,9 +71,9 @@ public final class StatusTrayIcon implements EventListener {
* @since Envoy Client v0.2-beta * @since Envoy Client v0.2-beta
*/ */
public StatusTrayIcon(Stage stage) { public StatusTrayIcon(Stage stage) {
trayIcon = new TrayIcon(IconUtil.loadAWTCompatible("/icons/envoy_logo.png"), "Envoy"); size = SystemTray.getSystemTray().getTrayIconSize();
trayIcon.setImageAutoSize(true); logo = IconUtil.loadAWTCompatible("/icons/envoy_logo.png").getScaledInstance(size.width,
trayIcon.setToolTip("You are notified if you have unread messages."); size.height, SCALE_SMOOTH);
final var popup = new PopupMenu(); final var popup = new PopupMenu();
@ -60,10 +84,7 @@ public final class StatusTrayIcon implements EventListener {
// Adding the logout menu item // Adding the logout menu item
final var logoutMenuItem = new MenuItem("Logout"); final var logoutMenuItem = new MenuItem("Logout");
logoutMenuItem.addActionListener(evt -> { logoutMenuItem.addActionListener(evt -> Platform.runLater(UserUtil::logout));
hide();
Platform.runLater(UserUtil::logout);
});
popup.add(logoutMenuItem); popup.add(logoutMenuItem);
// Adding the status change items // Adding the status change items
@ -76,12 +97,17 @@ public final class StatusTrayIcon implements EventListener {
} }
popup.add(statusSubMenu); popup.add(statusSubMenu);
trayIcon.setPopupMenu(popup); // Initialize the icon
trayIcon = new TrayIcon(createImage(), "Envoy", popup);
// Only display messages if the stage is not focused and the current user status // Only display messages if the stage is not focused and the current user status
// is not BUSY (if BUSY, displayMessages will be false) // is not BUSY (if BUSY, displayMessageNotification will be false)
stage.focusedProperty().addListener((ov, wasFocused, isFocused) -> displayMessages = stage.focusedProperty()
!displayMessages && wasFocused ? false : !isFocused); .addListener((ov, wasFocused, isFocused) -> displayMessageNotification =
!displayMessageNotification && wasFocused ? false : !isFocused);
// Listen to changes in the total unread message amount
Chat.getTotalUnreadAmount().addListener((ov, oldValue, newValue) -> updateImage());
// Show the window if the user clicks on the icon // Show the window if the user clicks on the icon
trayIcon.addActionListener(evt -> Platform.runLater(() -> { trayIcon.addActionListener(evt -> Platform.runLater(() -> {
@ -102,7 +128,7 @@ public final class StatusTrayIcon implements EventListener {
public void show() { public void show() {
try { try {
SystemTray.getSystemTray().add(trayIcon); SystemTray.getSystemTray().add(trayIcon);
} catch (final AWTException e) {} } catch (AWTException e) {}
} }
/** /**
@ -110,22 +136,89 @@ public final class StatusTrayIcon implements EventListener {
* *
* @since Envoy Client v0.2-beta * @since Envoy Client v0.2-beta
*/ */
@Event(eventType = Logout.class)
public void hide() { public void hide() {
SystemTray.getSystemTray().remove(trayIcon); SystemTray.getSystemTray().remove(trayIcon);
} }
@Event @Event
private void onOwnStatusChange(OwnStatusChange statusChange) { private void onOwnStatusChange(OwnStatusChange statusChange) {
displayMessages = !statusChange.get().equals(UserStatus.BUSY); displayMessageNotification = !statusChange.get().equals(UserStatus.BUSY);
trayIcon.getImage().flush();
trayIcon.setImage(createImage());
} }
@Event @Event
private void onMessage(Message message) { private void onMessage(Message message) {
if (displayMessages) if (displayMessageNotification)
trayIcon trayIcon
.displayMessage(message.hasAttachment() .displayMessage(message.hasAttachment()
? "New " + message.getAttachment().getType().toString().toLowerCase() ? "New " + message.getAttachment().getType().toString().toLowerCase()
+ " message received" + " message received"
: "New message received", message.getText(), MessageType.INFO); : "New message received", message.getText(), MessageType.INFO);
} }
/**
* Updates the tray icon's image by first releasing the resources held by the current image and
* then setting a new one generated by the {@link StatusTrayIcon#createImage()} method.
*
* @since Envoy Client v0.3-beta
*/
private void updateImage() {
trayIcon.getImage().flush();
trayIcon.setImage(createImage());
}
/**
* Composes an icon that displays the current user status and the amount of unread messages, if
* any are present.
*
* @since Envoy Client v0.3-beta
*/
private BufferedImage createImage() {
// Create a new image with the dimensions of the logo
var img = new BufferedImage(size.width, size.height, BufferedImage.TYPE_INT_ARGB);
// Obtain the draw graphics of the image and copy the logo
var g = img.createGraphics();
g.drawImage(logo, 0, 0, null);
// Draw the current user status
switch (Context.getInstance().getLocalDB().getUser().getStatus()) {
case ONLINE:
g.setColor(Color.GREEN);
break;
case AWAY:
g.setColor(Color.ORANGE);
break;
case BUSY:
g.setColor(Color.RED);
break;
case OFFLINE:
g.setColor(Color.GRAY);
}
g.fillOval(size.width / 2, size.height / 2, size.width / 2, size.height / 2);
// Draw total amount of unread messages, if any are present
if (Chat.getTotalUnreadAmount().get() > 0) {
// Draw black background circle
g.setColor(Color.BLACK);
g.fillOval(size.width / 2, 0, size.width / 2, size.height / 2);
// Unread amount in white
String unreadAmount = Chat.getTotalUnreadAmount().get() > 9 ? "9+"
: String.valueOf(Chat.getTotalUnreadAmount().get());
g.setColor(Color.WHITE);
g.setFont(unreadMessageFont);
g.drawString(unreadAmount,
3 * size.width / 4 - g.getFontMetrics().stringWidth(unreadAmount) / 2,
size.height / 2);
}
// Finish drawing
g.dispose();
return img;
}
} }

View File

@ -32,12 +32,12 @@ public final class ChatControl extends HBox {
setPadding(new Insets(0, 0, 3, 0)); setPadding(new Insets(0, 0, 3, 0));
// Profile picture // Profile picture
final var contactProfilePic = var contactProfilePic =
new ProfilePicImageView(chat instanceof GroupChat ? groupIcon : userIcon, 32); new ProfilePicImageView(chat instanceof GroupChat ? groupIcon : userIcon, 32);
getChildren().add(contactProfilePic); getChildren().add(contactProfilePic);
// Spacing // Spacing
final var leftSpacing = new Region(); var leftSpacing = new Region();
leftSpacing.setPrefSize(8, 0); leftSpacing.setPrefSize(8, 0);
leftSpacing.setMinSize(8, 0); leftSpacing.setMinSize(8, 0);
leftSpacing.setMaxSize(8, 0); leftSpacing.setMaxSize(8, 0);
@ -48,17 +48,15 @@ public final class ChatControl extends HBox {
// Unread messages // Unread messages
if (chat.getUnreadAmount() != 0) { if (chat.getUnreadAmount() != 0) {
final var spacing = new Region(); var spacing = new Region();
setHgrow(spacing, Priority.ALWAYS); setHgrow(spacing, Priority.ALWAYS);
getChildren().add(spacing); getChildren().add(spacing);
final var unreadMessagesLabel = new Label(Integer.toString(chat.getUnreadAmount())); var unreadMessagesLabel = new Label(
chat.getUnreadAmount() > 99 ? "99+" : String.valueOf(chat.getUnreadAmount()));
unreadMessagesLabel.setMinSize(15, 15); unreadMessagesLabel.setMinSize(15, 15);
final var vbox = new VBox(); unreadMessagesLabel.setAlignment(Pos.CENTER_RIGHT);
vbox.setAlignment(Pos.CENTER_RIGHT);
unreadMessagesLabel.setAlignment(Pos.CENTER);
unreadMessagesLabel.getStyleClass().add("unread-messages-amount"); unreadMessagesLabel.getStyleClass().add("unread-messages-amount");
vbox.getChildren().add(unreadMessagesLabel); getChildren().add(unreadMessagesLabel);
getChildren().add(vbox);
} }
getStyleClass().add("list-element"); getStyleClass().add("list-element");
} }

View File

@ -107,9 +107,6 @@ public final class ChatScene implements EventListener, Restorable {
@FXML @FXML
private TextArea contactSearch; private TextArea contactSearch;
@FXML
private VBox contactOperations;
@FXML @FXML
private TabPane tabPane; private TabPane tabPane;
@ -125,9 +122,6 @@ public final class ChatScene implements EventListener, Restorable {
@FXML @FXML
private HBox ownContactControl; private HBox ownContactControl;
@FXML
private Region spaceBetweenUserAndSettingsButton;
private Chat currentChat; private Chat currentChat;
private FilteredList<Chat> chats; private FilteredList<Chat> chats;
private Attachment pendingAttachment; private Attachment pendingAttachment;
@ -175,7 +169,7 @@ public final class ChatScene implements EventListener, Restorable {
// Set the icons of buttons and image views // Set the icons of buttons and image views
settingsButton.setGraphic( settingsButton.setGraphic(
new ImageView(IconUtil.loadIconThemeSensitive("settings", DEFAULT_ICON_SIZE))); new ImageView(IconUtil.loadIconThemeSensitive("settings", 22)));
voiceButton.setGraphic( voiceButton.setGraphic(
new ImageView(IconUtil.loadIconThemeSensitive("microphone", DEFAULT_ICON_SIZE))); new ImageView(IconUtil.loadIconThemeSensitive("microphone", DEFAULT_ICON_SIZE)));
attachmentButton.setGraphic( attachmentButton.setGraphic(
@ -195,7 +189,6 @@ public final class ChatScene implements EventListener, Restorable {
chatList.setItems(chats = new FilteredList<>(localDB.getChats())); chatList.setItems(chats = new FilteredList<>(localDB.getChats()));
// Set the design of the box in the upper-left corner // Set the design of the box in the upper-left corner
settingsButton.setAlignment(Pos.BOTTOM_RIGHT);
generateOwnStatusControl(); generateOwnStatusControl();
Platform.runLater(() -> { Platform.runLater(() -> {
@ -797,15 +790,15 @@ public final class ChatScene implements EventListener, Restorable {
private void generateOwnStatusControl() { private void generateOwnStatusControl() {
// Update the own user status if present // Update the own user status if present
if (ownContactControl.getChildren().get(0) instanceof ContactControl) if (ownContactControl.getChildren().get(1) instanceof ContactControl)
((ContactControl) ownContactControl.getChildren().get(0)).replaceInfoLabel(); ((ContactControl) ownContactControl.getChildren().get(1)).replaceInfoLabel();
else { else {
// Else prepend it to the HBox children // Else prepend it to the HBox children
final var ownUserControl = new ContactControl(localDB.getUser()); final var ownUserControl = new ContactControl(localDB.getUser());
ownUserControl.setAlignment(Pos.CENTER_LEFT); ownUserControl.setAlignment(Pos.CENTER_LEFT);
HBox.setHgrow(ownUserControl, Priority.NEVER); HBox.setHgrow(ownUserControl, Priority.NEVER);
ownContactControl.getChildren().add(0, ownUserControl); ownContactControl.getChildren().add(1, ownUserControl);
} }
} }

View File

@ -27,15 +27,15 @@ public final class GeneralSettingsPane extends SettingsPane {
final var settingsItems = settings.getItems(); final var settingsItems = settings.getItems();
// Add hide on close if supported // Add hide on close if supported
if (StatusTrayIcon.isSupported()) { final var hideOnCloseCheckbox =
final var hideOnCloseCheckbox = new SettingsCheckbox((SettingsItem<Boolean>) settingsItems.get("hideOnClose"));
new SettingsCheckbox((SettingsItem<Boolean>) settingsItems.get("hideOnClose")); final var hideOnCloseTooltip = new Tooltip(StatusTrayIcon.isSupported()
final var hideOnCloseTooltip = new Tooltip( ? "If selected, Envoy will still be present in the task bar when closed."
"If selected, Envoy will still be present in the task bar when closed."); : "status tray icon is not supported on your system.");
hideOnCloseTooltip.setWrapText(true); hideOnCloseTooltip.setWrapText(true);
hideOnCloseCheckbox.setTooltip(hideOnCloseTooltip); hideOnCloseCheckbox.setTooltip(hideOnCloseTooltip);
getChildren().add(hideOnCloseCheckbox); hideOnCloseCheckbox.setDisable(!StatusTrayIcon.isSupported());
} getChildren().add(hideOnCloseCheckbox);
final var enterToSendCheckbox = final var enterToSendCheckbox =
new SettingsCheckbox((SettingsItem<Boolean>) settingsItems.get("enterToSend")); new SettingsCheckbox((SettingsItem<Boolean>) settingsItems.get("enterToSend"));

View File

@ -57,7 +57,7 @@
<content> <content>
<AnchorPane minHeight="0.0" minWidth="0.0"> <AnchorPane minHeight="0.0" minWidth="0.0">
<children> <children>
<VBox fx:id="contactOperations" prefHeight="3000.0" <VBox prefHeight="3000.0"
prefWidth="316.0"> prefWidth="316.0">
<children> <children>
<VBox id="search-panel" maxHeight="-Infinity" <VBox id="search-panel" maxHeight="-Infinity"
@ -146,7 +146,8 @@
<Insets right="1.0" /> <Insets right="1.0" />
</GridPane.margin> </GridPane.margin>
</TabPane> </TabPane>
<HBox id="top-bar" alignment="CENTER_LEFT" prefHeight="100.0"> <HBox id="top-bar" alignment="CENTER_LEFT" prefHeight="100.0"
fx:id="ownContactControl">
<children> <children>
<ImageView id="profile-pic" fx:id="clientProfilePic" <ImageView id="profile-pic" fx:id="clientProfilePic"
fitHeight="43.0" fitWidth="43.0" pickOnBounds="true" fitHeight="43.0" fitWidth="43.0" pickOnBounds="true"
@ -155,22 +156,18 @@
<Insets left="15.0" top="5.0" right="10.0" /> <Insets left="15.0" top="5.0" right="10.0" />
</HBox.margin> </HBox.margin>
</ImageView> </ImageView>
<HBox id="transparent-background" fx:id="ownContactControl"> <Region id="transparent-background"
<children> HBox.hgrow="ALWAYS" />
<Region id="transparent-background" prefWidth="120" <Button fx:id="settingsButton" mnemonicParsing="false"
fx:id="spaceBetweenUserAndSettingsButton" HBox.hgrow="ALWAYS" /> onAction="#settingsButtonClicked" prefHeight="30.0"
<Button fx:id="settingsButton" mnemonicParsing="false" prefWidth="30.0" alignment="CENTER_RIGHT">
onAction="#settingsButtonClicked" prefHeight="30.0" <padding>
prefWidth="30.0" text="" alignment="CENTER"> <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
<padding> </padding>
<Insets bottom="5.0" left="5.0" right="5.0" top="5.0" /> <HBox.margin>
</padding> <Insets bottom="35.0" left="5.0" top="35.0" right="10.0"/>
<HBox.margin> </HBox.margin>
<Insets bottom="35.0" left="5.0" top="35.0" /> </Button>
</HBox.margin>
</Button>
</children>
</HBox>
</children> </children>
<GridPane.margin> <GridPane.margin>
<Insets bottom="1.0" right="1.0" /> <Insets bottom="1.0" right="1.0" />