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

@@ -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
}

View File

@@ -1,16 +1,12 @@
package com.ts3client.android package com.ts3client.android
import android.graphics.BitmapFactory 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.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import com.ts3client.net.TeamspeakConnection import com.ts3client.net.TeamspeakConnection
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
/** /**
@@ -20,7 +16,7 @@ import java.util.concurrent.ConcurrentHashMap
class ServerImages internal constructor(private val conn: TeamspeakConnection) { class ServerImages internal constructor(private val conn: TeamspeakConnection) {
private val icons = ConcurrentHashMap<Long, ImageBitmap>() private val icons = ConcurrentHashMap<Long, ImageBitmap>()
private val avatars = ConcurrentHashMap<String, AvatarImage>() private val avatars = ConcurrentHashMap<String, Picture>()
private val _version = MutableStateFlow(0) private val _version = MutableStateFlow(0)
val version: StateFlow<Int> = _version val version: StateFlow<Int> = _version
@@ -35,30 +31,12 @@ class ServerImages internal constructor(private val conn: TeamspeakConnection) {
return bitmap return bitmap
} }
fun avatar(uniqueId: String, flag: String): AvatarImage? { fun avatar(uniqueId: String, flag: String): Picture? {
if (flag.isEmpty()) return null if (flag.isEmpty()) return null
avatars[flag]?.let { return it } avatars[flag]?.let { return it }
val avatar = conn.avatars.get(uniqueId, flag) ?: return null val picture = conn.avatars.get(uniqueId, flag)?.let { decodePicture(it.data(), AVATAR_PX) } ?: return null
val image = if (avatar.isAnimated) decodeAnimated(avatar.data()) else decode(avatar.data(), AVATAR_PX)?.let(AvatarImage::Still) avatars[flag] = picture
if (image != null) avatars[flag] = image return picture
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)
} }
/** Decoded no larger than needed: avatars may be far bigger than they are ever shown. */ /** 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 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
}

View File

@@ -66,6 +66,7 @@ class SessionController(private val context: Context) {
val settings: Settings = Settings.load() val settings: Settings = Settings.load()
val icons = IconTheme(context, settings) val icons = IconTheme(context, settings)
val pictures = WebPictures(context.cacheDir, settings)
private val bookmarkStore = Bookmarks.load() private val bookmarkStore = Bookmarks.load()
internal val identityStore = IdentityStore.load(settings) internal val identityStore = IdentityStore.load(settings)
val identities = IdentityManager(context, identityStore, settings, bookmarkStore) { val identities = IdentityManager(context, identityStore, settings, bookmarkStore) {
@@ -318,6 +319,11 @@ class SessionController(private val context: Context) {
val soundPack: SoundPack? get() = sounds.pack() val soundPack: SoundPack? get() = sounds.pack()
fun setDescriptionPictures(shown: Boolean) {
settings.descriptionPictures = shown
settings.save()
}
fun setSoundPack(pack: SoundPack) { fun setSoundPack(pack: SoundPack) {
sounds.setPack(pack) sounds.setPack(pack)
settings.save() settings.save()

View File

@@ -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<String, Picture>(CACHED)
private val undecodable = ConcurrentHashMap.newKeySet<String>()
private val _version = MutableStateFlow(0)
/** Moves on whenever a download has come or failed. */
val version: StateFlow<Int> = _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
}
}

View File

@@ -70,7 +70,7 @@ fun App(vm: SessionController) {
contactDirect = uniqueId != null contactDirect = uniqueId != null
} }
CompositionLocalProvider(LocalIconTheme provides vm.icons) { CompositionLocalProvider(LocalIconTheme provides vm.icons, LocalWebPictures provides vm.pictures) {
val server = current val server = current
when { when {
settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false }) settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false })

View File

