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:
2026-09-30 23:06:13 +00:00
parent fd86d9e638
commit af0564702e
19 changed files with 805 additions and 68 deletions

View File

@@ -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) {