From e7d338ce28ea620e7e8a16378c470e2f04a6c8f9 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 1 Oct 2026 15:56:11 +0000 Subject: [PATCH] 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 --- .../main/java/com/ts3client/ui/InfoPanel.java | 79 +++++++++++++++++++ .../main/java/com/ts3client/ui/ServerTab.java | 29 +++++++ 2 files changed, 108 insertions(+) diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java index c8fee1e..55d038e 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java @@ -16,11 +16,20 @@ import com.ts3client.text.BBCode; import javax.swing.BorderFactory; import javax.swing.JEditorPane; +import javax.swing.JFileChooser; import javax.swing.JLabel; import javax.swing.JLayeredPane; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; import javax.swing.JPanel; +import javax.swing.JPopupMenu; import javax.swing.JScrollPane; 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.Cursor; import java.awt.Dimension; @@ -30,8 +39,12 @@ import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; +import java.awt.geom.Rectangle2D; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; import java.util.List; +import java.util.function.Consumer; /** * 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. */ private final JLabel toggle = new JLabel("Show in chat tab"); private DescriptionHandler descriptionHandler; + /** Takes someone else's avatar as ours; null where that cannot be done. */ + private Consumer avatarStealer; private ChannelNode shownChannel; private ClientEntry shownClient; /** 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; 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); scroll.getViewport().addComponentListener(new ComponentAdapter() { @Override @@ -193,6 +219,10 @@ public final class InfoPanel extends JPanel { this.descriptionHandler = handler; } + public void setAvatarStealer(Consumer stealer) { + this.avatarStealer = stealer; + } + /** Runs when the user closes the info chat tab; shows details in the panel again. */ private void onInfoTabClosed() { inChatTab = false; @@ -264,6 +294,55 @@ public final class InfoPanel extends JPanel { 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 }. */ + 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, Avatar avatar, int avatarBox) { String details = clientDetails(cl, model, icons, contact); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java index a9e15e2..6e2e141 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java @@ -9,15 +9,21 @@ import com.ts3client.net.ChannelNode; import com.ts3client.net.ConnectionListener.ChatScope; import com.ts3client.net.PictureDownloads; import com.ts3client.net.TeamspeakConnection; +import com.ts3client.net.avatar.Avatar; import com.ts3client.session.ServerSession; import com.ts3client.sound.SoundNotifier; import com.ts3client.text.TsLink; import javax.swing.JComponent; +import javax.swing.JOptionPane; import javax.swing.JSplitPane; +import javax.swing.SwingUtilities; import java.awt.Component; import java.awt.event.ComponentAdapter; 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 @@ -101,6 +107,8 @@ final class ServerTab { } }); + infoPanel.setAvatarStealer(this::stealAvatar); + leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel); leftColumn.setResizeWeight(0.68); leftColumn.setContinuousLayout(true); @@ -171,6 +179,27 @@ final class ServerTab { 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 ---- private void onSendChat(ChatPanel.Target target, int clientId, String text) {