@@ -1,15 +1,24 @@
package com.ts3client.android.ui 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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString 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.font.FontWeight
import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp 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 import com.ts3client.text.ChatHtml
/** /**
* HTML as [BBCode][com.ts3client.text.BBCode] and [ChatHtml] write it, with its rules as dividers: * 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. * and its pictures fetched from the web: Android's HTML parser shows neither, so the text is
* split at them.
*/ */
@Composable @Composable
fun RichText(html: String, onLink: (String) -> Unit, modifier: Modifier = Modifier, style: TextStyle = LocalTextStyle.current) { fun RichText(html: String, onLink: (String) -> Unit, modifier: Modifier = Modifier, style: TextStyle = LocalTextStyle.current) {
val link by rememberUpdatedState(onLink) val link by rememberUpdatedState(onLink)
val color = linkColor() 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) { Column(modifier) {
parts.forEachIndexed { i, text -> for (piece in pieces) {
if (i > 0) HorizontalDivider(Modifier.padding(vertical = 6.dp)) when (piece) {
// As wide as there is room, for aligned paragraphs to have somewhere to go. Piece.Rule -> HorizontalDivider(Modifier.padding(vertical = 6.dp))
if (text.isNotEmpty()) Text(text, Modifier.fillMaxWidth(), style = style) 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<WebPictures?> { 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<Piece> {
val pieces = ArrayList<Piece>()
val divs = ArrayList<String>()
var anchor: String? = null
var href: String? = null
val current = StringBuilder()
fun cut() {
if (anchor != null) current.append("</a>")
repeat(divs.size) { current.append("</div>") }
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("<hr", ignoreCase = true) -> {
cut()
pieces += Piece.Rule
}
tag.startsWith("<img", ignoreCase = true) -> {
cut()
pieces += Piece.Image(unescape(m.groupValues[1]), href?.let(::unescape), alignment(divs))
}
tag.startsWith("<div", ignoreCase = true) -> {
divs += tag
current.append(tag)
}
tag.equals("</div>", ignoreCase = true) -> {
divs.removeLastOrNull()
current.append(tag)
}
tag.startsWith("<a", ignoreCase = true) -> {
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<String>): 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("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&amp;", "&")
/**
* 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>(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(" ") { "<a href=\"${BBCode.escape(it)}\">${BBCode.escape(BBCode.shortAddress(it))}</a>" }, 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, * 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 * numbered lists, font sizes or `align`, so the classes [ChatHtml] marks lines with become
@@ -113,7 +257,11 @@ private val LI = Regex("</?li>")
private val SPAN = Regex("<span style=\"font-size:(\\d+)px\">|<span[^>]*>|</span>") private val SPAN = Regex("<span style=\"font-size:(\\d+)px\">|<span[^>]*>|</span>")
private val ALIGNS = mapOf("left" to "start", "justify" to "start", "center" to "center", "right" to "end") private val ALIGNS = mapOf("left" to "start", "justify" to "start", "center" to "center", "right" to "end")
private val RULE = Regex("<hr\\s*/?>", RegexOption.IGNORE_CASE) /** Where the text is cut, and what it has to carry across: aligned blocks and links. */
private val CUT_TAG = Regex(
"<hr\\s*/?>|<img src=\"([^\"]*)\">|<div[^>]*>|</div>|<a [^>]*href=\"([^\"]*)\"[^>]*>|</a>",
RegexOption.IGNORE_CASE,
)
private val CLASS = Regex("class=\"([a-z]+)\"") private val CLASS = Regex("class=\"([a-z]+)\"")
private val ALIGN = Regex("align=\"(left|center|right|justify)\"") private val ALIGN = Regex("align=\"(left|center|right|justify)\"")
private val CELL_END = Regex("</t[dh]>") private val CELL_END = Regex("</t[dh]>")

View File

@@ -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. */ /** The description in a box that opens up, when there is more of it than fits folded. */
@Composable @Composable
private fun Description(bbcode: String, onLink: (String) -> Unit) { 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 expanded by rememberSaveable(bbcode) { mutableStateOf(false) }
var overflows by remember(bbcode) { mutableStateOf(false) } var overflows by remember(bbcode) { mutableStateOf(false) }
val expandable = overflows || expanded val expandable = overflows || expanded

View File

@@ -29,7 +29,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.AvatarImage import com.ts3client.android.Picture
import com.ts3client.android.ServerImages import com.ts3client.android.ServerImages
import com.ts3client.net.IconRepository import com.ts3client.net.IconRepository
import kotlin.math.roundToInt 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) { fun Avatar(uniqueId: String, flag: String, modifier: Modifier = Modifier, size: Dp = 64.dp) {
val images = LocalServerImages.current ?: return val images = LocalServerImages.current ?: return
val version by images.version.collectAsStateWithLifecycle() val version by images.version.collectAsStateWithLifecycle()
val image = remember(flag, version) { images.avatar(uniqueId, flag) } ?: return val picture = remember(flag, version) { images.avatar(uniqueId, flag) } ?: return
val painter = when (image) { Image(rememberPicturePainter(picture), "Avatar", modifier.size(size).clip(RoundedCornerShape(8.dp)))
is AvatarImage.Still -> remember(image) { BitmapPainter(image.bitmap) } }
is AvatarImage.Moving -> rememberMovingPainter(image.drawable)
} /** Draws [picture], playing it while on screen if it moves. */
Image(painter, "Avatar", modifier.size(size).clip(RoundedCornerShape(8.dp))) @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 @Composable
private fun rememberMovingPainter(drawable: AnimatedImageDrawable): Painter { private fun rememberMovingPainter(drawable: AnimatedImageDrawable): Painter {
val painter = remember(drawable) { MovingPainter(drawable) } val painter = remember(drawable) { MovingPainter(drawable) }

View File

@@ -50,6 +50,11 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
"MYTS_ACCOUNT") { page = SettingsPage.MY_TEAMSPEAK } "MYTS_ACCOUNT") { page = SettingsPage.MY_TEAMSPEAK }
Section("Appearance") Section("Appearance")
IconPackPicker(vm.icons) IconPackPicker(vm.icons)
var pictures by remember { mutableStateOf(vm.settings.descriptionPictures) }
Toggle("Show pictures in descriptions", pictures) {
pictures = it
vm.setDescriptionPictures(it)
}
} }
} }
} }

View File

@@ -191,6 +191,8 @@ public final class Settings {
public boolean chatLogs = true; public boolean chatLogs = true;
/** Where chat logs go; empty for the TeamSpeak 3 client's own folder, so both share them. */ /** Where chat logs go; empty for the TeamSpeak 3 client's own folder, so both share them. */
public String chatLogDir = ""; 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() { public static Settings load() {
Settings s = new Settings(); Settings s = new Settings();
@@ -279,6 +281,7 @@ public final class Settings {
notifications.load(props); notifications.load(props);
contactDefaults.load(props); contactDefaults.load(props);
colorContacts = parseB(props.getProperty("colorContacts"), colorContacts); colorContacts = parseB(props.getProperty("colorContacts"), colorContacts);
descriptionPictures = parseB(props.getProperty("descriptionPictures"), descriptionPictures);
whisperPolicy = parseEnum(WhisperPolicy.class, props.getProperty("whisperPolicy"), whisperPolicy); whisperPolicy = parseEnum(WhisperPolicy.class, props.getProperty("whisperPolicy"), whisperPolicy);
chatLogs = parseB(props.getProperty("chatLogs"), chatLogs); chatLogs = parseB(props.getProperty("chatLogs"), chatLogs);
chatLogDir = props.getProperty("chatLogDir", chatLogDir); chatLogDir = props.getProperty("chatLogDir", chatLogDir);
@@ -333,6 +336,7 @@ public final class Settings {
notifications.store(props); notifications.store(props);
contactDefaults.store(props); contactDefaults.store(props);
props.setProperty("colorContacts", Boolean.toString(colorContacts)); props.setProperty("colorContacts", Boolean.toString(colorContacts));
props.setProperty("descriptionPictures", Boolean.toString(descriptionPictures));
props.setProperty("whisperPolicy", whisperPolicy.name()); props.setProperty("whisperPolicy", whisperPolicy.name());
props.setProperty("chatLogs", Boolean.toString(chatLogs)); props.setProperty("chatLogs", Boolean.toString(chatLogs));
props.setProperty("chatLogDir", chatLogDir); props.setProperty("chatLogDir", chatLogDir);

View File

@@ -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.
*
* <p>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<Runnable> listeners = new CopyOnWriteArrayList<>();
private final Set<String> pending = ConcurrentHashMap.newKeySet();
private final Set<String> 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);
}
}
}

View File

@@ -19,6 +19,10 @@ public final class BBCode {
Pattern.compile("\\[url]([^\\[\\]]+)\\[/url]", Pattern.CASE_INSENSITIVE); Pattern.compile("\\[url]([^\\[\\]]+)\\[/url]", Pattern.CASE_INSENSITIVE);
private static final Pattern BARE_IMG_TAG = private static final Pattern BARE_IMG_TAG =
Pattern.compile("\\[img]([^\\[\\]]+)\\[/img]", Pattern.CASE_INSENSITIVE); 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 = private static final Pattern PLAIN_URL =
Pattern.compile("(?i)\\b(?:https?://|www\\.)[^\\s<>\"']+"); 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}"); 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() { private BBCode() {
} }
/** Converts BBCode to an HTML fragment (no surrounding {@code <html>} element). */ /** Converts BBCode to an HTML fragment (no surrounding {@code <html>} element), pictures as links. */
public static String toHtml(String input) { public static String toHtml(String input) {
return toHtml(input, false);
}
/**
* @param images whether {@code [img]} becomes an {@code <img>}, 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 ""; if (input == null || input.isEmpty()) return "";
String src = BARE_IMG_TAG.matcher(BARE_URL_TAG.matcher(input).replaceAll("[url=$1]$1[/url]")) String src = BARE_URL_TAG.matcher(input).replaceAll("[url=$1]$1[/url]");
.replaceAll("[url=$1]$1[/url]"); if (!images) src = BARE_IMG_TAG.matcher(src).replaceAll("[url=$1]$1[/url]");
StringBuilder out = new StringBuilder(); StringBuilder out = new StringBuilder();
Deque<Open> open = new ArrayDeque<>(); Deque<Open> open = new ArrayDeque<>();
@@ -65,6 +77,13 @@ public final class BBCode {
text(out, src.substring(lb), open); text(out, src.substring(lb), open);
break; 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)) { if (!emitTag(out, src.substring(lb + 1, rb), open)) {
text(out, src.substring(lb, rb + 1), 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(); 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. */ /** Escapes plain text for HTML without interpreting any BBCode. */
public static String escape(String s) { public static String escape(String s) {
if (s == null) return ""; if (s == null) return "";
@@ -109,6 +138,20 @@ public final class BBCode {
.replace("\"", "&quot;"); .replace("\"", "&quot;");
} }
/**
* 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("<img src=\"").append(escape(url)).append("\">");
return close.end();
}
/** True once the tag was recognised and written; false to render it literally. */ /** True once the tag was recognised and written; false to render it literally. */
private static boolean emitTag(StringBuilder out, String tag, Deque<Open> open) { private static boolean emitTag(StringBuilder out, String tag, Deque<Open> open) {
if (tag.isEmpty()) return false; if (tag.isEmpty()) return false;
@@ -171,6 +214,7 @@ public final class BBCode {
if (!SAFE_URL.matcher(value).matches()) return false; if (!SAFE_URL.matcher(value).matches()) return false;
return push(out, open, name, link(value), linkClose(value)); return push(out, open, name, link(value), linkClose(value));
} }
default: default:
return false; return false;
} }

View File

@@ -3,6 +3,7 @@ package com.ts3client.text;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals; 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. */ /** The block tags TeamSpeak's own client renders in descriptions. */
class BBCodeTagsTest { class BBCodeTagsTest {
@@ -31,4 +32,36 @@ class BBCodeTagsTest {
BBCode.toHtml("[table][tr][th]h[/th][td]d[/td][/tr][/table]")); BBCode.toHtml("[table][tr][th]h[/th][td]d[/td][/tr][/table]"));
assertEquals("[td]loose[/td]", BBCode.toHtml("[td]loose[/td]")); assertEquals("[td]loose[/td]", BBCode.toHtml("[td]loose[/td]"));
} }
@Test
void showsPicturesOnlyWhereAsked() {
String bb = "[img]https://example.com/a.png[/img]";
assertEquals("<img src=\"https://example.com/a.png\">", BBCode.toHtml(bb, true));
assertEquals("<a class=\"extlink\" href=\"https://example.com/a.png\">https://example.com/a.png</a>",
BBCode.toHtml(bb));
}
@Test
void linksPicturesInsideALink() {
assertEquals("<a class=\"extlink\" href=\"https://x.org\"><img src=\"http://x.org/b.gif\"></a>",
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("<img"), "only [img]…[/img] is a picture");
}
@Test
void escapesThePictureAddress() {
assertEquals("<img src=\"https://x.org/a.png?a=1&amp;b=2\">", 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"));
}
} }

View File

@@ -12,6 +12,7 @@ import javax.swing.JPanel;
import javax.swing.JTextField; import javax.swing.JTextField;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.GridBagConstraints; import java.awt.GridBagConstraints;
import java.awt.Insets;
import java.io.File; import java.io.File;
/** /**
@@ -24,6 +25,7 @@ final class ChatLogsPanel extends FormPanel {
private final JCheckBox enabled = new JCheckBox("Keep chat logs"); private final JCheckBox enabled = new JCheckBox("Keep chat logs");
private final JTextField folder = new JTextField(18); private final JTextField folder = new JTextField(18);
private final JButton browse = new JButton("Browse…"); private final JButton browse = new JButton("Browse…");
private final JCheckBox pictures = new JCheckBox("Show pictures in descriptions");
ChatLogsPanel(Settings settings) { ChatLogsPanel(Settings settings) {
this.settings = settings; this.settings = settings;
@@ -50,9 +52,17 @@ final class ChatLogsPanel extends FormPanel {
c.gridy = 2; c.gridy = 2;
c.gridx = 1; c.gridx = 1;
c.weighty = 1;
c.anchor = GridBagConstraints.NORTHWEST; c.anchor = GridBagConstraints.NORTHWEST;
add(new JLabel("Takes effect on the next connect."), c); 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(); updateEnabled();
} }
@@ -76,5 +86,6 @@ final class ChatLogsPanel extends FormPanel {
void apply() { void apply() {
settings.chatLogs = enabled.isSelected(); settings.chatLogs = enabled.isSelected();
settings.chatLogDir = folder.getText().trim(); settings.chatLogDir = folder.getText().trim();
settings.descriptionPictures = pictures.isSelected();
} }
} }

View File

@@ -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("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&amp;", "&");
}
}

View File

@@ -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;
}
}

View File

@@ -2,12 +2,14 @@ package com.ts3client.ui;
import com.formdev.flatlaf.util.ScaledEmptyBorder; import com.formdev.flatlaf.util.ScaledEmptyBorder;
import com.formdev.flatlaf.util.UIScale; import com.formdev.flatlaf.util.UIScale;
import com.ts3client.config.Settings;
import com.ts3client.contacts.Contact; import com.ts3client.contacts.Contact;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ClientEntry;
import com.ts3client.net.Details; import com.ts3client.net.Details;
import com.ts3client.net.Group; import com.ts3client.net.Group;
import com.ts3client.net.IconRepository; import com.ts3client.net.IconRepository;
import com.ts3client.net.PictureDownloads;
import com.ts3client.net.ServerModel; import com.ts3client.net.ServerModel;
import com.ts3client.net.avatar.Avatar; import com.ts3client.net.avatar.Avatar;
import com.ts3client.text.BBCode; import com.ts3client.text.BBCode;
@@ -18,9 +20,11 @@ import javax.swing.JLabel;
import javax.swing.JLayeredPane; import javax.swing.JLayeredPane;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.JScrollPane; import javax.swing.JScrollPane;
import javax.swing.SwingUtilities;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.Cursor; import java.awt.Cursor;
import java.awt.Dimension; import java.awt.Dimension;
import java.awt.Point;
import java.awt.Rectangle; import java.awt.Rectangle;
import java.awt.event.ComponentAdapter; import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent; 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_MIN_SIZE = 64;
private static final int AVATAR_BOX_STEP = 16; 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 JEditorPane pane = HtmlStyles.pane("font-family:sans-serif; font-size:11px;");
private final JScrollPane scroll = new JScrollPane(pane); private final JScrollPane scroll = new JScrollPane(pane);
@@ -66,9 +73,19 @@ public final class InfoPanel extends JPanel {
private boolean inChatTab; private boolean inChatTab;
/** The avatar box the current HTML was built for; a resize that changes it re-renders. */ /** The avatar box the current HTML was built for; a resize that changes it re-renders. */
private int renderedAvatarBox; 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()); super(new BorderLayout());
this.downloads = downloads;
this.pictures = new DescriptionPictures(downloads, settings);
pane.setBorder(new ScaledEmptyBorder(6, 8, 6, 8)); pane.setBorder(new ScaledEmptyBorder(6, 8, 6, 8));
pane.addHyperlinkListener(e -> { pane.addHyperlinkListener(e -> {
if (e.getEventType() != javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) return; 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() { scroll.getViewport().addComponentListener(new ComponentAdapter() {
@Override @Override
public void componentResized(ComponentEvent e) { 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(); 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 @Override
public void updateUI() { public void updateUI() {
super.updateUI(); super.updateUI();
@@ -201,6 +240,7 @@ public final class InfoPanel extends JPanel {
} }
private String body() { private String body() {
showsPictures = false;
if (shownServer != null) return html(Details.server(shownModel, shownServer), "", null, null); if (shownServer != null) return html(Details.server(shownModel, shownServer), "", null, null);
if (shownChannel != null) return html(Details.channel(shownChannel), "", null, "No description."); if (shownChannel != null) return html(Details.channel(shownChannel), "", null, "No description.");
if (shownClient != null) { 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)); 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) { Avatar avatar, int avatarBox) {
String details = clientDetails(cl, model, icons, contact); String details = clientDetails(cl, model, icons, contact);
String picture = AvatarHtml.imgTag(avatar, avatarBox); String picture = AvatarHtml.imgTag(avatar, avatarBox);
@@ -236,7 +276,7 @@ public final class InfoPanel extends JPanel {
+ "</tr></table>"; + "</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>" : ""; String you = cl.self ? " <span class='muted'>(you)</span>" : "";
return html(Details.client(cl, model, contact), you, icons, null); 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 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 * @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(); StringBuilder sb = new StringBuilder();
sb.append(heading(esc(details.title()) + titleSuffix)); sb.append(heading(esc(details.title()) + titleSuffix));
for (Details.Field f : details.fields()) row(sb, esc(f.label()), value(f, icons)); for (Details.Field f : details.fields()) row(sb, esc(f.label()), value(f, icons));
if (!details.description().isEmpty()) { 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()) { } else if (details.pending()) {
sb.append("<hr><i class='muted'>Loading description…</i>"); sb.append("<hr><i class='muted'>Loading description…</i>");
} else if (none != null) { } else if (none != null) {
@@ -296,9 +336,24 @@ public final class InfoPanel extends JPanel {
.append(label).append(":</span> ").append(value).append("</div>"); .append(label).append(":</span> ").append(value).append("</div>");
} }
/** Descriptions may carry BBCode markup. */ /** Descriptions may carry BBCode markup, pictures included. */
private static String multiline(String text) { private String description(String bbcode) {
return BBCode.toHtml(text); 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) { private static String esc(String s) {

View File

@@ -3,6 +3,7 @@ package com.ts3client.ui;
import com.formdev.flatlaf.util.UIScale; import com.formdev.flatlaf.util.UIScale;
import com.ts3client.audio.AudioBackend; import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.desktop.DesktopAudioBackend; import com.ts3client.audio.desktop.DesktopAudioBackend;
import com.ts3client.config.AppDirs;
import com.ts3client.config.AwayMessages; import com.ts3client.config.AwayMessages;
import com.ts3client.config.BanReasonPresets; import com.ts3client.config.BanReasonPresets;
import com.ts3client.config.Bookmark; import com.ts3client.config.Bookmark;
@@ -13,6 +14,7 @@ import com.ts3client.myts.MyTeamSpeakLogin;
import com.ts3client.teamspeak.TeamSpeakImporter; import com.ts3client.teamspeak.TeamSpeakImporter;
import com.ts3client.contacts.ContactStore; import com.ts3client.contacts.ContactStore;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.PictureDownloads;
import com.ts3client.session.SelfState; import com.ts3client.session.SelfState;
import com.ts3client.session.ServerSession; import com.ts3client.session.ServerSession;
import com.ts3client.session.Sessions; import com.ts3client.session.Sessions;
@@ -33,6 +35,7 @@ import javax.swing.JPopupMenu;
import javax.swing.JTextField; import javax.swing.JTextField;
import javax.swing.KeyStroke; import javax.swing.KeyStroke;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import java.io.File;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.Dimension; import java.awt.Dimension;
import java.awt.GridLayout; 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. */ /** Friends and blocked clients, shared by every connection. */
private final ContactStore contacts = ContactStore.load(); private final ContactStore contacts = ContactStore.load();
private final AudioBackend audio = new DesktopAudioBackend(); 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. */ /** Sound pack playback, shared by every connection. */
private final SoundNotifier sounds; private final SoundNotifier sounds;
private final SoundPlayer soundPlayer; private final SoundPlayer soundPlayer;
@@ -360,7 +364,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
// ---- tab management ---- // ---- tab management ----
private ServerTab newTab() { 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); tabs.add(tab);
sessions.add(tab.session()); sessions.add(tab.session());
tabPane.addTab(tab); tabPane.addTab(tab);

View File

@@ -7,6 +7,7 @@ import com.ts3client.config.Settings;
import com.ts3client.contacts.ContactStore; import com.ts3client.contacts.ContactStore;
import com.ts3client.net.ChannelNode; 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.TeamspeakConnection; import com.ts3client.net.TeamspeakConnection;
import com.ts3client.session.ServerSession; import com.ts3client.session.ServerSession;
import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundNotifier;
@@ -38,7 +39,7 @@ final class ServerTab {
private final ServerTabTreeActions treeActions; private final ServerTabTreeActions treeActions;
private final ServerTreePanel treePanel; private final ServerTreePanel treePanel;
private final ChatPanel chatPanel; private final ChatPanel chatPanel;
private final InfoPanel infoPanel = new InfoPanel(); private final InfoPanel infoPanel;
private final JComponent component; private final JComponent component;
private JSplitPane leftColumn; private JSplitPane leftColumn;
private int normalDividerSize; private int normalDividerSize;
@@ -50,9 +51,10 @@ final class ServerTab {
private BanListDialog banList; private BanListDialog banList;
ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio, ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio,
SoundNotifier sounds, ContactStore contacts) { SoundNotifier sounds, ContactStore contacts, PictureDownloads pictures) {
this.host = host; this.host = host;
this.settings = settings; this.settings = settings;
this.infoPanel = new InfoPanel(settings, pictures);
// ServerTabConnectionEvents must exist before the session (which needs a listener // 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 // up front), and the session must exist before the tree/chat panels and the other