From af0564702e09e2a8265f3af3939b3631771f6a7c Mon Sep 17 00:00:00 2001 From: ericek111 Date: Wed, 30 Sep 2026 23:06:13 +0000 Subject: [PATCH] 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 --- .../java/com/ts3client/android/Picture.kt | 42 +++++ .../com/ts3client/android/ServerImages.kt | 39 +--- .../ts3client/android/SessionController.kt | 6 + .../java/com/ts3client/android/WebPictures.kt | 55 ++++++ .../main/java/com/ts3client/android/ui/App.kt | 2 +- .../java/com/ts3client/android/ui/ChatText.kt | 164 +++++++++++++++- .../ts3client/android/ui/DetailsSection.kt | 2 +- .../com/ts3client/android/ui/ServerImage.kt | 18 +- .../ts3client/android/ui/SettingsScreen.kt | 5 + .../java/com/ts3client/config/Settings.java | 4 + .../com/ts3client/net/PictureDownloads.java | 175 ++++++++++++++++++ .../main/java/com/ts3client/text/BBCode.java | 50 ++++- .../com/ts3client/text/BBCodeTagsTest.java | 33 ++++ .../java/com/ts3client/ui/ChatLogsPanel.java | 13 +- .../com/ts3client/ui/DescriptionPictures.java | 124 +++++++++++++ .../main/java/com/ts3client/ui/IconFiles.java | 56 ++++++ .../main/java/com/ts3client/ui/InfoPanel.java | 73 +++++++- .../main/java/com/ts3client/ui/MainFrame.java | 6 +- .../main/java/com/ts3client/ui/ServerTab.java | 6 +- 19 files changed, 805 insertions(+), 68 deletions(-) create mode 100644 android/app/src/main/java/com/ts3client/android/Picture.kt create mode 100644 android/app/src/main/java/com/ts3client/android/WebPictures.kt create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/PictureDownloads.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/DescriptionPictures.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/IconFiles.java diff --git a/android/app/src/main/java/com/ts3client/android/Picture.kt b/android/app/src/main/java/com/ts3client/android/Picture.kt new file mode 100644 index 0000000..0340183 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/Picture.kt @@ -0,0 +1,42 @@ +package com.ts3client.android + +import android.graphics.ImageDecoder +import android.graphics.drawable.AnimatedImageDrawable +import android.graphics.drawable.BitmapDrawable +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import java.nio.ByteBuffer + +/** An avatar or a picture from a description: still, or moving like an animated GIF. */ +sealed interface Picture { + val width: Int + val height: Int + + data class Still(val bitmap: ImageBitmap) : Picture { + override val width get() = bitmap.width + override val height get() = bitmap.height + } + + /** Shared by whatever shows it: one animation, drawn wherever it is on screen. */ + data class Moving(val drawable: AnimatedImageDrawable) : Picture { + override val width get() = drawable.intrinsicWidth + override val height get() = drawable.intrinsicHeight + } +} + +/** Decodes any image Android knows, no larger than [maxPx] on its longer side; null if it is none. */ +internal fun decodePicture(data: ByteArray, maxPx: Int): Picture? = try { + val drawable = ImageDecoder.decodeDrawable(ImageDecoder.createSource(ByteBuffer.wrap(data))) { decoder, info, _ -> + val largest = maxOf(info.size.width, info.size.height) + if (largest > maxPx) { + decoder.setTargetSize(info.size.width * maxPx / largest, info.size.height * maxPx / largest) + } + } + when (drawable) { + is AnimatedImageDrawable -> Picture.Moving(drawable) + is BitmapDrawable -> Picture.Still(drawable.bitmap.asImageBitmap()) + else -> null + } +} catch (e: Exception) { + null +} diff --git a/android/app/src/main/java/com/ts3client/android/ServerImages.kt b/android/app/src/main/java/com/ts3client/android/ServerImages.kt index 786f749..1cd8f9e 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerImages.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerImages.kt @@ -1,16 +1,12 @@ package com.ts3client.android import android.graphics.BitmapFactory -import android.graphics.ImageDecoder -import android.graphics.drawable.AnimatedImageDrawable -import android.graphics.drawable.BitmapDrawable import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import com.ts3client.net.TeamspeakConnection import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import java.nio.ByteBuffer import java.util.concurrent.ConcurrentHashMap /** @@ -20,7 +16,7 @@ import java.util.concurrent.ConcurrentHashMap class ServerImages internal constructor(private val conn: TeamspeakConnection) { private val icons = ConcurrentHashMap() - private val avatars = ConcurrentHashMap() + private val avatars = ConcurrentHashMap() private val _version = MutableStateFlow(0) val version: StateFlow = _version @@ -35,30 +31,12 @@ class ServerImages internal constructor(private val conn: TeamspeakConnection) { return bitmap } - fun avatar(uniqueId: String, flag: String): AvatarImage? { + fun avatar(uniqueId: String, flag: String): Picture? { if (flag.isEmpty()) return null avatars[flag]?.let { return it } - val avatar = conn.avatars.get(uniqueId, flag) ?: return null - val image = if (avatar.isAnimated) decodeAnimated(avatar.data()) else decode(avatar.data(), AVATAR_PX)?.let(AvatarImage::Still) - if (image != null) avatars[flag] = image - return image - } - - /** A GIF as it moves; one of a single frame stays still. */ - private fun decodeAnimated(data: ByteArray): AvatarImage? = try { - val drawable = ImageDecoder.decodeDrawable(ImageDecoder.createSource(ByteBuffer.wrap(data))) { decoder, info, _ -> - val largest = maxOf(info.size.width, info.size.height) - if (largest > AVATAR_PX) { - decoder.setTargetSize(info.size.width * AVATAR_PX / largest, info.size.height * AVATAR_PX / largest) - } - } - when (drawable) { - is AnimatedImageDrawable -> AvatarImage.Moving(drawable) - is BitmapDrawable -> AvatarImage.Still(drawable.bitmap.asImageBitmap()) - else -> null - } - } catch (e: Exception) { - decode(data, AVATAR_PX)?.let(AvatarImage::Still) + val picture = conn.avatars.get(uniqueId, flag)?.let { decodePicture(it.data(), AVATAR_PX) } ?: return null + avatars[flag] = picture + return picture } /** Decoded no larger than needed: avatars may be far bigger than they are ever shown. */ @@ -77,10 +55,3 @@ class ServerImages internal constructor(private val conn: TeamspeakConnection) { const val AVATAR_PX = 512 } } - -sealed interface AvatarImage { - data class Still(val bitmap: ImageBitmap) : AvatarImage - - /** Shared by whatever shows it: one animation, drawn wherever it is on screen. */ - data class Moving(val drawable: AnimatedImageDrawable) : AvatarImage -} diff --git a/android/app/src/main/java/com/ts3client/android/SessionController.kt b/android/app/src/main/java/com/ts3client/android/SessionController.kt index 96d0638..37eb057 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionController.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -66,6 +66,7 @@ class SessionController(private val context: Context) { val settings: Settings = Settings.load() val icons = IconTheme(context, settings) + val pictures = WebPictures(context.cacheDir, settings) private val bookmarkStore = Bookmarks.load() internal val identityStore = IdentityStore.load(settings) val identities = IdentityManager(context, identityStore, settings, bookmarkStore) { @@ -318,6 +319,11 @@ class SessionController(private val context: Context) { val soundPack: SoundPack? get() = sounds.pack() + fun setDescriptionPictures(shown: Boolean) { + settings.descriptionPictures = shown + settings.save() + } + fun setSoundPack(pack: SoundPack) { sounds.setPack(pack) settings.save() diff --git a/android/app/src/main/java/com/ts3client/android/WebPictures.kt b/android/app/src/main/java/com/ts3client/android/WebPictures.kt new file mode 100644 index 0000000..d3c582e --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/WebPictures.kt @@ -0,0 +1,55 @@ +package com.ts3client.android + +import android.util.LruCache +import com.ts3client.config.Settings +import com.ts3client.net.PictureDownloads +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** The pictures descriptions show, fetched by core's downloads and decoded while there is room. */ +class WebPictures(cacheDir: File, private val settings: Settings) { + + sealed interface State { + data object Loading : State + data object Broken : State + data class Shown(val picture: Picture) : State + } + + private val downloads = PictureDownloads(File(cacheDir, "pictures"), false) + private val decoded = LruCache(CACHED) + private val undecodable = ConcurrentHashMap.newKeySet() + + private val _version = MutableStateFlow(0) + /** Moves on whenever a download has come or failed. */ + val version: StateFlow = _version + + init { + downloads.addListener { _version.update { it + 1 } } + } + + /** Whether pictures are shown at all, rather than links to them. */ + val enabled: Boolean get() = settings.descriptionPictures + + suspend fun state(url: String): State { + decoded.get(url)?.let { return State.Shown(it) } + if (url in undecodable) return State.Broken + val file = downloads.get(url) ?: return if (downloads.failed(url)) State.Broken else State.Loading + val picture = withContext(Dispatchers.IO) { decodePicture(file.readBytes(), MAX_PX) } + if (picture == null) { + undecodable += url + return State.Broken + } + decoded.put(url, picture) + return State.Shown(picture) + } + + private companion object { + const val CACHED = 32 + const val MAX_PX = 1024 + } +} diff --git a/android/app/src/main/java/com/ts3client/android/ui/App.kt b/android/app/src/main/java/com/ts3client/android/ui/App.kt index 3c544f7..cb639a8 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/App.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/App.kt @@ -70,7 +70,7 @@ fun App(vm: SessionController) { contactDirect = uniqueId != null } - CompositionLocalProvider(LocalIconTheme provides vm.icons) { + CompositionLocalProvider(LocalIconTheme provides vm.icons, LocalWebPictures provides vm.pictures) { val server = current when { settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false }) diff --git a/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt b/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt index f7be549..2f9a62c 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ChatText.kt @@ -1,15 +1,24 @@ package com.ts3client.android.ui +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString @@ -20,26 +29,161 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.fromHtml import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ts3client.android.WebPictures +import com.ts3client.text.BBCode import com.ts3client.text.ChatHtml /** - * HTML as [BBCode][com.ts3client.text.BBCode] and [ChatHtml] write it, with its rules as dividers: - * Android's HTML parser knows none, so the text is split at them. + * HTML as [BBCode][com.ts3client.text.BBCode] and [ChatHtml] write it, with its rules as dividers + * and its pictures fetched from the web: Android's HTML parser shows neither, so the text is + * split at them. */ @Composable fun RichText(html: String, onLink: (String) -> Unit, modifier: Modifier = Modifier, style: TextStyle = LocalTextStyle.current) { val link by rememberUpdatedState(onLink) val color = linkColor() - val parts = remember(html, color) { html.split(RULE).map { chatText(it, color) { href -> link(href) } } } + val pieces = remember(html, color) { split(html) { chatText(it, color) { href -> link(href) } } } Column(modifier) { - parts.forEachIndexed { i, text -> - if (i > 0) HorizontalDivider(Modifier.padding(vertical = 6.dp)) - // As wide as there is room, for aligned paragraphs to have somewhere to go. - if (text.isNotEmpty()) Text(text, Modifier.fillMaxWidth(), style = style) + for (piece in pieces) { + when (piece) { + Piece.Rule -> HorizontalDivider(Modifier.padding(vertical = 6.dp)) + is Piece.Image -> WebPicture(piece, style) { link(it) } + // As wide as there is room, for aligned paragraphs to have somewhere to go. + is Piece.Text -> if (piece.text.isNotEmpty()) Text(piece.text, Modifier.fillMaxWidth(), style = style) + } } } } +/** The web pictures descriptions show. */ +val LocalWebPictures = staticCompositionLocalOf { null } + +private sealed interface Piece { + data class Text(val text: AnnotatedString) : Piece + data object Rule : Piece + /** @param href where the picture links to, if it sits in a link */ + data class Image(val url: String, val href: String?, val align: Alignment.Horizontal) : Piece +} + +/** + * [html] cut at its rules and pictures. Each piece of text is closed where it is cut and the + * next reopens what it was in, so an aligned block or a link carries on past a picture. + */ +private fun split(html: String, text: (String) -> AnnotatedString): List { + val pieces = ArrayList() + val divs = ArrayList() + var anchor: String? = null + var href: String? = null + val current = StringBuilder() + fun cut() { + if (anchor != null) current.append("") + repeat(divs.size) { current.append("") } + pieces += Piece.Text(text(current.toString())) + current.setLength(0) + divs.forEach { current.append(it) } + anchor?.let { current.append(it) } + } + var last = 0 + for (m in CUT_TAG.findAll(html)) { + current.append(html, last, m.range.first) + last = m.range.last + 1 + val tag = m.value + when { + tag.startsWith(" { + cut() + pieces += Piece.Rule + } + tag.startsWith(" { + cut() + pieces += Piece.Image(unescape(m.groupValues[1]), href?.let(::unescape), alignment(divs)) + } + tag.startsWith(" { + divs += tag + current.append(tag) + } + tag.equals("", ignoreCase = true) -> { + divs.removeLastOrNull() + current.append(tag) + } + tag.startsWith(" { + anchor = tag + href = m.groupValues[2] + current.append(tag) + } + else -> { + anchor = null + href = null + current.append(tag) + } + } + } + current.append(html, last, html.length) + pieces += Piece.Text(text(current.toString())) + return pieces +} + +/** Where the innermost aligned block puts what is in it. */ +private fun alignment(divs: List): Alignment.Horizontal { + val align = divs.asReversed().firstNotNullOfOrNull { ALIGN.find(it)?.groupValues?.get(1) } + return when (align) { + "center" -> Alignment.CenterHorizontally + "right" -> Alignment.End + else -> Alignment.Start + } +} + +private fun unescape(s: String) = + s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&") + +/** + * A picture at its own size, a pixel to a dp as TeamSpeak shows it, narrowed to fit. With + * pictures turned off, or when one cannot be had, its address as a link behind a picture icon. + */ +@Composable +private fun WebPicture(image: Piece.Image, style: TextStyle, onLink: (String) -> Unit) { + val pictures = LocalWebPictures.current + Column(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalAlignment = image.align) { + if (pictures == null || !pictures.enabled) { + PictureLink("ICONVIEWER", image, style, onLink) + return@Column + } + val version by pictures.version.collectAsStateWithLifecycle() + val state by produceState(WebPictures.State.Loading, image.url, version) { + value = pictures.state(image.url) + } + when (val shown = state) { + WebPictures.State.Loading -> PackIcon("LOADING_IMAGE", "Loading picture", size = 16.dp) + WebPictures.State.Broken -> PictureLink("BROKEN_IMAGE", image, style, onLink) + is WebPictures.State.Shown -> { + val picture = shown.picture + Image( + rememberPicturePainter(picture), + null, + Modifier + .widthIn(max = picture.width.dp) + .aspectRatio(picture.width.toFloat() / picture.height.coerceAtLeast(1)) + .then(if (image.href != null) Modifier.clickable { onLink(image.href) } else Modifier), + ) + } + } + } +} + +/** The picture's address as a link behind [iconKey], then the link it sat in, if any, so neither is lost. */ +@Composable +private fun PictureLink(iconKey: String, image: Piece.Image, style: TextStyle, onLink: (String) -> Unit) { + val color = linkColor() + val text = remember(image, color) { + val links = listOfNotNull(image.url, image.href?.takeIf { it != image.url }) + chatText(links.joinToString(" ") { "${BBCode.escape(BBCode.shortAddress(it))}" }, color, onLink) + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + PackIcon(iconKey, "Picture", size = 16.dp) + Text(text, style = style) + } +} + /** * HTML without rules as styled text. Android's parser knows no stylesheets, tables, * numbered lists, font sizes or `align`, so the classes [ChatHtml] marks lines with become @@ -113,7 +257,11 @@ private val LI = Regex("") private val SPAN = Regex("|]*>|") private val ALIGNS = mapOf("left" to "start", "justify" to "start", "center" to "center", "right" to "end") -private val RULE = Regex("", RegexOption.IGNORE_CASE) +/** Where the text is cut, and what it has to carry across: aligned blocks and links. */ +private val CUT_TAG = Regex( + "||]*>||]*href=\"([^\"]*)\"[^>]*>|", + RegexOption.IGNORE_CASE, +) private val CLASS = Regex("class=\"([a-z]+)\"") private val ALIGN = Regex("align=\"(left|center|right|justify)\"") private val CELL_END = Regex("") diff --git a/android/app/src/main/java/com/ts3client/android/ui/DetailsSection.kt b/android/app/src/main/java/com/ts3client/android/ui/DetailsSection.kt index 4b92949..4c6c5d3 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/DetailsSection.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/DetailsSection.kt @@ -80,7 +80,7 @@ private fun Fact(field: Details.Field) { /** The description in a box that opens up, when there is more of it than fits folded. */ @Composable private fun Description(bbcode: String, onLink: (String) -> Unit) { - val html = remember(bbcode) { BBCode.toHtml(bbcode) } + val html = remember(bbcode) { BBCode.toHtml(bbcode, true) } var expanded by rememberSaveable(bbcode) { mutableStateOf(false) } var overflows by remember(bbcode) { mutableStateOf(false) } val expandable = overflows || expanded diff --git a/android/app/src/main/java/com/ts3client/android/ui/ServerImage.kt b/android/app/src/main/java/com/ts3client/android/ui/ServerImage.kt index 2c4f208..8563a9c 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ServerImage.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ServerImage.kt @@ -29,7 +29,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.ts3client.android.AvatarImage +import com.ts3client.android.Picture import com.ts3client.android.ServerImages import com.ts3client.net.IconRepository import kotlin.math.roundToInt @@ -66,15 +66,17 @@ fun ServerIcon(id: Long, modifier: Modifier = Modifier, size: Dp = 16.dp) { fun Avatar(uniqueId: String, flag: String, modifier: Modifier = Modifier, size: Dp = 64.dp) { val images = LocalServerImages.current ?: return val version by images.version.collectAsStateWithLifecycle() - val image = remember(flag, version) { images.avatar(uniqueId, flag) } ?: return - val painter = when (image) { - is AvatarImage.Still -> remember(image) { BitmapPainter(image.bitmap) } - is AvatarImage.Moving -> rememberMovingPainter(image.drawable) - } - Image(painter, "Avatar", modifier.size(size).clip(RoundedCornerShape(8.dp))) + val picture = remember(flag, version) { images.avatar(uniqueId, flag) } ?: return + Image(rememberPicturePainter(picture), "Avatar", modifier.size(size).clip(RoundedCornerShape(8.dp))) +} + +/** Draws [picture], playing it while on screen if it moves. */ +@Composable +fun rememberPicturePainter(picture: Picture): Painter = when (picture) { + is Picture.Still -> remember(picture) { BitmapPainter(picture.bitmap) } + is Picture.Moving -> rememberMovingPainter(picture.drawable) } -/** Plays [drawable] while on screen, redrawing at each of its frames. */ @Composable private fun rememberMovingPainter(drawable: AnimatedImageDrawable): Painter { val painter = remember(drawable) { MovingPainter(drawable) } diff --git a/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt index b60baec..667e206 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/SettingsScreen.kt @@ -50,6 +50,11 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) { "MYTS_ACCOUNT") { page = SettingsPage.MY_TEAMSPEAK } Section("Appearance") IconPackPicker(vm.icons) + var pictures by remember { mutableStateOf(vm.settings.descriptionPictures) } + Toggle("Show pictures in descriptions", pictures) { + pictures = it + vm.setDescriptionPictures(it) + } } } } diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java index 933259b..3d11d3a 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java @@ -191,6 +191,8 @@ public final class Settings { public boolean chatLogs = true; /** Where chat logs go; empty for the TeamSpeak 3 client's own folder, so both share them. */ public String chatLogDir = ""; + /** Whether descriptions show their pictures, fetched from the web, or only links to them. */ + public boolean descriptionPictures = true; public static Settings load() { Settings s = new Settings(); @@ -279,6 +281,7 @@ public final class Settings { notifications.load(props); contactDefaults.load(props); colorContacts = parseB(props.getProperty("colorContacts"), colorContacts); + descriptionPictures = parseB(props.getProperty("descriptionPictures"), descriptionPictures); whisperPolicy = parseEnum(WhisperPolicy.class, props.getProperty("whisperPolicy"), whisperPolicy); chatLogs = parseB(props.getProperty("chatLogs"), chatLogs); chatLogDir = props.getProperty("chatLogDir", chatLogDir); @@ -333,6 +336,7 @@ public final class Settings { notifications.store(props); contactDefaults.store(props); props.setProperty("colorContacts", Boolean.toString(colorContacts)); + props.setProperty("descriptionPictures", Boolean.toString(descriptionPictures)); props.setProperty("whisperPolicy", whisperPolicy.name()); props.setProperty("chatLogs", Boolean.toString(chatLogs)); props.setProperty("chatLogDir", chatLogDir); diff --git a/ts3-client/core/src/main/java/com/ts3client/net/PictureDownloads.java b/ts3-client/core/src/main/java/com/ts3client/net/PictureDownloads.java new file mode 100644 index 0000000..2f39f9c --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/PictureDownloads.java @@ -0,0 +1,175 @@ +package com.ts3client.net; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * The pictures descriptions show, fetched from the web on first sight and kept on disk + * for a month after they were last shown. + * + *

Lookups never block: {@link #get} returns {@code null} while a picture is on its + * way, and the listeners hear when one has come or could not be had. + */ +public final class PictureDownloads { + + private static final long MAX_BYTES = 8L << 20; + private static final int TIMEOUT_MS = 10_000; + private static final long KEEP_MS = 30L * 24 * 60 * 60 * 1000; + private static final String USER_AGENT = "ts3client"; + private static final int MAX_REDIRECTS = 5; + + private final File dir; + private final boolean plainHttp; + private final List listeners = new CopyOnWriteArrayList<>(); + private final Set pending = ConcurrentHashMap.newKeySet(); + private final Set failed = ConcurrentHashMap.newKeySet(); + private final ExecutorService downloads = Executors.newFixedThreadPool(2, r -> { + Thread t = new Thread(r, "picture-download"); + t.setDaemon(true); + return t; + }); + + /** + * @param plainHttp whether plain http may be used; where it may not (Android), an + * http address is fetched over https, which most hosts also serve + */ + public PictureDownloads(File dir, boolean plainHttp) { + this.dir = dir; + this.plainHttp = plainHttp; + downloads.execute(this::prune); + } + + /** @param listener run off the UI thread whenever a picture has come or failed */ + public void addListener(Runnable listener) { + listeners.add(listener); + } + + public void removeListener(Runnable listener) { + listeners.remove(listener); + } + + /** + * @return the picture's file, or {@code null} while it downloads or when it cannot be + * had (see {@link #failed}). A picture not on disk yet starts downloading. + */ + public File get(String url) { + File file = new File(dir, name(url)); + if (file.isFile()) { + //noinspection ResultOfMethodCallIgnored + file.setLastModified(System.currentTimeMillis()); + return file; + } + if (!failed.contains(url) && pending.add(url)) downloads.execute(() -> download(url, file)); + return null; + } + + /** Whether the picture could not be had this session; it is not asked for again until then. */ + public boolean failed(String url) { + return failed.contains(url); + } + + private void download(String url, File file) { + try { + byte[] data = fetch(allowed(url)); + if (data == null) { + failed.add(url); + } else { + //noinspection ResultOfMethodCallIgnored + dir.mkdirs(); + File part = new File(dir, file.getName() + ".part"); + Files.write(part.toPath(), data); + Files.move(part.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException | RuntimeException e) { + failed.add(url); + } finally { + pending.remove(url); + } + for (Runnable listener : listeners) listener.run(); + } + + /** The address as it may be fetched here: over https where plain http is not allowed. */ + private String allowed(String url) { + boolean http = url.regionMatches(true, 0, "http://", 0, 7); + return plainHttp || !http ? url : "https://" + url.substring(7); + } + + /** + * Follows redirects itself: Java does not follow one that changes protocol, which is how + * many hosts send plain http on to https. + */ + private byte[] fetch(String url) throws IOException { + for (int hop = 0; hop <= MAX_REDIRECTS; hop++) { + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + try { + connection.setInstanceFollowRedirects(false); + connection.setConnectTimeout(TIMEOUT_MS); + connection.setReadTimeout(TIMEOUT_MS); + // Some hosts turn away Java's own agent (Wikimedia does). + connection.setRequestProperty("User-Agent", USER_AGENT); + int code = connection.getResponseCode(); + String location = connection.getHeaderField("Location"); + if (code / 100 == 3 && location != null) { + String next = new URL(new URL(url), location).toString(); + if (!next.regionMatches(true, 0, "http", 0, 4)) return null; + url = allowed(next); + continue; + } + return code == HttpURLConnection.HTTP_OK ? read(connection) : null; + } finally { + connection.disconnect(); + } + } + return null; + } + + private static byte[] read(HttpURLConnection connection) throws IOException { + if (connection.getContentLengthLong() > MAX_BYTES) return null; + try (InputStream in = connection.getInputStream()) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[16 * 1024]; + for (int n; (n = in.read(buffer)) >= 0; ) { + out.write(buffer, 0, n); + if (out.size() > MAX_BYTES) return null; + } + return out.toByteArray(); + } + } + + /** Forgets the pictures not shown for a month. */ + private void prune() { + File[] files = dir.listFiles(); + if (files == null) return; + long cutoff = System.currentTimeMillis() - KEEP_MS; + for (File f : files) { + //noinspection ResultOfMethodCallIgnored + if (f.lastModified() < cutoff) f.delete(); + } + } + + private static String name(String url) { + try { + byte[] hash = MessageDigest.getInstance("SHA-1").digest(url.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : hash) sb.append(String.format("%02x", b)); + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java b/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java index 411ab1d..e3c7dd8 100644 --- a/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java +++ b/ts3-client/core/src/main/java/com/ts3client/text/BBCode.java @@ -19,6 +19,10 @@ public final class BBCode { Pattern.compile("\\[url]([^\\[\\]]+)\\[/url]", Pattern.CASE_INSENSITIVE); private static final Pattern BARE_IMG_TAG = Pattern.compile("\\[img]([^\\[\\]]+)\\[/img]", Pattern.CASE_INSENSITIVE); + /** A picture's address: only the web, so a description cannot point the client anywhere else. */ + private static final Pattern IMAGE_URL = Pattern.compile("(?i)https?://[^\\s\"'<>]+"); + private static final int MAX_ADDRESS = 48; + private static final Pattern IMG_CLOSE = Pattern.compile("(?i)\\[/img]"); private static final Pattern PLAIN_URL = Pattern.compile("(?i)\\b(?:https?://|www\\.)[^\\s<>\"']+"); private static final Pattern COLOR = Pattern.compile("#[0-9a-fA-F]{3,6}|[a-zA-Z]{3,20}"); @@ -44,11 +48,19 @@ public final class BBCode { private BBCode() { } - /** Converts BBCode to an HTML fragment (no surrounding {@code } element). */ + /** Converts BBCode to an HTML fragment (no surrounding {@code } element), pictures as links. */ public static String toHtml(String input) { + return toHtml(input, false); + } + + /** + * @param images whether {@code [img]} becomes an {@code }, as TeamSpeak shows it in + * descriptions, rather than a link to the picture, as in chat + */ + public static String toHtml(String input, boolean images) { if (input == null || input.isEmpty()) return ""; - String src = BARE_IMG_TAG.matcher(BARE_URL_TAG.matcher(input).replaceAll("[url=$1]$1[/url]")) - .replaceAll("[url=$1]$1[/url]"); + String src = BARE_URL_TAG.matcher(input).replaceAll("[url=$1]$1[/url]"); + if (!images) src = BARE_IMG_TAG.matcher(src).replaceAll("[url=$1]$1[/url]"); StringBuilder out = new StringBuilder(); Deque open = new ArrayDeque<>(); @@ -65,6 +77,13 @@ public final class BBCode { text(out, src.substring(lb), open); break; } + if (images && src.substring(lb + 1, rb).equalsIgnoreCase("img")) { + int end = image(out, src, rb + 1); + if (end > 0) { + i = end; + continue; + } + } if (!emitTag(out, src.substring(lb + 1, rb), open)) { text(out, src.substring(lb, rb + 1), open); } @@ -102,6 +121,16 @@ public final class BBCode { return out.append(escape(input.substring(last))).toString(); } + /** A long web address as its host and file name, for a link's label where room is short. */ + public static String shortAddress(String url) { + if (url.length() <= MAX_ADDRESS) return url; + int host = url.indexOf("://"); + int path = host < 0 ? -1 : url.indexOf('/', host + 3); + int file = url.lastIndexOf('/'); + if (path < 0 || file <= path) return url; + return url.substring(host + 3, path) + "/…" + url.substring(file); + } + /** Escapes plain text for HTML without interpreting any BBCode. */ public static String escape(String s) { if (s == null) return ""; @@ -109,6 +138,20 @@ public final class BBCode { .replace("\"", """); } + /** + * Writes the picture whose address starts at {@code from}, up to its {@code [/img]}. + * + * @return where the text goes on after it, or -1 when there is no web address to show + */ + private static int image(StringBuilder out, String src, int from) { + Matcher close = IMG_CLOSE.matcher(src); + if (!close.find(from)) return -1; + String url = src.substring(from, close.start()).trim(); + if (!IMAGE_URL.matcher(url).matches()) return -1; + out.append(""); + return close.end(); + } + /** True once the tag was recognised and written; false to render it literally. */ private static boolean emitTag(StringBuilder out, String tag, Deque open) { if (tag.isEmpty()) return false; @@ -171,6 +214,7 @@ public final class BBCode { if (!SAFE_URL.matcher(value).matches()) return false; return push(out, open, name, link(value), linkClose(value)); } + default: return false; } diff --git a/ts3-client/core/src/test/java/com/ts3client/text/BBCodeTagsTest.java b/ts3-client/core/src/test/java/com/ts3client/text/BBCodeTagsTest.java index 3a61e3d..7e3c888 100644 --- a/ts3-client/core/src/test/java/com/ts3client/text/BBCodeTagsTest.java +++ b/ts3-client/core/src/test/java/com/ts3client/text/BBCodeTagsTest.java @@ -3,6 +3,7 @@ package com.ts3client.text; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; /** The block tags TeamSpeak's own client renders in descriptions. */ class BBCodeTagsTest { @@ -31,4 +32,36 @@ class BBCodeTagsTest { BBCode.toHtml("[table][tr][th]h[/th][td]d[/td][/tr][/table]")); assertEquals("[td]loose[/td]", BBCode.toHtml("[td]loose[/td]")); } + + @Test + void showsPicturesOnlyWhereAsked() { + String bb = "[img]https://example.com/a.png[/img]"; + assertEquals("", BBCode.toHtml(bb, true)); + assertEquals("https://example.com/a.png", + BBCode.toHtml(bb)); + } + + @Test + void linksPicturesInsideALink() { + assertEquals("", + BBCode.toHtml("[url=https://x.org][img]http://x.org/b.gif[/img][/url]", true)); + } + + @Test + void showsNoPictureFromElsewhereThanTheWeb() { + assertEquals("[img]file:///etc/passwd[/img]", BBCode.toHtml("[img]file:///etc/passwd[/img]", true)); + assertFalse(BBCode.toHtml("[img=https://x.org/a.png]", true).contains("", BBCode.toHtml("[img]https://x.org/a.png?a=1&b=2[/img]", true)); + } + + @Test + void shortensLongAddressesToHostAndFile() { + assertEquals("https://x.org/a.png", BBCode.shortAddress("https://x.org/a.png")); + assertEquals("upload.wikimedia.org/…/250px-Example.png", BBCode.shortAddress( + "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/Example.png/250px-Example.png")); + } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatLogsPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatLogsPanel.java index 7e11ce2..91e032b 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatLogsPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatLogsPanel.java @@ -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(); } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/DescriptionPictures.java b/ts3-client/swing/src/main/java/com/ts3client/ui/DescriptionPictures.java new file mode 100644 index 0000000..d95fe44 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/DescriptionPictures.java @@ -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( + "|]*href=\"([^\"]*)\"[^>]*>|", Pattern.CASE_INSENSITIVE); + private static final int ICON = 16; + + private final PictureDownloads downloads; + private final Settings settings; + private final Map sizes = new ConcurrentHashMap<>(); + + DescriptionPictures(PictureDownloads downloads, Settings settings) { + this.downloads = downloads; + this.settings = settings; + } + + static boolean hasPictures(String html) { + return html.contains(""; + } + + /** + * 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) + "" + + BBCode.escape(BBCode.shortAddress(url)) + ""; + if (anchor == null) return own; + String outer = href.equals(url) ? "" : " " + anchor + BBCode.escape(href) + ""; + return "" + 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 " "; + } + + /** 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 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("&", "&"); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/IconFiles.java b/ts3-client/swing/src/main/java/com/ts3client/ui/IconFiles.java new file mode 100644 index 0000000..a7caf2b --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/IconFiles.java @@ -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 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; + } +} 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 f37c985..fa5ec35 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 @@ -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 { + ""; } - 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 ? " (you)" : ""; 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("


").append(multiline(details.description())).append("
"); + sb.append("
").append(description(details.description())).append("
"); } else if (details.pending()) { sb.append("
Loading description…"); } else if (none != null) { @@ -296,9 +336,24 @@ public final class InfoPanel extends JPanel { .append(label).append(": ").append(value).append(""); } - /** 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) { diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index a5d0268..5ea9d9a 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -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); 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 ade9230..a9e15e2 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 @@ -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