From e61e7a38a047d1c0e073d1b47461a884f975e733 Mon Sep 17 00:00:00 2001 From: nea Date: Mon, 31 Jul 2023 18:00:32 +0200 Subject: Clickable chat links --- .../moe/nea/firmament/features/FeatureManager.kt | 4 +- .../moe/nea/firmament/features/chat/ChatLinks.kt | 158 +++++++++++++++++++++ .../nea/firmament/features/chat/ImagePreview.kt | 148 ------------------- .../resources/assets/firmament/lang/en_us.json | 11 +- 4 files changed, 166 insertions(+), 155 deletions(-) create mode 100644 src/main/kotlin/moe/nea/firmament/features/chat/ChatLinks.kt delete mode 100644 src/main/kotlin/moe/nea/firmament/features/chat/ImagePreview.kt (limited to 'src/main') diff --git a/src/main/kotlin/moe/nea/firmament/features/FeatureManager.kt b/src/main/kotlin/moe/nea/firmament/features/FeatureManager.kt index 416da18..f1600a5 100644 --- a/src/main/kotlin/moe/nea/firmament/features/FeatureManager.kt +++ b/src/main/kotlin/moe/nea/firmament/features/FeatureManager.kt @@ -21,7 +21,7 @@ package moe.nea.firmament.features import kotlinx.serialization.Serializable import kotlinx.serialization.serializer import moe.nea.firmament.Firmament -import moe.nea.firmament.features.chat.ImagePreview +import moe.nea.firmament.features.chat.ChatLinks import moe.nea.firmament.features.debug.DebugView import moe.nea.firmament.features.debug.DeveloperFeatures import moe.nea.firmament.features.fishing.FishingWarning @@ -58,7 +58,7 @@ object FeatureManager : DataHolder(serializer(), "feature loadFeature(SlotLocking) loadFeature(StorageOverlay) loadFeature(CraftingOverlay) - loadFeature(ImagePreview) + loadFeature(ChatLinks) loadFeature(SaveCursorPosition) loadFeature(CustomSkyBlockTextures) loadFeature(Fixes) diff --git a/src/main/kotlin/moe/nea/firmament/features/chat/ChatLinks.kt b/src/main/kotlin/moe/nea/firmament/features/chat/ChatLinks.kt new file mode 100644 index 0000000..41b2e96 --- /dev/null +++ b/src/main/kotlin/moe/nea/firmament/features/chat/ChatLinks.kt @@ -0,0 +1,158 @@ +package moe.nea.firmament.features.chat + +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.utils.io.jvm.javaio.* +import java.net.URL +import java.util.* +import moe.nea.jarvis.api.Point +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlin.math.min +import net.minecraft.client.gui.screen.ChatScreen +import net.minecraft.client.texture.NativeImage +import net.minecraft.client.texture.NativeImageBackedTexture +import net.minecraft.text.ClickEvent +import net.minecraft.text.HoverEvent +import net.minecraft.text.Style +import net.minecraft.text.Text +import net.minecraft.util.Formatting +import net.minecraft.util.Identifier +import moe.nea.firmament.Firmament +import moe.nea.firmament.events.ClientChatLineReceivedEvent +import moe.nea.firmament.events.ScreenRenderPostEvent +import moe.nea.firmament.features.FirmamentFeature +import moe.nea.firmament.gui.config.ManagedConfig +import moe.nea.firmament.util.MC +import moe.nea.firmament.util.transformEachRecursively +import moe.nea.firmament.util.unformattedString + +object ChatLinks : FirmamentFeature { + override val identifier: String + get() = "chat-links" + + object TConfig : ManagedConfig(identifier) { + val enableLinks by toggle("links-enabled") { true } + val imageEnabled by toggle("image-enabled") { true } + val allowAllHosts by toggle("allow-all-hosts") { false } + val allowedHosts by string("allowed-hosts") { "cdn.discordapp.com,media.discordapp.com,media.discordapp.net,i.imgur.com" } + val actualAllowedHosts get() = allowedHosts.split(",").map { it.trim() } + val position by position("position", 16 * 20, 9 * 20) { Point(0.0, 0.0) } + } + + private fun isHostAllowed(host: String) = + TConfig.allowAllHosts || TConfig.actualAllowedHosts.any { it.equals(host, ignoreCase = true) } + + private fun isUrlAllowed(url: String) = isHostAllowed(url.removePrefix("https://").substringBefore("/")) + + override val config get() = TConfig + val urlRegex = "https://[^. ]+\\.[^ ]+( |$)".toRegex() + + data class Image( + val texture: Identifier, + val width: Int, + val height: Int, + ) + + val imageCache: MutableMap> = + Collections.synchronizedMap(mutableMapOf>()) + + private fun tryCacheUrl(url: String) { + if (!isUrlAllowed(url)) { + return + } + if (url in imageCache) { + return + } + imageCache[url] = Firmament.coroutineScope.async { + try { + val response = Firmament.httpClient.get(URL(url)) + if (response.status.value == 200) { + val inputStream = response.bodyAsChannel().toInputStream(Firmament.globalJob) + val image = NativeImage.read(inputStream) + val texture = MC.textureManager.registerDynamicTexture( + "dynamic_image_preview", + NativeImageBackedTexture(image) + ) + Image(texture, image.width, image.height) + } else + null + } catch (exc: Exception) { + exc.printStackTrace() + null + } + } + } + + val imageExtensions = listOf("jpg", "png", "gif", "jpeg") + fun isImageUrl(url: String): Boolean { + return (url.substringAfterLast('.').lowercase() in imageExtensions) + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun onLoad() { + ClientChatLineReceivedEvent.subscribe { + if (TConfig.enableLinks) + it.replaceWith = it.text.transformEachRecursively { child -> + val text = child.string + if ("://" !in text) return@transformEachRecursively child + val s = Text.empty().setStyle(child.style) + var index = 0 + while (index < text.length) { + val nextMatch = urlRegex.find(text, index) + if (nextMatch == null) { + s.append(Text.literal(text.substring(index, text.length))) + break + } + val range = nextMatch.groups[0]!!.range + val url = nextMatch.groupValues[0] + s.append(Text.literal(text.substring(index, range.first))) + s.append( + Text.literal(url).setStyle( + Style.EMPTY.withUnderline(true).withColor( + Formatting.AQUA + ).withHoverEvent(HoverEvent(HoverEvent.Action.SHOW_TEXT, Text.literal(url))) + .withClickEvent(ClickEvent(ClickEvent.Action.OPEN_URL, url)) + ) + ) + if (isImageUrl(url)) + tryCacheUrl(url) + index = range.last + 1 + } + s + } + } + + ScreenRenderPostEvent.subscribe { + if (!TConfig.imageEnabled) return@subscribe + if (it.screen !is ChatScreen) return@subscribe + val hoveredComponent = + MC.inGameHud.chatHud.getTextStyleAt(it.mouseX.toDouble(), it.mouseY.toDouble()) ?: return@subscribe + val hoverEvent = hoveredComponent.hoverEvent ?: return@subscribe + val value = hoverEvent.getValue(HoverEvent.Action.SHOW_TEXT) ?: return@subscribe + val url = urlRegex.matchEntire(value.unformattedString)?.groupValues?.get(0) ?: return@subscribe + if (!isImageUrl(url)) return@subscribe + val imageFuture = imageCache[url] ?: return@subscribe + if (!imageFuture.isCompleted) return@subscribe + val image = imageFuture.getCompleted() ?: return@subscribe + it.drawContext.matrices.push() + val pos = TConfig.position + pos.applyTransformations(it.drawContext.matrices) + val scale = min(1F, min((9 * 20F) / image.height, (16 * 20F) / image.width)) + it.drawContext.matrices.scale(scale, scale, 1F) + it.drawContext.drawTexture( + image.texture, + 0, + 0, + 1F, + 1F, + image.width, + image.height, + image.width, + image.height, + ) + it.drawContext.matrices.pop() + } + } +} diff --git a/src/main/kotlin/moe/nea/firmament/features/chat/ImagePreview.kt b/src/main/kotlin/moe/nea/firmament/features/chat/ImagePreview.kt deleted file mode 100644 index e612995..0000000 --- a/src/main/kotlin/moe/nea/firmament/features/chat/ImagePreview.kt +++ /dev/null @@ -1,148 +0,0 @@ -package moe.nea.firmament.features.chat - -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.utils.io.jvm.javaio.* -import java.net.URL -import java.util.* -import moe.nea.jarvis.api.Point -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlin.math.max -import kotlin.math.min -import net.minecraft.client.gui.screen.ChatScreen -import net.minecraft.client.texture.NativeImage -import net.minecraft.client.texture.NativeImageBackedTexture -import net.minecraft.text.HoverEvent -import net.minecraft.text.Style -import net.minecraft.text.Text -import net.minecraft.util.Formatting -import net.minecraft.util.Identifier -import moe.nea.firmament.Firmament -import moe.nea.firmament.events.ClientChatLineReceivedEvent -import moe.nea.firmament.events.ScreenRenderPostEvent -import moe.nea.firmament.features.FirmamentFeature -import moe.nea.firmament.gui.config.ManagedConfig -import moe.nea.firmament.util.MC -import moe.nea.firmament.util.transformEachRecursively -import moe.nea.firmament.util.unformattedString - -object ImagePreview : FirmamentFeature { - override val identifier: String - get() = "image-preview" - - object TConfig : ManagedConfig(identifier) { - val enabled by toggle("enabled") { true } - val allowAllHosts by toggle("allow-all-hosts") { false } - val allowedHosts by string("allowed-hosts") { "cdn.discordapp.com,media.discordapp.com,media.discordapp.net,i.imgur.com" } - val actualAllowedHosts get() = allowedHosts.split(",").map { it.trim() } - val position by position("position", 16 * 20, 9 * 20) { Point(0.0, 0.0) } - } - - private fun isHostAllowed(host: String) = - TConfig.allowAllHosts || TConfig.actualAllowedHosts.any { it.equals(host, ignoreCase = true) } - - private fun isUrlAllowed(url: String) = isHostAllowed(url.removePrefix("https://").substringBefore("/")) - - override val config get() = TConfig - val urlRegex = "https://[^. ]+\\.[^ ]+(\\.(png|gif|jpe?g))(\\?[^ ]*)?( |$)".toRegex() - - data class Image( - val texture: Identifier, - val width: Int, - val height: Int, - ) - - val imageCache: MutableMap> = - Collections.synchronizedMap(mutableMapOf>()) - - private fun tryCacheUrl(url: String) { - if (!isUrlAllowed(url)) { - return - } - if (url in imageCache) { - return - } - imageCache[url] = Firmament.coroutineScope.async { - try { - val response = Firmament.httpClient.get(URL(url)) - if (response.status.value == 200) { - val inputStream = response.bodyAsChannel().toInputStream(Firmament.globalJob) - val image = NativeImage.read(inputStream) - val texture = MC.textureManager.registerDynamicTexture( - "dynamic_image_preview", - NativeImageBackedTexture(image) - ) - Image(texture, image.width, image.height) - } else - null - } catch (exc: Exception) { - exc.printStackTrace() - null - } - } - } - - @OptIn(ExperimentalCoroutinesApi::class) - override fun onLoad() { - ClientChatLineReceivedEvent.subscribe { - it.replaceWith = it.text.transformEachRecursively { child -> - val text = child.string - if ("://" !in text) return@transformEachRecursively child - val s = Text.empty().setStyle(child.style) - var index = 0 - while (index < text.length) { - val nextMatch = urlRegex.find(text, index) - if (nextMatch == null) { - s.append(Text.literal(text.substring(index, text.length))) - break - } - val range = nextMatch.groups[0]!!.range - val url = nextMatch.groupValues[0] - s.append(Text.literal(text.substring(index, range.first))) - s.append( - Text.literal(url).setStyle( - Style.EMPTY.withUnderline(true).withColor( - Formatting.AQUA - ).withHoverEvent(HoverEvent(HoverEvent.Action.SHOW_TEXT, Text.literal(url))) - ) - ) - tryCacheUrl(url) - index = range.last + 1 - } - s - } - } - - ScreenRenderPostEvent.subscribe { - if (!TConfig.enabled) return@subscribe - if (it.screen !is ChatScreen) return@subscribe - val hoveredComponent = - MC.inGameHud.chatHud.getTextStyleAt(it.mouseX.toDouble(), it.mouseY.toDouble()) ?: return@subscribe - val hoverEvent = hoveredComponent.hoverEvent ?: return@subscribe - val value = hoverEvent.getValue(HoverEvent.Action.SHOW_TEXT) ?: return@subscribe - val url = urlRegex.matchEntire(value.unformattedString)?.groupValues?.get(0) ?: return@subscribe - val imageFuture = imageCache[url] ?: return@subscribe - if (!imageFuture.isCompleted) return@subscribe - val image = imageFuture.getCompleted() ?: return@subscribe - it.drawContext.matrices.push() - val pos = TConfig.position - pos.applyTransformations(it.drawContext.matrices) - val scale = min(1F, min((9 * 20F) / image.height, (16 * 20F) / image.width)) - it.drawContext.matrices.scale(scale, scale, 1F) - it.drawContext.drawTexture( - image.texture, - 0, - 0, - 1F, - 1F, - image.width, - image.height, - image.width, - image.height, - ) - it.drawContext.matrices.pop() - } - } -} diff --git a/src/main/resources/assets/firmament/lang/en_us.json b/src/main/resources/assets/firmament/lang/en_us.json index 9fc4340..ee6a23b 100644 --- a/src/main/resources/assets/firmament/lang/en_us.json +++ b/src/main/resources/assets/firmament/lang/en_us.json @@ -69,11 +69,12 @@ "firmament.config.storage-overlay.scroll-speed": "Scroll Speed", "firmament.config.storage-overlay.inverse-scroll": "Invert Scroll", "firmament.config.storage-overlay.margin": "Margin", - "firmament.config.image-preview": "Image Preview", - "firmament.config.image-preview.enabled": "Enable Image Preview", - "firmament.config.image-preview.allow-all-hosts": "Allow all Image Hosts", - "firmament.config.image-preview.allowed-hosts": "Allowed Image Hosts", - "firmament.config.image-preview.position": "Chat Image Preview", + "firmament.config.chat-links": "Chat Links", + "firmament.config.chat-links.links-enabled": "Enable Clickable Links", + "firmament.config.chat-links.image-enabled": "Enable Image Preview", + "firmament.config.chat-links.allow-all-hosts": "Allow all Image Hosts", + "firmament.config.chat-links.allowed-hosts": "Allowed Image Hosts", + "firmament.config.chat-links.position": "Chat Image Preview", "firmament.hud.edit": "Edit %s", "firmament.config.custom-skyblock-textures": "Custom SkyBlock Item Textures", "firmament.config.custom-skyblock-textures.cache-duration": "Model Cache Duration", -- cgit