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.util.*;
import javafx.beans.property.*;
import javafx.collections.*;
import envoy.data.*;
@ -21,18 +22,20 @@ import envoy.client.net.WriteProxy;
*/
public class Chat implements Serializable {
protected transient ObservableList<Message> messages = FXCollections.observableArrayList();
protected int unreadAmount;
protected boolean disabled;
protected final Contact recipient;
protected boolean disabled;
/**
* Stores the last time an {@link envoy.event.IsTyping} event has been sent.
*/
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;
/**
@ -50,6 +53,7 @@ public class Chat implements Serializable {
private void readObject(ObjectInputStream stream) throws ClassNotFoundException, IOException {
stream.defaultReadObject();
messages = FXCollections.observableList((List<Message>) stream.readObject());
totalUnreadAmount.set(totalUnreadAmount.get() + unreadAmount);
}
private void writeObject(ObjectOutputStream stream) throws IOException {
@ -111,6 +115,7 @@ public class Chat implements Serializable {
writeProxy.writeMessageStatusChange(new MessageStatusChange(m));
}
}
totalUnreadAmount.set(totalUnreadAmount.get() - unreadAmount);
unreadAmount = 0;
}
@ -150,6 +155,12 @@ public class Chat implements Serializable {
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.
*
@ -157,6 +168,7 @@ public class Chat implements Serializable {
*/
public void incrementUnreadAmount() {
++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");
userFile = new File(dbDir, user.getID() + ".db");
try (var in = new ObjectInputStream(new FileInputStream(userFile))) {
Chat.getTotalUnreadAmount().set(0);
chats = FXCollections.observableList((List<Chat>) in.readObject());
// Some chats have changed and should not be overwritten by the saved values

View File

@ -1,7 +1,6 @@
package envoy.client.data;
import java.io.Serializable;
import java.util.function.Consumer;
import javax.swing.JComponent;
@ -17,8 +16,6 @@ public final class SettingsItem<T> implements Serializable {
private T value;
private String userFriendlyName, description;
private transient Consumer<T> changeHandler;
private static final long serialVersionUID = 1L;
/**
@ -52,8 +49,6 @@ public final class SettingsItem<T> implements Serializable {
* @since Envoy Client v0.3-alpha
*/
public void set(T value) {
if (changeHandler != null && value != this.value)
changeHandler.accept(value);
this.value = value;
}
@ -82,16 +77,4 @@ public final class SettingsItem<T> implements Serializable {
* @since Envoy Client v0.3-alpha
*/
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();
});
if (StatusTrayIcon.isSupported()) {
// Initialize status tray icon
final var trayIcon = new StatusTrayIcon(stage);
Settings.getInstance().getItems().get("hideOnClose").setChangeHandler(c -> {
if ((Boolean) c)
trayIcon.show();
else
trayIcon.hide();
});
}
// Initialize status tray icon
if (StatusTrayIcon.isSupported())
new StatusTrayIcon(stage).show();
// Start auto save thread
localDB.initAutoSave();

View File

@ -1,7 +1,10 @@
package envoy.client.ui;
import static java.awt.Image.SCALE_SMOOTH;
import java.awt.*;
import java.awt.TrayIcon.MessageType;
import java.awt.image.BufferedImage;
import javafx.application.Platform;
import javafx.stage.Stage;
@ -12,11 +15,19 @@ import dev.kske.eventbus.Event;
import envoy.data.Message;
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.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
* @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
* 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
@ -47,9 +71,9 @@ public final class StatusTrayIcon implements EventListener {
* @since Envoy Client v0.2-beta
*/
public StatusTrayIcon(Stage stage) {
trayIcon = new TrayIcon(IconUtil.loadAWTCompatible("/icons/envoy_logo.png"), "Envoy");
trayIcon.setImageAutoSize(true);
trayIcon.setToolTip("You are notified if you have unread messages.");
size = SystemTray.getSystemTray().getTrayIconSize();
logo = IconUtil.loadAWTCompatible("/icons/envoy_logo.png").getScaledInstance(size.width,
size.height, SCALE_SMOOTH);
final var popup = new PopupMenu();
@ -60,10 +84,7 @@ public final class StatusTrayIcon implements EventListener {
// Adding the logout menu item
final var logoutMenuItem = new MenuItem("Logout");
logoutMenuItem.addActionListener(evt -> {
hide();
Platform.runLater(UserUtil::logout);
});
logoutMenuItem.addActionListener(evt -> Platform.runLater(UserUtil::logout));
popup.add(logoutMenuItem);
// Adding the status change items
@ -76,12 +97,17 @@ public final class StatusTrayIcon implements EventListener {
}
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
// is not BUSY (if BUSY, displayMessages will be false)
stage.focusedProperty().addListener((ov, wasFocused, isFocused) -> displayMessages =
!displayMessages && wasFocused ? false : !isFocused);
// is not BUSY (if BUSY, displayMessageNotification will be false)
stage.focusedProperty()
.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
trayIcon.addActionListener(evt -> Platform.runLater(() -> {
@ -102,7 +128,7 @@ public final class StatusTrayIcon implements EventListener {
public void show() {
try {
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
*/
@Event(eventType = Logout.class)
public void hide() {
SystemTray.getSystemTray().remove(trayIcon);
}
@Event
private void onOwnStatusChange(OwnStatusChange statusChange) {
displayMessages = !statusChange.get().equals(UserStatus.BUSY);
displayMessageNotification = !statusChange.get().equals(UserStatus.BUSY);
trayIcon.getImage().flush();
trayIcon.setImage(createImage());
}
@Event
private void onMessage(Message message) {
if (displayMessages)
if (displayMessageNotification)
trayIcon
.displayMessage(message.hasAttachment()
? "New " + message.getAttachment().getType().toString().toLowerCase()
+ " message received"
: "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));
// Profile picture
final var contactProfilePic =
var contactProfilePic =
new ProfilePicImageView(chat instanceof GroupChat ? groupIcon : userIcon, 32);
getChildren().add(contactProfilePic);
// Spacing
final var leftSpacing = new Region();
var leftSpacing = new Region();
leftSpacing.setPrefSize(8, 0);
leftSpacing.setMinSize(8, 0);
leftSpacing.setMaxSize(8, 0);
@ -48,17 +48,15 @@ public final class ChatControl extends HBox {
// Unread messages
if (chat.getUnreadAmount() != 0) {
final var spacing = new Region();
var spacing = new Region();
setHgrow(spacing, Priority.ALWAYS);
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);
final var vbox = new VBox();
vbox.setAlignment(Pos.CENTER_RIGHT);
unreadMessagesLabel.setAlignment(Pos.CENTER);
unreadMessagesLabel.setAlignment(Pos.CENTER_RIGHT);
unreadMessagesLabel.getStyleClass().add("unread-messages-amount");
vbox.getChildren().add(unreadMessagesLabel);
getChildren().add(vbox);
getChildren().add(unreadMessagesLabel);
}
getStyleClass().add("list-element");
}

View File

@ -107,9 +107,6 @@ public final class ChatScene implements EventListener, Restorable {
@FXML
private TextArea contactSearch;
@FXML
private VBox contactOperations;
@FXML
private TabPane tabPane;
@ -125,9 +122,6 @@ public final class ChatScene implements EventListener, Restorable {
@FXML
private HBox ownContactControl;
@FXML
private Region spaceBetweenUserAndSettingsButton;
private Chat currentChat;
private FilteredList<Chat> chats;
private Attachment pendingAttachment;
@ -175,7 +169,7 @@ public final class ChatScene implements EventListener, Restorable {
// Set the icons of buttons and image views
settingsButton.setGraphic(
new ImageView(IconUtil.loadIconThemeSensitive("settings", DEFAULT_ICON_SIZE)));
new ImageView(IconUtil.loadIconThemeSensitive("settings", 22)));
voiceButton.setGraphic(
new ImageView(IconUtil.loadIconThemeSensitive("microphone", DEFAULT_ICON_SIZE)));
attachmentButton.setGraphic(
@ -195,7 +189,6 @@ public final class ChatScene implements EventListener, Restorable {
chatList.setItems(chats = new FilteredList<>(localDB.getChats()));
// Set the design of the box in the upper-left corner
settingsButton.setAlignment(Pos.BOTTOM_RIGHT);
generateOwnStatusControl();
Platform.runLater(() -> {
@ -797,15 +790,15 @@ public final class ChatScene implements EventListener, Restorable {
private void generateOwnStatusControl() {
// Update the own user status if present
if (ownContactControl.getChildren().get(0) instanceof ContactControl)
((ContactControl) ownContactControl.getChildren().get(0)).replaceInfoLabel();
if (ownContactControl.getChildren().get(1) instanceof ContactControl)
((ContactControl) ownContactControl.getChildren().get(1)).replaceInfoLabel();
else {
// Else prepend it to the HBox children
final var ownUserControl = new ContactControl(localDB.getUser());
ownUserControl.setAlignment(Pos.CENTER_LEFT);
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();
// Add hide on close if supported
if (StatusTrayIcon.isSupported()) {
final var hideOnCloseCheckbox =
new SettingsCheckbox((SettingsItem<Boolean>) settingsItems.get("hideOnClose"));
final var hideOnCloseTooltip = new Tooltip(
"If selected, Envoy will still be present in the task bar when closed.");
hideOnCloseTooltip.setWrapText(true);
hideOnCloseCheckbox.setTooltip(hideOnCloseTooltip);
getChildren().add(hideOnCloseCheckbox);
}
final var hideOnCloseCheckbox =
new SettingsCheckbox((SettingsItem<Boolean>) settingsItems.get("hideOnClose"));
final var hideOnCloseTooltip = new Tooltip(StatusTrayIcon.isSupported()
? "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);
hideOnCloseCheckbox.setTooltip(hideOnCloseTooltip);
hideOnCloseCheckbox.setDisable(!StatusTrayIcon.isSupported());
getChildren().add(hideOnCloseCheckbox);
final var enterToSendCheckbox =
new SettingsCheckbox((SettingsItem<Boolean>) settingsItems.get("enterToSend"));

View File

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