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:
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user