Notify of pokes and private messages on the desktop
While none of the client's windows has the focus, a poke or a private message shows a desktop notification, which the desktop's notification centre keeps. A private conversation keeps one notification, updated as messages come; clicking it brings the window forward with that chat open, and they all go once a window of the client is in front again. On Linux they go to the freedesktop.org notification service, spoken to over the session bus by a small D-Bus client of our own on Java's Unix sockets, so no library or native code is involved; Plasma also takes a reply typed into the notification. Elsewhere they are AWT tray messages, which Windows and macOS put in their notification centres. The options can turn them off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,7 @@ public final class DesktopEntry {
|
||||
}
|
||||
try {
|
||||
Path data = dataHome();
|
||||
Path icon = data.resolve("icons").resolve(WindowClass.NAME + ".png");
|
||||
Path icon = icon();
|
||||
writeIfChanged(icon, iconPng());
|
||||
writeIfChanged(data.resolve("applications").resolve(WindowClass.NAME + ".desktop"),
|
||||
entry(icon).getBytes(StandardCharsets.UTF_8));
|
||||
@@ -49,6 +49,11 @@ public final class DesktopEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/** The application icon the entry names, which desktop notifications show too. */
|
||||
static Path icon() {
|
||||
return dataHome().resolve("icons").resolve(WindowClass.NAME + ".png");
|
||||
}
|
||||
|
||||
private static Path dataHome() {
|
||||
String xdg = System.getenv("XDG_DATA_HOME");
|
||||
if (xdg != null && !xdg.isBlank()) return Path.of(xdg);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.net.ConnectionListener.ChatScope;
|
||||
import com.ts3client.notify.desktop.DesktopNotifications;
|
||||
import com.ts3client.notify.desktop.DesktopNotifications.Notice;
|
||||
import com.ts3client.notify.desktop.FreedesktopNotifications;
|
||||
import com.ts3client.text.BBCode;
|
||||
import com.ts3client.ui.tray.AwtTray;
|
||||
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.KeyboardFocusManager;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* Tells the user about pokes and private messages through the desktop's notifications
|
||||
* while none of the client's windows has the focus. A private conversation keeps one
|
||||
* notification, updated as messages come; all of them go once the window is back in
|
||||
* front, as they have then been seen.
|
||||
*
|
||||
* <p>Called on the event thread. Talking to the desktop may block, so that happens on a
|
||||
* thread of its own, which also keeps the notifications in order.
|
||||
*/
|
||||
final class DesktopNotifier {
|
||||
|
||||
private static final int MAX_LINES = 5;
|
||||
|
||||
private final MainFrame frame;
|
||||
private final Settings settings;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "notifications");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
/** Found on first use, on the worker; {@code null} where the desktop offers none. */
|
||||
private DesktopNotifications backend;
|
||||
private boolean resolved;
|
||||
|
||||
/** One notification per private conversation; touched on the worker only. */
|
||||
private final Map<Conversation, Shown> conversations = new HashMap<>();
|
||||
private final List<Integer> pokes = new ArrayList<>();
|
||||
|
||||
private record Conversation(ServerTab tab, int clientId) {
|
||||
}
|
||||
|
||||
private static final class Shown {
|
||||
final List<String> lines = new ArrayList<>();
|
||||
int id;
|
||||
}
|
||||
|
||||
DesktopNotifier(MainFrame frame, Settings settings) {
|
||||
this.frame = frame;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
void poke(ServerTab tab, String from, String message) {
|
||||
if (!wanted()) return;
|
||||
String body = BBCode.toPlain(message);
|
||||
Notice notice = new Notice(from + " poked you", body, () -> open(tab, null), null);
|
||||
worker.execute(() -> {
|
||||
DesktopNotifications out = backend();
|
||||
if (out == null) return;
|
||||
int id = out.show(notice, 0);
|
||||
if (id != 0) pokes.add(id);
|
||||
});
|
||||
}
|
||||
|
||||
void privateMessage(ServerTab tab, int clientId, String from, String message) {
|
||||
if (!wanted()) return;
|
||||
String text = BBCode.toPlain(message);
|
||||
Conversation key = new Conversation(tab, clientId);
|
||||
worker.execute(() -> {
|
||||
DesktopNotifications out = backend();
|
||||
if (out == null) return;
|
||||
Shown shown = conversations.computeIfAbsent(key, k -> new Shown());
|
||||
shown.lines.add(text);
|
||||
if (shown.lines.size() > MAX_LINES) shown.lines.removeFirst();
|
||||
Notice notice = new Notice(from, String.join("\n", shown.lines),
|
||||
() -> open(tab, clientId), reply -> reply(tab, clientId, reply));
|
||||
shown.id = out.show(notice, shown.id);
|
||||
});
|
||||
}
|
||||
|
||||
/** The window is in front again: what was notified of is in plain sight. */
|
||||
void windowActivated() {
|
||||
worker.execute(() -> {
|
||||
if (backend == null) return;
|
||||
conversations.values().forEach(shown -> backend.close(shown.id));
|
||||
conversations.clear();
|
||||
pokes.forEach(backend::close);
|
||||
pokes.clear();
|
||||
});
|
||||
}
|
||||
|
||||
/** A private chat on a server that went away can neither be opened nor answered. */
|
||||
void forget(ServerTab tab) {
|
||||
worker.execute(() -> conversations.entrySet().removeIf(e -> {
|
||||
if (e.getKey().tab() != tab) return false;
|
||||
if (backend != null) backend.close(e.getValue().id);
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
void shutdown() {
|
||||
worker.execute(() -> {
|
||||
if (backend != null) backend.shutdown();
|
||||
});
|
||||
worker.shutdown();
|
||||
}
|
||||
|
||||
private boolean wanted() {
|
||||
// Some window of ours has the focus exactly when there is an active window.
|
||||
return settings.desktopNotifications
|
||||
&& KeyboardFocusManager.getCurrentKeyboardFocusManager().getActiveWindow() == null;
|
||||
}
|
||||
|
||||
private DesktopNotifications backend() {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
backend = create();
|
||||
}
|
||||
return backend;
|
||||
}
|
||||
|
||||
/** The desktop's notification service on Linux; elsewhere the tray icon's messages. */
|
||||
private DesktopNotifications create() {
|
||||
if (System.getProperty("os.name", "").toLowerCase(Locale.ROOT).startsWith("linux")) {
|
||||
DesktopNotifications service = FreedesktopNotifications.connect(
|
||||
"TS3J", WindowClass.NAME, DesktopEntry.icon().toString());
|
||||
if (service != null) return service;
|
||||
}
|
||||
return frame.awtTray() instanceof AwtTray tray ? new TrayNotifications(tray) : null;
|
||||
}
|
||||
|
||||
private void open(ServerTab tab, Integer clientId) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
frame.bringToFront();
|
||||
frame.selectTab(tab);
|
||||
if (clientId != null) {
|
||||
var client = tab.connection().getModel().getClient(clientId);
|
||||
if (client != null) tab.chat().openPrivateChat(clientId, client.uniqueId, client.nickname);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void reply(ServerTab tab, int clientId, String text) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
if (tab.session().isConnected()) tab.session().sendChat(ChatScope.PRIVATE, clientId, text);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import com.ts3client.session.ServerSession;
|
||||
import com.ts3client.session.Sessions;
|
||||
import com.ts3client.sound.SoundNotifier;
|
||||
import com.ts3client.sound.SoundPlayer;
|
||||
import com.ts3client.ui.tray.TrayBackend;
|
||||
|
||||
import javax.swing.AbstractAction;
|
||||
import javax.swing.JCheckBox;
|
||||
@@ -35,6 +36,7 @@ import javax.swing.SwingUtilities;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.GridLayout;
|
||||
import java.awt.KeyboardFocusManager;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.event.WindowAdapter;
|
||||
import java.awt.event.WindowEvent;
|
||||
@@ -81,6 +83,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
|
||||
/** Mirrors the active server's own client state next to the clock; set once the window is up. */
|
||||
private volatile TrayController tray;
|
||||
private final DesktopNotifier notifier;
|
||||
|
||||
private MainToolbar toolbar;
|
||||
private StatusBar statusBar;
|
||||
@@ -100,6 +103,11 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
this.sounds = new SoundNotifier(settings);
|
||||
this.soundPlayer = audio.createSoundPlayer(settings);
|
||||
this.sounds.setPlayer(soundPlayer);
|
||||
this.notifier = new DesktopNotifier(this, settings);
|
||||
// Any of the client's windows coming to the front counts, a dialog included.
|
||||
KeyboardFocusManager.getCurrentKeyboardFocusManager().addPropertyChangeListener("activeWindow", e -> {
|
||||
if (e.getNewValue() != null) notifier.windowActivated();
|
||||
});
|
||||
this.hotkeys = new HotkeyService(new HotkeyActions(this), this::hotkeyArgumentChoices);
|
||||
hotkeys.addKeyPressListener(() -> SwingUtilities.invokeLater(sessions::keyPressed));
|
||||
contacts.addListener(() -> SwingUtilities.invokeLater(this::contactsChanged));
|
||||
@@ -437,9 +445,21 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
void tabDisconnected(ServerTab tab) {
|
||||
if (!tabs.contains(tab)) return;
|
||||
sessions.disconnected(tab.session());
|
||||
notifier.forget(tab);
|
||||
tabUpdated(tab);
|
||||
}
|
||||
|
||||
/** Tells the user of a poke or a private message while the client is in the background. */
|
||||
DesktopNotifier notifier() {
|
||||
return notifier;
|
||||
}
|
||||
|
||||
/** The tray icon when AWT docked it, whose messages are the notifications on Windows and macOS. */
|
||||
TrayBackend awtTray() {
|
||||
TrayController current = tray;
|
||||
return current == null ? null : current.backend();
|
||||
}
|
||||
|
||||
// ---- hotkeys ----
|
||||
|
||||
/**
|
||||
@@ -537,7 +557,14 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
IconTheme.get().reload(settings);
|
||||
}
|
||||
|
||||
/** Brings the window back from the tray, the taskbar or behind other windows. */
|
||||
void bringToFront() {
|
||||
TrayController current = tray;
|
||||
if (current != null) {
|
||||
// The tray can ask the window manager in a way it honours for a window without focus.
|
||||
current.show();
|
||||
return;
|
||||
}
|
||||
setVisible(true);
|
||||
setExtendedState(getExtendedState() & ~JFrame.ICONIFIED);
|
||||
toFront();
|
||||
@@ -743,6 +770,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
soundPlayer.shutdown();
|
||||
hotkeys.dispose();
|
||||
if (tray != null) tray.dispose();
|
||||
notifier.shutdown();
|
||||
}
|
||||
|
||||
/** Leaves every server and ends the process. */
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.ts3client.sound.SoundNotifier;
|
||||
import com.ts3client.sound.SoundPack;
|
||||
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JFileChooser;
|
||||
@@ -76,6 +77,8 @@ final class NotificationsPanel extends JPanel {
|
||||
private final JLabel packInfo = new JLabel();
|
||||
private final JSlider volume;
|
||||
private final JTextField packDirField;
|
||||
private final JCheckBox desktopNotifications =
|
||||
new JCheckBox("Show pokes and private messages as desktop notifications while in the background");
|
||||
private final JTable table;
|
||||
private final EventTableModel tableModel = new EventTableModel();
|
||||
|
||||
@@ -107,6 +110,7 @@ final class NotificationsPanel extends JPanel {
|
||||
String directory = packDirField.getText().trim();
|
||||
boolean rescan = !directory.equals(settings.soundPackDir);
|
||||
settings.soundPackDir = directory;
|
||||
settings.desktopNotifications = desktopNotifications.isSelected();
|
||||
sounds.setPack((SoundPack) packCombo.getSelectedItem());
|
||||
if (rescan) sounds.reload();
|
||||
}
|
||||
@@ -153,7 +157,14 @@ final class NotificationsPanel extends JPanel {
|
||||
dirRow.add(packDirField, BorderLayout.CENTER);
|
||||
dirRow.add(browse, BorderLayout.EAST);
|
||||
packDirField.setToolTipText("Extra folder to look for sound packs in");
|
||||
addRow(p, c, row, new JLabel("Extra pack folder:"), dirRow);
|
||||
addRow(p, c, row++, new JLabel("Extra pack folder:"), dirRow);
|
||||
|
||||
desktopNotifications.setSelected(settings.desktopNotifications);
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.gridwidth = 2;
|
||||
p.add(desktopNotifications, c);
|
||||
c.gridwidth = 1;
|
||||
return p;
|
||||
}
|
||||
|
||||
|
||||
@@ -136,8 +136,11 @@ final class ServerTabConnectionEvents implements ServerSession.Listener {
|
||||
public void onChat(ChatScope scope, int fromClientId, String fromUniqueId, String fromName, String message) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
switch (scope) {
|
||||
case PRIVATE -> chatPanel.appendPrivateMessage(fromClientId, fromUniqueId, fromName,
|
||||
fromClientId, fromUniqueId, fromName, message);
|
||||
case PRIVATE -> {
|
||||
chatPanel.appendPrivateMessage(fromClientId, fromUniqueId, fromName,
|
||||
fromClientId, fromUniqueId, fromName, message);
|
||||
host.notifier().privateMessage(tab, fromClientId, fromName, message);
|
||||
}
|
||||
case SERVER -> chatPanel.appendServerMessage(fromClientId, fromUniqueId, fromName, message);
|
||||
default -> chatPanel.appendChannelMessage(fromClientId, fromUniqueId, fromName, message);
|
||||
}
|
||||
@@ -183,6 +186,7 @@ final class ServerTabConnectionEvents implements ServerSession.Listener {
|
||||
@Override
|
||||
public void onPoke(String fromName, String message) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
host.notifier().poke(tab, fromName, message);
|
||||
host.selectTab(tab);
|
||||
JOptionPane.showMessageDialog(host, fromName + " poked you:\n\n" + message,
|
||||
"Poke", JOptionPane.INFORMATION_MESSAGE);
|
||||
|
||||
@@ -110,6 +110,11 @@ final class TrayController {
|
||||
if (tray != null) tray.dispose();
|
||||
}
|
||||
|
||||
/** The backend that took the icon, or {@code null} when none could. */
|
||||
TrayBackend backend() {
|
||||
return tray;
|
||||
}
|
||||
|
||||
/** The icon a state is drawn with, from the active icon pack. */
|
||||
private static ImageIcon icon(SelfState state, int size) {
|
||||
return switch (state) {
|
||||
@@ -230,7 +235,7 @@ final class TrayController {
|
||||
* not focused, which is every time the tray icon is used, so the backend is asked
|
||||
* first — it can make the request the window manager does honour.
|
||||
*/
|
||||
private void show() {
|
||||
void show() {
|
||||
hideMenu();
|
||||
frame.setVisible(true);
|
||||
frame.setExtendedState(frame.getExtendedState() & ~Frame.ICONIFIED);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.notify.desktop.DesktopNotifications;
|
||||
import com.ts3client.ui.tray.AwtTray;
|
||||
|
||||
/**
|
||||
* Desktop notifications as messages from AWT's tray icon, for Windows and macOS: they
|
||||
* land in the system's notification centre. A message cannot be replaced or taken back.
|
||||
*/
|
||||
final class TrayNotifications implements DesktopNotifications {
|
||||
|
||||
private final AwtTray tray;
|
||||
|
||||
TrayNotifications(AwtTray tray) {
|
||||
this.tray = tray;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int show(Notice notice, int replaces) {
|
||||
tray.showMessage(notice.title(), notice.body(), notice.onOpen());
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close(int id) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canReply() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ public final class AwtTray implements TrayBackend {
|
||||
|
||||
private final TrayIcon icon;
|
||||
private final int size;
|
||||
/** What a click on the message last shown does, until it is used. */
|
||||
private volatile Runnable messageClick;
|
||||
|
||||
/**
|
||||
* @param menu the icon's context menu, or {@code null} to have right clicks
|
||||
@@ -64,9 +66,24 @@ public final class AwtTray implements TrayBackend {
|
||||
}
|
||||
}
|
||||
});
|
||||
// Fired by a click on a message (and on some desktops a double click on the icon).
|
||||
icon.addActionListener(e -> {
|
||||
Runnable click = messageClick;
|
||||
messageClick = null;
|
||||
if (click != null) click.run();
|
||||
});
|
||||
SystemTray.getSystemTray().add(icon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a message by the icon, which Windows turns into a toast in its notification
|
||||
* centre and macOS into a banner in its own.
|
||||
*/
|
||||
public void showMessage(String title, String body, Runnable onClick) {
|
||||
messageClick = onClick;
|
||||
icon.displayMessage(title, body, TrayIcon.MessageType.NONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setIcon(IntFunction<Image> render) {
|
||||
Image image = render.apply(size);
|
||||
|
||||
Reference in New Issue
Block a user