Save or steal other people's avatars from the info panel

Right-clicking a client's avatar offers to save it to a file or to take
it as our own avatar on that server, as the Android picture viewer does.
Our own avatar only offers saving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 15:56:11 +00:00
parent 8fc7be826c
commit e7d338ce28
2 changed files with 108 additions and 0 deletions

View File

@@ -16,11 +16,20 @@ import com.ts3client.text.BBCode;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;
import javax.swing.JEditorPane; import javax.swing.JEditorPane;
import javax.swing.JFileChooser;
import javax.swing.JLabel; import javax.swing.JLabel;
import javax.swing.JLayeredPane; import javax.swing.JLayeredPane;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane; import javax.swing.JScrollPane;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.StyleConstants;
import javax.swing.text.html.HTML;
import javax.swing.text.html.HTMLDocument;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.Cursor; import java.awt.Cursor;
import java.awt.Dimension; import java.awt.Dimension;
@@ -30,8 +39,12 @@ import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent; import java.awt.event.ComponentEvent;
import java.awt.event.MouseAdapter; import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent; import java.awt.event.MouseEvent;
import java.awt.geom.Rectangle2D;
import java.io.File; import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.List; import java.util.List;
import java.util.function.Consumer;
/** /**
* Read-only detail view for the server, channel or client selected, mirroring the * Read-only detail view for the server, channel or client selected, mirroring the
@@ -62,6 +75,8 @@ public final class InfoPanel extends JPanel {
/** Floats over the bottom-right corner of the details, out of the text's way. */ /** Floats over the bottom-right corner of the details, out of the text's way. */
private final JLabel toggle = new JLabel("Show in chat tab"); private final JLabel toggle = new JLabel("Show in chat tab");
private DescriptionHandler descriptionHandler; private DescriptionHandler descriptionHandler;
/** Takes someone else's avatar as ours; null where that cannot be done. */
private Consumer<Avatar> avatarStealer;
private ChannelNode shownChannel; private ChannelNode shownChannel;
private ClientEntry shownClient; private ClientEntry shownClient;
/** The server's address while the server itself is shown. */ /** The server's address while the server itself is shown. */
@@ -91,6 +106,17 @@ public final class InfoPanel extends JPanel {
if (e.getEventType() != javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) return; if (e.getEventType() != javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) return;
Links.open(e.getDescription(), this); Links.open(e.getDescription(), this);
}); });
pane.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
if (e.isPopupTrigger()) showAvatarMenu(e);
}
@Override
public void mouseReleased(MouseEvent e) {
if (e.isPopupTrigger()) showAvatarMenu(e);
}
});
HtmlStyles.fillViewport(pane); HtmlStyles.fillViewport(pane);
scroll.getViewport().addComponentListener(new ComponentAdapter() { scroll.getViewport().addComponentListener(new ComponentAdapter() {
@Override @Override
@@ -193,6 +219,10 @@ public final class InfoPanel extends JPanel {
this.descriptionHandler = handler; this.descriptionHandler = handler;
} }
public void setAvatarStealer(Consumer<Avatar> stealer) {
this.avatarStealer = stealer;
}
/** Runs when the user closes the info chat tab; shows details in the panel again. */ /** Runs when the user closes the info chat tab; shows details in the panel again. */
private void onInfoTabClosed() { private void onInfoTabClosed() {
inChatTab = false; inChatTab = false;
@@ -264,6 +294,55 @@ public final class InfoPanel extends JPanel {
return Math.max(UIScale.scale(AVATAR_MIN_SIZE), Math.min(max, box)); return Math.max(UIScale.scale(AVATAR_MIN_SIZE), Math.min(max, box));
} }
private void showAvatarMenu(MouseEvent e) {
Avatar avatar = shownClient == null ? null : shownAvatar;
if (avatar == null || !overAvatar(avatar, e.getPoint())) return;
JPopupMenu menu = new JPopupMenu();
JMenuItem save = new JMenuItem("Save…");
save.addActionListener(a -> saveAvatar(avatar, "avatar_" + shownClient.nickname));
menu.add(save);
if (!shownClient.self && avatarStealer != null) {
JMenuItem steal = new JMenuItem("Steal for avatar");
steal.addActionListener(a -> avatarStealer.accept(avatar));
menu.add(steal);
}
menu.show(pane, e.getX(), e.getY());
}
/** Whether {@code point} falls on the avatar's {@code <img>}. */
private boolean overAvatar(Avatar avatar, Point point) {
if (avatar.file() == null) return false;
String src = avatar.file().toURI().toString();
int pos = pane.viewToModel2D(point);
for (int at : new int[]{pos, pos - 1}) {
if (at < 0) continue;
var el = ((HTMLDocument) pane.getDocument()).getCharacterElement(at);
AttributeSet attrs = el.getAttributes();
if (attrs.getAttribute(StyleConstants.NameAttribute) != HTML.Tag.IMG
|| !src.equals(attrs.getAttribute(HTML.Attribute.SRC))) continue;
try {
Rectangle2D start = pane.modelToView2D(el.getStartOffset());
Rectangle2D end = pane.modelToView2D(el.getEndOffset());
if (start != null && end != null && start.createUnion(end).contains(point)) return true;
} catch (BadLocationException ignored) {
}
}
return false;
}
private void saveAvatar(Avatar avatar, String name) {
JFileChooser chooser = new JFileChooser();
chooser.setDialogTitle("Save avatar");
chooser.setSelectedFile(new File(name + "." + avatar.format().extension));
if (chooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) return;
try {
Files.write(chooser.getSelectedFile().toPath(), avatar.data());
} catch (IOException ex) {
JOptionPane.showMessageDialog(this, "Could not save the avatar: " + ex.getMessage(),
"Error", JOptionPane.ERROR_MESSAGE);
}
}
private String clientBody(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact, private String clientBody(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact,
Avatar avatar, int avatarBox) { Avatar avatar, int avatarBox) {
String details = clientDetails(cl, model, icons, contact); String details = clientDetails(cl, model, icons, contact);

View File

@@ -9,15 +9,21 @@ import com.ts3client.net.ChannelNode;
import com.ts3client.net.ConnectionListener.ChatScope; import com.ts3client.net.ConnectionListener.ChatScope;
import com.ts3client.net.PictureDownloads; import com.ts3client.net.PictureDownloads;
import com.ts3client.net.TeamspeakConnection; import com.ts3client.net.TeamspeakConnection;
import com.ts3client.net.avatar.Avatar;
import com.ts3client.session.ServerSession; import com.ts3client.session.ServerSession;
import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundNotifier;
import com.ts3client.text.TsLink; import com.ts3client.text.TsLink;
import javax.swing.JComponent; import javax.swing.JComponent;
import javax.swing.JOptionPane;
import javax.swing.JSplitPane; import javax.swing.JSplitPane;
import javax.swing.SwingUtilities;
import java.awt.Component; import java.awt.Component;
import java.awt.event.ComponentAdapter; import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent; import java.awt.event.ComponentEvent;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
/** /**
* One server connection and the views bound to it (tree, info and chat). The * One server connection and the views bound to it (tree, info and chat). The
@@ -101,6 +107,8 @@ final class ServerTab {
} }
}); });
infoPanel.setAvatarStealer(this::stealAvatar);
leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel); leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel);
leftColumn.setResizeWeight(0.68); leftColumn.setResizeWeight(0.68);
leftColumn.setContinuousLayout(true); leftColumn.setContinuousLayout(true);
@@ -171,6 +179,27 @@ final class ServerTab {
if (conn.isConnected()) new AvatarDialog(host, conn).setVisible(true); if (conn.isConnected()) new AvatarDialog(host, conn).setVisible(true);
} }
/** Uploads someone else's avatar as ours. */
private void stealAvatar(Avatar avatar) {
if (!conn.isConnected()) return;
File file;
try {
file = Files.createTempFile("avatar", "." + avatar.format().extension).toFile();
Files.write(file.toPath(), avatar.data());
} catch (IOException e) {
showAvatarError(e.getMessage());
return;
}
conn.setAvatar(file, error -> {
file.delete();
if (error != null) SwingUtilities.invokeLater(() -> showAvatarError(error));
});
}
private void showAvatarError(String error) {
JOptionPane.showMessageDialog(host, "Error uploading avatar: " + error, "Error", JOptionPane.ERROR_MESSAGE);
}
// ---- chat ---- // ---- chat ----
private void onSendChat(ChatPanel.Target target, int clientId, String text) { private void onSendChat(ChatPanel.Target target, int clientId, String text) {