Add an away button with global actions and message presets
The toolbar gains an away toggle with a drop-down: it toggles away on the
current server with no message, while the menu carries the global actions
("Set Globally Away", "Set Globally Away Status"), the saved presets and
their editor. Presets live in ~/.ts3jclient/away.properties and are
managed in a list with add/remove and double-click renaming.
Clients in the channel tree now show their away message in brackets after
the nickname instead of their primary server group.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
package com.ts3client.config;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent list of away-message presets, stored alongside the settings file.
|
||||||
|
* Frontend-agnostic: no UI dependencies.
|
||||||
|
*/
|
||||||
|
public final class AwayMessages {
|
||||||
|
|
||||||
|
private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient");
|
||||||
|
private static final File FILE = new File(DIR, "away.properties");
|
||||||
|
|
||||||
|
private final List<String> entries = new ArrayList<>();
|
||||||
|
|
||||||
|
public List<String> all() {
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void add(String message) {
|
||||||
|
entries.add(message == null ? "" : message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void set(int index, String message) {
|
||||||
|
if (index >= 0 && index < entries.size()) entries.set(index, message == null ? "" : message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void remove(int index) {
|
||||||
|
if (index >= 0 && index < entries.size()) entries.remove(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AwayMessages load() {
|
||||||
|
AwayMessages m = new AwayMessages();
|
||||||
|
if (!FILE.isFile()) return m.withDefaults();
|
||||||
|
Properties p = new Properties();
|
||||||
|
try (FileInputStream in = new FileInputStream(FILE)) {
|
||||||
|
p.load(in);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return m.withDefaults();
|
||||||
|
}
|
||||||
|
int count = parseInt(p.getProperty("count"), 0);
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
String text = p.getProperty("message." + i);
|
||||||
|
if (text != null && !text.isBlank()) m.entries.add(text);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void save() {
|
||||||
|
Properties p = new Properties();
|
||||||
|
p.setProperty("count", Integer.toString(entries.size()));
|
||||||
|
for (int i = 0; i < entries.size(); i++) {
|
||||||
|
p.setProperty("message." + i, entries.get(i));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!DIR.isDirectory()) {
|
||||||
|
//noinspection ResultOfMethodCallIgnored
|
||||||
|
DIR.mkdirs();
|
||||||
|
}
|
||||||
|
try (FileOutputStream out = new FileOutputStream(FILE)) {
|
||||||
|
p.store(out, "TS3J client away messages");
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First run: seed the presets a fresh client offers. */
|
||||||
|
private AwayMessages withDefaults() {
|
||||||
|
entries.add("Away from keyboard");
|
||||||
|
entries.add("Be right back");
|
||||||
|
entries.add("Lunch");
|
||||||
|
entries.add("Busy");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int parseInt(String v, int def) {
|
||||||
|
if (v == null) return def;
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(v.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ public final class ClientEntry {
|
|||||||
public boolean inputMuted; // microphone muted (client_input_muted)
|
public boolean inputMuted; // microphone muted (client_input_muted)
|
||||||
public boolean outputMuted; // speakers muted / deafened (client_output_muted)
|
public boolean outputMuted; // speakers muted / deafened (client_output_muted)
|
||||||
public boolean away;
|
public boolean away;
|
||||||
|
/** The message published with the away state, empty when there is none. */
|
||||||
|
public String awayMessage = "";
|
||||||
public boolean channelCommander;
|
public boolean channelCommander;
|
||||||
public boolean self;
|
public boolean self;
|
||||||
|
|
||||||
|
|||||||
@@ -361,6 +361,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
e.inputMuted = cl.isInputMuted();
|
e.inputMuted = cl.isInputMuted();
|
||||||
e.outputMuted = cl.isOutputMuted();
|
e.outputMuted = cl.isOutputMuted();
|
||||||
e.away = cl.isAway();
|
e.away = cl.isAway();
|
||||||
|
e.awayMessage = orEmpty(cl.get("client_away_message"));
|
||||||
e.uniqueId = cl.getUniqueIdentifier();
|
e.uniqueId = cl.getUniqueIdentifier();
|
||||||
e.serverGroupIds = cl.getServerGroups();
|
e.serverGroupIds = cl.getServerGroups();
|
||||||
e.channelGroupId = cl.getChannelGroupId();
|
e.channelGroupId = cl.getChannelGroupId();
|
||||||
@@ -581,7 +582,10 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
|
|
||||||
public void setAway(boolean away, String message) {
|
public void setAway(boolean away, String message) {
|
||||||
sound(away ? SoundEvent.STATUS_SET_AWAY : SoundEvent.STATUS_SET_PRESENT);
|
sound(away ? SoundEvent.STATUS_SET_AWAY : SoundEvent.STATUS_SET_PRESENT);
|
||||||
updateSelf(self -> self.away = away);
|
updateSelf(self -> {
|
||||||
|
self.away = away;
|
||||||
|
self.awayMessage = away && message != null ? message : "";
|
||||||
|
});
|
||||||
selfUpdate(cmd -> {
|
selfUpdate(cmd -> {
|
||||||
cmd.add(new CommandSingleParameter("client_away", away ? "1" : "0"));
|
cmd.add(new CommandSingleParameter("client_away", away ? "1" : "0"));
|
||||||
cmd.add(new CommandSingleParameter("client_away_message", away && message != null ? message : ""));
|
cmd.add(new CommandSingleParameter("client_away_message", away && message != null ? message : ""));
|
||||||
@@ -648,6 +652,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
c.inputMuted = e.isClientInputMuted();
|
c.inputMuted = e.isClientInputMuted();
|
||||||
c.outputMuted = e.isClientOutputMuted();
|
c.outputMuted = e.isClientOutputMuted();
|
||||||
c.away = e.isClientAway();
|
c.away = e.isClientAway();
|
||||||
|
c.awayMessage = orEmpty(e.get("client_away_message"));
|
||||||
c.uniqueId = orEmpty(e.getUniqueClientIdentifier());
|
c.uniqueId = orEmpty(e.getUniqueClientIdentifier());
|
||||||
c.serverGroupIds = parseIntList(e.getClientServerGroups());
|
c.serverGroupIds = parseIntList(e.getClientServerGroups());
|
||||||
c.channelGroupId = e.getClientChannelGroupId();
|
c.channelGroupId = e.getClientChannelGroupId();
|
||||||
@@ -814,7 +819,14 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
if (renamed) c.nickname = e.get("client_nickname");
|
if (renamed) c.nickname = e.get("client_nickname");
|
||||||
if (has(e, "client_input_muted")) c.inputMuted = e.getBoolean("client_input_muted");
|
if (has(e, "client_input_muted")) c.inputMuted = e.getBoolean("client_input_muted");
|
||||||
if (has(e, "client_output_muted")) c.outputMuted = e.getBoolean("client_output_muted");
|
if (has(e, "client_output_muted")) c.outputMuted = e.getBoolean("client_output_muted");
|
||||||
if (has(e, "client_away")) c.away = e.getBoolean("client_away");
|
if (has(e, "client_away")) {
|
||||||
|
c.away = e.getBoolean("client_away");
|
||||||
|
// Both fields travel together, so an absent message here means "no message"
|
||||||
|
// — which `has` cannot tell from "not reported".
|
||||||
|
c.awayMessage = c.away ? orEmpty(e.get("client_away_message")) : "";
|
||||||
|
} else if (has(e, "client_away_message")) {
|
||||||
|
c.awayMessage = e.get("client_away_message");
|
||||||
|
}
|
||||||
if (has(e, "client_talk_power")) c.talkPower = e.getInt("client_talk_power");
|
if (has(e, "client_talk_power")) c.talkPower = e.getInt("client_talk_power");
|
||||||
if (has(e, "client_is_channel_commander"))
|
if (has(e, "client_is_channel_commander"))
|
||||||
c.channelCommander = e.getBoolean("client_is_channel_commander");
|
c.channelCommander = e.getBoolean("client_is_channel_commander");
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.config.AwayMessages;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.Box;
|
||||||
|
import javax.swing.BoxLayout;
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JDialog;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JScrollPane;
|
||||||
|
import javax.swing.JTable;
|
||||||
|
import javax.swing.ListSelectionModel;
|
||||||
|
import javax.swing.table.AbstractTableModel;
|
||||||
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.Dimension;
|
||||||
|
import java.awt.Frame;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor for the away-message presets: a plain list of entries that are renamed
|
||||||
|
* by double-clicking them, plus Add and Remove.
|
||||||
|
*
|
||||||
|
* <p>Backed by a one-column table because that is what gives Swing's list look
|
||||||
|
* inline editing for free. Every change is written straight through to the
|
||||||
|
* {@link AwayMessages} store.
|
||||||
|
*/
|
||||||
|
public final class AwayMessagesDialog extends JDialog {
|
||||||
|
|
||||||
|
private final AwayMessages messages;
|
||||||
|
private final Runnable onChanged;
|
||||||
|
private final Model model = new Model();
|
||||||
|
private final JTable table = new JTable(model);
|
||||||
|
|
||||||
|
public AwayMessagesDialog(Frame owner, AwayMessages messages, Runnable onChanged) {
|
||||||
|
super(owner, "Away Message Presets", true);
|
||||||
|
this.messages = messages;
|
||||||
|
this.onChanged = onChanged;
|
||||||
|
|
||||||
|
table.setTableHeader(null);
|
||||||
|
table.setShowGrid(false);
|
||||||
|
table.setFillsViewportHeight(true);
|
||||||
|
table.setRowHeight(Math.max(20, table.getRowHeight()));
|
||||||
|
table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||||
|
// Single click selects, double click starts editing — like a renameable list.
|
||||||
|
table.putClientProperty("JTable.autoStartsEdit", Boolean.FALSE);
|
||||||
|
|
||||||
|
JScrollPane scroll = new JScrollPane(table);
|
||||||
|
scroll.setBorder(BorderFactory.createEmptyBorder(8, 8, 4, 8));
|
||||||
|
|
||||||
|
getContentPane().setLayout(new BorderLayout());
|
||||||
|
getContentPane().add(scroll, BorderLayout.CENTER);
|
||||||
|
getContentPane().add(buildButtons(), BorderLayout.SOUTH);
|
||||||
|
|
||||||
|
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||||
|
setSize(new Dimension(360, 300));
|
||||||
|
setLocationRelativeTo(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JPanel buildButtons() {
|
||||||
|
JPanel buttons = new JPanel();
|
||||||
|
buttons.setLayout(new BoxLayout(buttons, BoxLayout.X_AXIS));
|
||||||
|
buttons.setBorder(BorderFactory.createEmptyBorder(0, 8, 8, 8));
|
||||||
|
addButton(buttons, "Add", this::addEntry);
|
||||||
|
addButton(buttons, "Remove", this::removeSelected);
|
||||||
|
buttons.add(Box.createHorizontalGlue());
|
||||||
|
addButton(buttons, "Close", this::closeDialog);
|
||||||
|
return buttons;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addButton(JPanel panel, String text, Runnable action) {
|
||||||
|
JButton b = new JButton(text);
|
||||||
|
b.addActionListener(e -> action.run());
|
||||||
|
panel.add(b);
|
||||||
|
panel.add(Box.createHorizontalStrut(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addEntry() {
|
||||||
|
stopEditing();
|
||||||
|
messages.add("New away message");
|
||||||
|
int row = messages.all().size() - 1;
|
||||||
|
model.fireTableRowsInserted(row, row);
|
||||||
|
table.setRowSelectionInterval(row, row);
|
||||||
|
table.scrollRectToVisible(table.getCellRect(row, 0, true));
|
||||||
|
persist();
|
||||||
|
table.editCellAt(row, 0);
|
||||||
|
if (table.getEditorComponent() != null) table.getEditorComponent().requestFocusInWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeSelected() {
|
||||||
|
stopEditing();
|
||||||
|
int row = table.getSelectedRow();
|
||||||
|
if (row < 0) return;
|
||||||
|
messages.remove(row);
|
||||||
|
model.fireTableRowsDeleted(row, row);
|
||||||
|
if (!messages.all().isEmpty()) {
|
||||||
|
int next = Math.min(row, messages.all().size() - 1);
|
||||||
|
table.setRowSelectionInterval(next, next);
|
||||||
|
}
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeDialog() {
|
||||||
|
stopEditing();
|
||||||
|
dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flushes a cell that is still being edited so its text is not lost. */
|
||||||
|
private void stopEditing() {
|
||||||
|
if (table.isEditing()) table.getCellEditor().stopCellEditing();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void persist() {
|
||||||
|
messages.save();
|
||||||
|
if (onChanged != null) onChanged.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class Model extends AbstractTableModel {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getRowCount() {
|
||||||
|
return messages.all().size();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getColumnCount() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object getValueAt(int row, int column) {
|
||||||
|
return messages.all().get(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isCellEditable(int row, int column) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setValueAt(Object value, int row, int column) {
|
||||||
|
String text = value == null ? "" : value.toString().trim();
|
||||||
|
if (text.isEmpty()) return; // an emptied entry keeps its old text
|
||||||
|
messages.set(row, text);
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.Icon;
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JPopupMenu;
|
||||||
|
import javax.swing.JToggleButton;
|
||||||
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.Component;
|
||||||
|
import java.awt.Dimension;
|
||||||
|
import java.awt.Graphics;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.Insets;
|
||||||
|
import java.awt.RenderingHints;
|
||||||
|
import java.awt.event.ActionListener;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A toolbar toggle button with a small arrow next to it that opens a menu:
|
||||||
|
* clicking the button toggles, clicking the arrow offers the related actions.
|
||||||
|
*
|
||||||
|
* <p>The menu is built on every click by the supplier, so it can reflect the
|
||||||
|
* current state (checked items, the presets that exist right now).
|
||||||
|
*/
|
||||||
|
public final class DropDownToggleButton extends JPanel {
|
||||||
|
|
||||||
|
private final JToggleButton button = new JToggleButton();
|
||||||
|
private final JButton arrow = new JButton(new ArrowIcon());
|
||||||
|
|
||||||
|
public DropDownToggleButton(Icon icon, String tooltip, Supplier<JPopupMenu> menu) {
|
||||||
|
super(new BorderLayout());
|
||||||
|
setOpaque(false);
|
||||||
|
button.setIcon(icon);
|
||||||
|
button.setToolTipText(tooltip);
|
||||||
|
|
||||||
|
arrow.setToolTipText(tooltip);
|
||||||
|
arrow.setMargin(new Insets(0, 2, 0, 2));
|
||||||
|
arrow.setFocusable(false);
|
||||||
|
arrow.addActionListener(e -> {
|
||||||
|
JPopupMenu popup = menu.get();
|
||||||
|
if (popup != null) popup.show(this, 0, getHeight());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the whole control the size of a plain toolbar button: the arrow's
|
||||||
|
// width comes out of the toggle half instead of being added to it.
|
||||||
|
Dimension plain = button.getPreferredSize();
|
||||||
|
button.setPreferredSize(new Dimension(
|
||||||
|
Math.max(icon.getIconWidth() + 8, plain.width - arrow.getPreferredSize().width),
|
||||||
|
plain.height));
|
||||||
|
|
||||||
|
add(button, BorderLayout.CENTER);
|
||||||
|
add(arrow, BorderLayout.EAST);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Listener for the toggle half only; the arrow half opens the menu instead. */
|
||||||
|
public void addActionListener(ActionListener l) {
|
||||||
|
button.addActionListener(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSelected() {
|
||||||
|
return button.isSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSelected(boolean selected) {
|
||||||
|
button.setSelected(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIcon(Icon icon) {
|
||||||
|
button.setIcon(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
super.setEnabled(enabled);
|
||||||
|
button.setEnabled(enabled);
|
||||||
|
arrow.setEnabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disables the toggle half on its own, leaving the menu reachable. */
|
||||||
|
public void setToggleEnabled(boolean enabled) {
|
||||||
|
button.setEnabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Dimension getMaximumSize() {
|
||||||
|
return getPreferredSize(); // keep the toolbar from stretching us
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The downward triangle on the menu half of the button. */
|
||||||
|
private static final class ArrowIcon implements Icon {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void paintIcon(Component c, Graphics g0, int x, int y) {
|
||||||
|
Graphics2D g = (Graphics2D) g0.create();
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
|
g.setColor(c.isEnabled() ? Color.DARK_GRAY : Color.GRAY);
|
||||||
|
int[] xs = {x, x + getIconWidth(), x + getIconWidth() / 2};
|
||||||
|
int[] ys = {y, y, y + getIconHeight()};
|
||||||
|
g.fillPolygon(xs, ys, 3);
|
||||||
|
g.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getIconWidth() {
|
||||||
|
return 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getIconHeight() {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,6 +148,11 @@ public final class Icons {
|
|||||||
return themed("ACTIVATE_MICROPHONE", Icons::paintMicActive);
|
return themed("ACTIVATE_MICROPHONE", Icons::paintMicActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The toolbar's away marker. */
|
||||||
|
public static ImageIcon away() {
|
||||||
|
return themed("AWAY", IconTheme.TOOLBAR_SIZE, g -> paintPerson(g, Theme.AWAY));
|
||||||
|
}
|
||||||
|
|
||||||
public static ImageIcon settings() {
|
public static ImageIcon settings() {
|
||||||
return themed("SETTINGS", IconTheme.TOOLBAR_SIZE, Icons::paintSettings);
|
return themed("SETTINGS", IconTheme.TOOLBAR_SIZE, Icons::paintSettings);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.ts3client.ui;
|
|||||||
|
|
||||||
import com.ts3client.audio.AudioBackend;
|
import com.ts3client.audio.AudioBackend;
|
||||||
import com.ts3client.audio.desktop.DesktopAudioBackend;
|
import com.ts3client.audio.desktop.DesktopAudioBackend;
|
||||||
|
import com.ts3client.config.AwayMessages;
|
||||||
import com.ts3client.config.Bookmark;
|
import com.ts3client.config.Bookmark;
|
||||||
import com.ts3client.config.Bookmarks;
|
import com.ts3client.config.Bookmarks;
|
||||||
import com.ts3client.config.IdentityStore;
|
import com.ts3client.config.IdentityStore;
|
||||||
@@ -21,6 +22,7 @@ import javax.swing.JMenuBar;
|
|||||||
import javax.swing.JMenuItem;
|
import javax.swing.JMenuItem;
|
||||||
import javax.swing.JOptionPane;
|
import javax.swing.JOptionPane;
|
||||||
import javax.swing.JPanel;
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JPopupMenu;
|
||||||
import javax.swing.JTextField;
|
import javax.swing.JTextField;
|
||||||
import javax.swing.JToggleButton;
|
import javax.swing.JToggleButton;
|
||||||
import javax.swing.JToolBar;
|
import javax.swing.JToolBar;
|
||||||
@@ -50,6 +52,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
|
|
||||||
private final Settings settings;
|
private final Settings settings;
|
||||||
private final Bookmarks bookmarks = Bookmarks.load();
|
private final Bookmarks bookmarks = Bookmarks.load();
|
||||||
|
private final AwayMessages awayMessages = AwayMessages.load();
|
||||||
private final IdentityStore identities;
|
private final IdentityStore identities;
|
||||||
private final AudioBackend audio = new DesktopAudioBackend();
|
private final AudioBackend audio = new DesktopAudioBackend();
|
||||||
/** Sound pack playback, shared by every connection. */
|
/** Sound pack playback, shared by every connection. */
|
||||||
@@ -66,6 +69,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
|
|
||||||
private JMenu bookmarksMenu;
|
private JMenu bookmarksMenu;
|
||||||
private JCheckBoxMenuItem awayItem;
|
private JCheckBoxMenuItem awayItem;
|
||||||
|
private JMenuItem awayStatusItem;
|
||||||
private JCheckBoxMenuItem commanderItem;
|
private JCheckBoxMenuItem commanderItem;
|
||||||
private javax.swing.Timer statusTimer;
|
private javax.swing.Timer statusTimer;
|
||||||
|
|
||||||
@@ -78,6 +82,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
private JToggleButton activeButton;
|
private JToggleButton activeButton;
|
||||||
private JToggleButton micButton;
|
private JToggleButton micButton;
|
||||||
private JToggleButton speakerButton;
|
private JToggleButton speakerButton;
|
||||||
|
private DropDownToggleButton awayButton;
|
||||||
|
|
||||||
private boolean pttPressed;
|
private boolean pttPressed;
|
||||||
|
|
||||||
@@ -173,6 +178,8 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
deaf.addActionListener(e -> speakerButton.doClick());
|
deaf.addActionListener(e -> speakerButton.doClick());
|
||||||
awayItem = new JCheckBoxMenuItem("Away", Icons.of("AWAY"), false);
|
awayItem = new JCheckBoxMenuItem("Away", Icons.of("AWAY"), false);
|
||||||
awayItem.addActionListener(e -> toggleAway());
|
awayItem.addActionListener(e -> toggleAway());
|
||||||
|
awayStatusItem = new JMenuItem("Set away status…", Icons.of("EDIT"));
|
||||||
|
awayStatusItem.addActionListener(e -> setAwayStatus());
|
||||||
commanderItem = new JCheckBoxMenuItem("Channel commander", Icons.of("CHANNEL_COMMANDER"), false);
|
commanderItem = new JCheckBoxMenuItem("Channel commander", Icons.of("CHANNEL_COMMANDER"), false);
|
||||||
commanderItem.addActionListener(e -> {
|
commanderItem.addActionListener(e -> {
|
||||||
if (selected != null) selected.setCommander(commanderItem.isSelected());
|
if (selected != null) selected.setCommander(commanderItem.isSelected());
|
||||||
@@ -183,6 +190,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
self.add(deaf);
|
self.add(deaf);
|
||||||
self.addSeparator();
|
self.addSeparator();
|
||||||
self.add(awayItem);
|
self.add(awayItem);
|
||||||
|
self.add(awayStatusItem);
|
||||||
self.add(commanderItem);
|
self.add(commanderItem);
|
||||||
self.addSeparator();
|
self.addSeparator();
|
||||||
self.add(nick);
|
self.add(nick);
|
||||||
@@ -263,6 +271,16 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
updateToolbar();
|
updateToolbar();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
awayButton = new DropDownToggleButton(Icons.away(),
|
||||||
|
"Away on this server (the arrow offers the global actions and presets)",
|
||||||
|
this::buildAwayMenu);
|
||||||
|
awayButton.addActionListener(e -> {
|
||||||
|
if (selected == null) return;
|
||||||
|
// The plain toggle carries no message; the menu is where messages are chosen.
|
||||||
|
selected.setAway(awayButton.isSelected(), "");
|
||||||
|
updateToolbar();
|
||||||
|
});
|
||||||
|
|
||||||
JButton settingsButton = new JButton(Icons.settings());
|
JButton settingsButton = new JButton(Icons.settings());
|
||||||
settingsButton.setToolTipText("Options");
|
settingsButton.setToolTipText("Options");
|
||||||
settingsButton.addActionListener(e -> showSettings());
|
settingsButton.addActionListener(e -> showSettings());
|
||||||
@@ -273,6 +291,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
tb.add(activeButton);
|
tb.add(activeButton);
|
||||||
tb.add(micButton);
|
tb.add(micButton);
|
||||||
tb.add(speakerButton);
|
tb.add(speakerButton);
|
||||||
|
tb.add(awayButton);
|
||||||
tb.addSeparator();
|
tb.addSeparator();
|
||||||
tb.add(settingsButton);
|
tb.add(settingsButton);
|
||||||
tb.add(Box.createHorizontalGlue());
|
tb.add(Box.createHorizontalGlue());
|
||||||
@@ -495,18 +514,86 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
rebuildBookmarksMenu();
|
rebuildBookmarksMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The away button's drop-down: the global actions, the presets and their editor. */
|
||||||
|
private JPopupMenu buildAwayMenu() {
|
||||||
|
JPopupMenu menu = new JPopupMenu();
|
||||||
|
boolean anyConnected = tabs.stream().anyMatch(ServerTab::isConnected);
|
||||||
|
|
||||||
|
JCheckBoxMenuItem globally = new JCheckBoxMenuItem("Set Globally Away", Icons.of("AWAY"), isGloballyAway());
|
||||||
|
globally.setEnabled(anyConnected);
|
||||||
|
globally.addActionListener(e -> setAwayEverywhere(globally.isSelected(), null));
|
||||||
|
|
||||||
|
JMenuItem globalStatus = new JMenuItem("Set Globally Away Status…", Icons.of("EDIT"));
|
||||||
|
globalStatus.setEnabled(anyConnected);
|
||||||
|
globalStatus.addActionListener(e -> {
|
||||||
|
String message = askAwayMessage(currentAwayMessage());
|
||||||
|
if (message != null) setAwayEverywhere(true, message);
|
||||||
|
});
|
||||||
|
|
||||||
|
menu.add(globally);
|
||||||
|
menu.add(globalStatus);
|
||||||
|
menu.addSeparator();
|
||||||
|
|
||||||
|
for (String preset : awayMessages.all()) {
|
||||||
|
JMenuItem item = new JMenuItem(preset);
|
||||||
|
item.setEnabled(anyConnected);
|
||||||
|
item.addActionListener(e -> setAwayEverywhere(true, preset));
|
||||||
|
menu.add(item);
|
||||||
|
}
|
||||||
|
if (!awayMessages.all().isEmpty()) menu.addSeparator();
|
||||||
|
|
||||||
|
JMenuItem manage = new JMenuItem("Manage away messages…", Icons.of("EDIT"));
|
||||||
|
manage.addActionListener(e -> new AwayMessagesDialog(this, awayMessages, null).setVisible(true));
|
||||||
|
menu.add(manage);
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
private void toggleAway() {
|
private void toggleAway() {
|
||||||
if (selected == null) return;
|
if (selected == null) return;
|
||||||
boolean away = awayItem.isSelected();
|
selected.setAway(awayItem.isSelected(), "");
|
||||||
String message = null;
|
updateToolbar();
|
||||||
if (away) {
|
|
||||||
message = JOptionPane.showInputDialog(this, "Away message (optional):", "");
|
|
||||||
if (message == null) { // cancelled
|
|
||||||
awayItem.setSelected(false);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sets the away message on the selected server only. */
|
||||||
|
private void setAwayStatus() {
|
||||||
|
if (selected == null) return;
|
||||||
|
String message = askAwayMessage(selected.awayMessage());
|
||||||
|
if (message == null) return;
|
||||||
|
selected.setAway(true, message);
|
||||||
|
updateToolbar();
|
||||||
}
|
}
|
||||||
selected.setAway(away, message);
|
|
||||||
|
private void setAwayEverywhere(boolean away, String message) {
|
||||||
|
for (ServerTab tab : tabs) {
|
||||||
|
if (tab.isConnected()) tab.setAway(away, message);
|
||||||
|
}
|
||||||
|
updateToolbar();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return true when every connected server is marked away (and there is one) */
|
||||||
|
private boolean isGloballyAway() {
|
||||||
|
boolean any = false;
|
||||||
|
for (ServerTab tab : tabs) {
|
||||||
|
if (!tab.isConnected()) continue;
|
||||||
|
any = true;
|
||||||
|
if (!tab.isAway()) return false;
|
||||||
|
}
|
||||||
|
return any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The message to preload the prompt with: the selected tab's, else any set one. */
|
||||||
|
private String currentAwayMessage() {
|
||||||
|
if (selected != null && !selected.awayMessage().isEmpty()) return selected.awayMessage();
|
||||||
|
for (ServerTab tab : tabs) {
|
||||||
|
if (tab.isConnected() && !tab.awayMessage().isEmpty()) return tab.awayMessage();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the entered message (possibly empty), or null when cancelled */
|
||||||
|
private String askAwayMessage(String initial) {
|
||||||
|
return (String) JOptionPane.showInputDialog(this, "Away message (optional):", "Away status",
|
||||||
|
JOptionPane.PLAIN_MESSAGE, Icons.of("AWAY"), null, initial);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void doDisconnect() {
|
private void doDisconnect() {
|
||||||
@@ -586,6 +673,10 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
speakerButton.setEnabled(connected);
|
speakerButton.setEnabled(connected);
|
||||||
activeButton.setEnabled(connected);
|
activeButton.setEnabled(connected);
|
||||||
awayItem.setEnabled(connected);
|
awayItem.setEnabled(connected);
|
||||||
|
awayStatusItem.setEnabled(connected);
|
||||||
|
// The menu's actions are global, so the arrow stays live while any server is up.
|
||||||
|
awayButton.setEnabled(tabs.stream().anyMatch(ServerTab::isConnected));
|
||||||
|
awayButton.setToggleEnabled(connected);
|
||||||
commanderItem.setEnabled(connected);
|
commanderItem.setEnabled(connected);
|
||||||
|
|
||||||
boolean micMuted = connected && selected.isMicMuted();
|
boolean micMuted = connected && selected.isMicMuted();
|
||||||
@@ -595,7 +686,9 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
speakerButton.setSelected(deaf);
|
speakerButton.setSelected(deaf);
|
||||||
speakerButton.setIcon(deaf ? Icons.speakerMutedLarge() : Icons.speaker());
|
speakerButton.setIcon(deaf ? Icons.speakerMutedLarge() : Icons.speaker());
|
||||||
activeButton.setSelected(selected != null && selected == micTab);
|
activeButton.setSelected(selected != null && selected == micTab);
|
||||||
awayItem.setSelected(connected && selected.isAway());
|
boolean away = connected && selected.isAway();
|
||||||
|
awayItem.setSelected(away);
|
||||||
|
awayButton.setSelected(away);
|
||||||
commanderItem.setSelected(connected && selected.isCommander());
|
commanderItem.setSelected(connected && selected.isCommander());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
private boolean micMuted;
|
private boolean micMuted;
|
||||||
private boolean deafened;
|
private boolean deafened;
|
||||||
private boolean away;
|
private boolean away;
|
||||||
|
/** Away message currently published, empty when away carries no message. */
|
||||||
|
private String awayMessage = "";
|
||||||
private boolean commander;
|
private boolean commander;
|
||||||
|
|
||||||
private Object currentSelection;
|
private Object currentSelection;
|
||||||
@@ -143,6 +145,10 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
return away;
|
return away;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String awayMessage() {
|
||||||
|
return awayMessage;
|
||||||
|
}
|
||||||
|
|
||||||
boolean isCommander() {
|
boolean isCommander() {
|
||||||
return commander;
|
return commander;
|
||||||
}
|
}
|
||||||
@@ -223,9 +229,16 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
conn.setMicrophoneActive(active);
|
conn.setMicrophoneActive(active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param message the away message, or null to keep the one already set — the
|
||||||
|
* message outlives coming back, so toggling away again restores it
|
||||||
|
*/
|
||||||
void setAway(boolean away, String message) {
|
void setAway(boolean away, String message) {
|
||||||
this.away = away;
|
this.away = away;
|
||||||
conn.setAway(away, message);
|
if (message != null) this.awayMessage = message;
|
||||||
|
conn.setAway(away, awayMessage);
|
||||||
|
chatPanel.appendSystem(!away ? "No longer away."
|
||||||
|
: awayMessage.isEmpty() ? "Away." : "Away: " + awayMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setCommander(boolean commander) {
|
void setCommander(boolean commander) {
|
||||||
@@ -394,6 +407,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
micMuted = false;
|
micMuted = false;
|
||||||
deafened = false;
|
deafened = false;
|
||||||
away = false;
|
away = false;
|
||||||
|
awayMessage = "";
|
||||||
commander = false;
|
commander = false;
|
||||||
chatPanel.setInputEnabled(true);
|
chatPanel.setInputEnabled(true);
|
||||||
chatPanel.appendSystem("Connected.");
|
chatPanel.appendSystem("Connected.");
|
||||||
|
|||||||
@@ -590,11 +590,7 @@ public final class ServerTreePanel extends JScrollPane {
|
|||||||
} else if (obj instanceof ClientEntry) {
|
} else if (obj instanceof ClientEntry) {
|
||||||
ClientEntry cl = (ClientEntry) obj;
|
ClientEntry cl = (ClientEntry) obj;
|
||||||
String label = cl.nickname;
|
String label = cl.nickname;
|
||||||
if (badgesOf(cl).isEmpty()) {
|
if (cl.away && !cl.awayMessage.isEmpty()) label += " [" + cl.awayMessage + "]";
|
||||||
// No icons (yet): fall back to naming the primary group inline.
|
|
||||||
String primaryGroup = model.primaryServerGroupName(cl.serverGroupIds);
|
|
||||||
if (primaryGroup != null) label += " [" + primaryGroup + "]";
|
|
||||||
}
|
|
||||||
setText(label);
|
setText(label);
|
||||||
setIcon(iconFor(cl));
|
setIcon(iconFor(cl));
|
||||||
setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT);
|
setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT);
|
||||||
|
|||||||
Reference in New Issue
Block a user