Show pictures in descriptions, or links to them when turned off
[img] in a channel, client or server description is now a picture on both clients, as TeamSpeak shows it; chat keeps it a link. Only web addresses become pictures. While one loads it is TeamSpeak's loading icon; one that cannot be had is its address behind the broken-picture icon. "Show pictures in descriptions" (desktop: Chat settings; Android: Appearance) turns them into links behind a picture icon instead, fetching nothing. A picture that was itself a link keeps that link beside it. Core's PictureDownloads fetches them for both clients: kept on disk for a month after last shown, at most 8 MB, redirects followed across http and https, and on Android plain http asked for over https. The desktop draws them narrowed to the info panel, Android at a pixel to a dp, animated GIFs playing on both; long addresses are shortened to host and file name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import javax.swing.JPanel;
|
||||
import javax.swing.JTextField;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.Insets;
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,7 @@ final class ChatLogsPanel extends FormPanel {
|
||||
private final JCheckBox enabled = new JCheckBox("Keep chat logs");
|
||||
private final JTextField folder = new JTextField(18);
|
||||
private final JButton browse = new JButton("Browse…");
|
||||
private final JCheckBox pictures = new JCheckBox("Show pictures in descriptions");
|
||||
|
||||
ChatLogsPanel(Settings settings) {
|
||||
this.settings = settings;
|
||||
@@ -50,9 +52,17 @@ final class ChatLogsPanel extends FormPanel {
|
||||
|
||||
c.gridy = 2;
|
||||
c.gridx = 1;
|
||||
c.weighty = 1;
|
||||
c.anchor = GridBagConstraints.NORTHWEST;
|
||||
add(new JLabel("Takes effect on the next connect."), c);
|
||||
|
||||
pictures.setSelected(settings.descriptionPictures);
|
||||
pictures.setToolTipText("Off: only links to them, nothing fetched");
|
||||
c.gridy = 3;
|
||||
c.gridx = 0;
|
||||
c.gridwidth = 2;
|
||||
c.weighty = 1;
|
||||
c.insets = new Insets(UIScale.scale(12), c.insets.left, c.insets.bottom, c.insets.right);
|
||||
add(pictures, c);
|
||||
updateEnabled();
|
||||
}
|
||||
|
||||
@@ -76,5 +86,6 @@ final class ChatLogsPanel extends FormPanel {
|
||||
void apply() {
|
||||
settings.chatLogs = enabled.isSelected();
|
||||
settings.chatLogDir = folder.getText().trim();
|
||||
settings.descriptionPictures = pictures.isSelected();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.net.PictureDownloads;
|
||||
import com.ts3client.text.BBCode;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
import java.awt.Dimension;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Shows the pictures of description HTML as the info views can: from the downloads' files,
|
||||
* narrowed to fit, with a loading icon while one comes. With pictures turned off, or when one
|
||||
* cannot be had, it is a link to the picture behind a picture icon instead.
|
||||
*/
|
||||
final class DescriptionPictures {
|
||||
|
||||
/** A picture, and the links it may sit in. */
|
||||
private static final Pattern TAG = Pattern.compile(
|
||||
"<img src=\"([^\"]*)\">|<a [^>]*href=\"([^\"]*)\"[^>]*>|</a>", Pattern.CASE_INSENSITIVE);
|
||||
private static final int ICON = 16;
|
||||
|
||||
private final PictureDownloads downloads;
|
||||
private final Settings settings;
|
||||
private final Map<File, Dimension> sizes = new ConcurrentHashMap<>();
|
||||
|
||||
DescriptionPictures(PictureDownloads downloads, Settings settings) {
|
||||
this.downloads = downloads;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
static boolean hasPictures(String html) {
|
||||
return html.contains("<img src=\"");
|
||||
}
|
||||
|
||||
/** @param maxWidth the widest a picture may be drawn */
|
||||
String html(String html, int maxWidth) {
|
||||
StringBuilder out = new StringBuilder();
|
||||
Matcher m = TAG.matcher(html);
|
||||
String anchor = null;
|
||||
String href = null;
|
||||
int last = 0;
|
||||
while (m.find()) {
|
||||
out.append(html, last, m.start());
|
||||
last = m.end();
|
||||
if (m.group(1) != null) {
|
||||
out.append(picture(unescape(m.group(1)), anchor, href == null ? null : unescape(href), maxWidth));
|
||||
} else if (m.group(2) != null) {
|
||||
anchor = m.group();
|
||||
href = m.group(2);
|
||||
out.append(anchor);
|
||||
} else {
|
||||
anchor = null;
|
||||
href = null;
|
||||
out.append(m.group());
|
||||
}
|
||||
}
|
||||
return out.append(html.substring(last)).toString();
|
||||
}
|
||||
|
||||
private String picture(String url, String anchor, String href, int maxWidth) {
|
||||
if (!settings.descriptionPictures) return link("ICONVIEWER", url, anchor, href);
|
||||
File file = downloads.get(url);
|
||||
if (file == null) {
|
||||
return downloads.failed(url) ? link("BROKEN_IMAGE", url, anchor, href) : icon("LOADING_IMAGE");
|
||||
}
|
||||
Dimension size = sizes.computeIfAbsent(file, DescriptionPictures::read);
|
||||
if (size.width <= 0 || size.height <= 0) return link("BROKEN_IMAGE", url, anchor, href);
|
||||
int width = Math.min(UIScale.scale(size.width), Math.max(1, maxWidth));
|
||||
int height = (int) ((long) size.height * width / size.width);
|
||||
// Swing frames a picture inside a link unless told not to.
|
||||
return "<img src='" + file.toURI() + "' width='" + width + "' height='" + height + "' border='0'>";
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture's address as a link behind {@code iconKey}, shortened: Swing never breaks a
|
||||
* long one, so it would widen the view past its edge. Inside another link, that link is
|
||||
* closed around it and shown after it, so it is not lost with the picture that carried it.
|
||||
*/
|
||||
private static String link(String iconKey, String url, String anchor, String href) {
|
||||
String own = icon(iconKey) + "<a class=\"" + BBCode.linkClass(url) + "\" href=\"" + BBCode.escape(url) + "\">"
|
||||
+ BBCode.escape(BBCode.shortAddress(url)) + "</a>";
|
||||
if (anchor == null) return own;
|
||||
String outer = href.equals(url) ? "" : " " + anchor + BBCode.escape(href) + "</a>";
|
||||
return "</a>" + own + outer + anchor;
|
||||
}
|
||||
|
||||
private static String icon(String key) {
|
||||
int size = UIScale.scale(ICON);
|
||||
File file = IconFiles.of(key, size);
|
||||
if (file == null) return "";
|
||||
return "<img src='" + file.toURI() + "' width='" + size + "' height='" + size + "'> ";
|
||||
}
|
||||
|
||||
/** The picture's size in pixels, read from its header; zero when it is no picture Swing can show. */
|
||||
private static Dimension read(File file) {
|
||||
try (ImageInputStream in = ImageIO.createImageInputStream(file)) {
|
||||
Iterator<ImageReader> readers = in == null ? null : ImageIO.getImageReaders(in);
|
||||
if (readers == null || !readers.hasNext()) return new Dimension();
|
||||
ImageReader reader = readers.next();
|
||||
try {
|
||||
reader.setInput(in);
|
||||
return new Dimension(reader.getWidth(0), reader.getHeight(0));
|
||||
} finally {
|
||||
reader.dispose();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
return new Dimension();
|
||||
}
|
||||
}
|
||||
|
||||
private static String unescape(String s) {
|
||||
return s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.swing.ImageIcon;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Icons of the active pack as PNG files, for HTML views, which only show pictures by URL.
|
||||
* A pack change gives the theme new icons, and so new files.
|
||||
*/
|
||||
final class IconFiles {
|
||||
|
||||
private static final Map<String, File> FILES = new ConcurrentHashMap<>();
|
||||
private static File dir;
|
||||
|
||||
private IconFiles() {
|
||||
}
|
||||
|
||||
/** @return the icon's file, or {@code null} if no pack has it or it could not be written */
|
||||
static File of(String key, int size) {
|
||||
ImageIcon icon = IconTheme.icon(key, size);
|
||||
if (icon == null) return null;
|
||||
String name = key + '_' + size + '_' + Integer.toHexString(System.identityHashCode(icon)) + ".png";
|
||||
File file = FILES.computeIfAbsent(name, n -> write(icon, n));
|
||||
return file.isFile() ? file : null;
|
||||
}
|
||||
|
||||
private static File write(ImageIcon icon, String name) {
|
||||
try {
|
||||
File file = new File(dir(), name);
|
||||
BufferedImage image = new BufferedImage(icon.getIconWidth(), icon.getIconHeight(), BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = image.createGraphics();
|
||||
icon.paintIcon(null, g, 0, 0);
|
||||
g.dispose();
|
||||
ImageIO.write(image, "png", file);
|
||||
file.deleteOnExit();
|
||||
return file;
|
||||
} catch (IOException e) {
|
||||
return new File(name); // never a file, so asked for again next time
|
||||
}
|
||||
}
|
||||
|
||||
private static synchronized File dir() throws IOException {
|
||||
if (dir == null) {
|
||||
dir = Files.createTempDirectory("ts3client-icons").toFile();
|
||||
dir.deleteOnExit();
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@ package com.ts3client.ui;
|
||||
|
||||
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.contacts.Contact;
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
import com.ts3client.net.Details;
|
||||
import com.ts3client.net.Group;
|
||||
import com.ts3client.net.IconRepository;
|
||||
import com.ts3client.net.PictureDownloads;
|
||||
import com.ts3client.net.ServerModel;
|
||||
import com.ts3client.net.avatar.Avatar;
|
||||
import com.ts3client.text.BBCode;
|
||||
@@ -18,9 +20,11 @@ import javax.swing.JLabel;
|
||||
import javax.swing.JLayeredPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Point;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.event.ComponentAdapter;
|
||||
import java.awt.event.ComponentEvent;
|
||||
@@ -49,6 +53,9 @@ public final class InfoPanel extends JPanel {
|
||||
|
||||
private static final int AVATAR_MIN_SIZE = 64;
|
||||
private static final int AVATAR_BOX_STEP = 16;
|
||||
/** Pictures in a chat tab have no panel to fit, so they get this much. */
|
||||
private static final int CHAT_TAB_PICTURE_WIDTH = 480;
|
||||
private static final int PICTURE_WIDTH_STEP = 32;
|
||||
|
||||
private final JEditorPane pane = HtmlStyles.pane("font-family:sans-serif; font-size:11px;");
|
||||
private final JScrollPane scroll = new JScrollPane(pane);
|
||||
@@ -66,9 +73,19 @@ public final class InfoPanel extends JPanel {
|
||||
private boolean inChatTab;
|
||||
/** The avatar box the current HTML was built for; a resize that changes it re-renders. */
|
||||
private int renderedAvatarBox;
|
||||
private final PictureDownloads downloads;
|
||||
private final DescriptionPictures pictures;
|
||||
/** Whether the description shown has pictures, which re-render it as they come or the panel resizes. */
|
||||
private boolean showsPictures;
|
||||
private int renderedPictureWidth;
|
||||
private final Runnable onPicture = () -> SwingUtilities.invokeLater(() -> {
|
||||
if (showsPictures) keepingScroll(this::render);
|
||||
});
|
||||
|
||||
public InfoPanel() {
|
||||
public InfoPanel(Settings settings, PictureDownloads downloads) {
|
||||
super(new BorderLayout());
|
||||
this.downloads = downloads;
|
||||
this.pictures = new DescriptionPictures(downloads, settings);
|
||||
pane.setBorder(new ScaledEmptyBorder(6, 8, 6, 8));
|
||||
pane.addHyperlinkListener(e -> {
|
||||
if (e.getEventType() != javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) return;
|
||||
@@ -78,7 +95,10 @@ public final class InfoPanel extends JPanel {
|
||||
scroll.getViewport().addComponentListener(new ComponentAdapter() {
|
||||
@Override
|
||||
public void componentResized(ComponentEvent e) {
|
||||
if (shownAvatar != null && !inChatTab && avatarBox() != renderedAvatarBox) render();
|
||||
if (inChatTab) return;
|
||||
boolean avatar = shownAvatar != null && avatarBox() != renderedAvatarBox;
|
||||
boolean pictures = showsPictures && pictureWidth() != renderedPictureWidth;
|
||||
if (avatar || pictures) keepingScroll(InfoPanel.this::render);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -129,6 +149,25 @@ public final class InfoPanel extends JPanel {
|
||||
clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addNotify() {
|
||||
super.addNotify();
|
||||
downloads.addListener(onPicture);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeNotify() {
|
||||
downloads.removeListener(onPicture);
|
||||
super.removeNotify();
|
||||
}
|
||||
|
||||
/** Runs {@code change}, which renders again, without the view jumping back to the top. */
|
||||
private void keepingScroll(Runnable change) {
|
||||
Point at = scroll.getViewport().getViewPosition();
|
||||
change.run();
|
||||
SwingUtilities.invokeLater(() -> scroll.getViewport().setViewPosition(at));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateUI() {
|
||||
super.updateUI();
|
||||
@@ -201,6 +240,7 @@ public final class InfoPanel extends JPanel {
|
||||
}
|
||||
|
||||
private String body() {
|
||||
showsPictures = false;
|
||||
if (shownServer != null) return html(Details.server(shownModel, shownServer), "", null, null);
|
||||
if (shownChannel != null) return html(Details.channel(shownChannel), "", null, "No description.");
|
||||
if (shownClient != null) {
|
||||
@@ -224,7 +264,7 @@ public final class InfoPanel extends JPanel {
|
||||
return Math.max(UIScale.scale(AVATAR_MIN_SIZE), Math.min(max, box));
|
||||
}
|
||||
|
||||
private static 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) {
|
||||
String details = clientDetails(cl, model, icons, contact);
|
||||
String picture = AvatarHtml.imgTag(avatar, avatarBox);
|
||||
@@ -236,7 +276,7 @@ public final class InfoPanel extends JPanel {
|
||||
+ "</tr></table>";
|
||||
}
|
||||
|
||||
private static String clientDetails(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) {
|
||||
private String clientDetails(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) {
|
||||
String you = cl.self ? " <span class='muted'>(you)</span>" : "";
|
||||
return html(Details.client(cl, model, contact), you, icons, null);
|
||||
}
|
||||
@@ -246,12 +286,12 @@ public final class InfoPanel extends JPanel {
|
||||
* @param icons for the icons of the groups a field names; null for none
|
||||
* @param none what to say when there is no description; null to say nothing
|
||||
*/
|
||||
private static String html(Details details, String titleSuffix, IconRepository icons, String none) {
|
||||
private String html(Details details, String titleSuffix, IconRepository icons, String none) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(heading(esc(details.title()) + titleSuffix));
|
||||
for (Details.Field f : details.fields()) row(sb, esc(f.label()), value(f, icons));
|
||||
if (!details.description().isEmpty()) {
|
||||
sb.append("<hr><div>").append(multiline(details.description())).append("</div>");
|
||||
sb.append("<hr><div>").append(description(details.description())).append("</div>");
|
||||
} else if (details.pending()) {
|
||||
sb.append("<hr><i class='muted'>Loading description…</i>");
|
||||
} else if (none != null) {
|
||||
@@ -296,9 +336,24 @@ public final class InfoPanel extends JPanel {
|
||||
.append(label).append(":</span> ").append(value).append("</div>");
|
||||
}
|
||||
|
||||
/** Descriptions may carry BBCode markup. */
|
||||
private static String multiline(String text) {
|
||||
return BBCode.toHtml(text);
|
||||
/** Descriptions may carry BBCode markup, pictures included. */
|
||||
private String description(String bbcode) {
|
||||
String html = BBCode.toHtml(bbcode, true);
|
||||
if (!DescriptionPictures.hasPictures(html)) return html;
|
||||
showsPictures = true;
|
||||
renderedPictureWidth = pictureWidth();
|
||||
return pictures.html(html, renderedPictureWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* How wide a picture may be: the panel's width inside its padding, in steps so that
|
||||
* dragging a divider does not rebuild the HTML on every pixel.
|
||||
*/
|
||||
private int pictureWidth() {
|
||||
if (inChatTab) return UIScale.scale(CHAT_TAB_PICTURE_WIDTH);
|
||||
int step = UIScale.scale(PICTURE_WIDTH_STEP);
|
||||
int width = scroll.getViewport().getWidth() - UIScale.scale(24);
|
||||
return Math.max(step, width / step * step);
|
||||
}
|
||||
|
||||
private static String esc(String s) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.ts3client.ui;
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.ts3client.audio.AudioBackend;
|
||||
import com.ts3client.audio.desktop.DesktopAudioBackend;
|
||||
import com.ts3client.config.AppDirs;
|
||||
import com.ts3client.config.AwayMessages;
|
||||
import com.ts3client.config.BanReasonPresets;
|
||||
import com.ts3client.config.Bookmark;
|
||||
@@ -13,6 +14,7 @@ import com.ts3client.myts.MyTeamSpeakLogin;
|
||||
import com.ts3client.teamspeak.TeamSpeakImporter;
|
||||
import com.ts3client.contacts.ContactStore;
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.PictureDownloads;
|
||||
import com.ts3client.session.SelfState;
|
||||
import com.ts3client.session.ServerSession;
|
||||
import com.ts3client.session.Sessions;
|
||||
@@ -33,6 +35,7 @@ import javax.swing.JPopupMenu;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.KeyStroke;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.io.File;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.GridLayout;
|
||||
@@ -62,6 +65,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
/** Friends and blocked clients, shared by every connection. */
|
||||
private final ContactStore contacts = ContactStore.load();
|
||||
private final AudioBackend audio = new DesktopAudioBackend();
|
||||
private final PictureDownloads pictures = new PictureDownloads(new File(AppDirs.profile(), "pictures"), true);
|
||||
/** Sound pack playback, shared by every connection. */
|
||||
private final SoundNotifier sounds;
|
||||
private final SoundPlayer soundPlayer;
|
||||
@@ -360,7 +364,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
// ---- tab management ----
|
||||
|
||||
private ServerTab newTab() {
|
||||
ServerTab tab = new ServerTab(this, settings, identities, audio, sounds, contacts);
|
||||
ServerTab tab = new ServerTab(this, settings, identities, audio, sounds, contacts, pictures);
|
||||
tabs.add(tab);
|
||||
sessions.add(tab.session());
|
||||
tabPane.addTab(tab);
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.ts3client.config.Settings;
|
||||
import com.ts3client.contacts.ContactStore;
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ConnectionListener.ChatScope;
|
||||
import com.ts3client.net.PictureDownloads;
|
||||
import com.ts3client.net.TeamspeakConnection;
|
||||
import com.ts3client.session.ServerSession;
|
||||
import com.ts3client.sound.SoundNotifier;
|
||||
@@ -38,7 +39,7 @@ final class ServerTab {
|
||||
private final ServerTabTreeActions treeActions;
|
||||
private final ServerTreePanel treePanel;
|
||||
private final ChatPanel chatPanel;
|
||||
private final InfoPanel infoPanel = new InfoPanel();
|
||||
private final InfoPanel infoPanel;
|
||||
private final JComponent component;
|
||||
private JSplitPane leftColumn;
|
||||
private int normalDividerSize;
|
||||
@@ -50,9 +51,10 @@ final class ServerTab {
|
||||
private BanListDialog banList;
|
||||
|
||||
ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio,
|
||||
SoundNotifier sounds, ContactStore contacts) {
|
||||
SoundNotifier sounds, ContactStore contacts, PictureDownloads pictures) {
|
||||
this.host = host;
|
||||
this.settings = settings;
|
||||
this.infoPanel = new InfoPanel(settings, pictures);
|
||||
|
||||
// ServerTabConnectionEvents must exist before the session (which needs a listener
|
||||
// up front), and the session must exist before the tree/chat panels and the other
|
||||
|
||||
Reference in New Issue
Block a user