diff --git a/minchat-client/src/main/kotlin/io/minchat/client/Minchat.kt b/minchat-client/src/main/kotlin/io/minchat/client/Minchat.kt index 0b1b666..91c5e1e 100644 --- a/minchat-client/src/main/kotlin/io/minchat/client/Minchat.kt +++ b/minchat-client/src/main/kotlin/io/minchat/client/Minchat.kt @@ -1,7 +1,6 @@ package io.minchat.client import arc.Events -import arc.math.Mathf import arc.scene.ui.Label import com.github.mnemotechnician.mkui.delegates.setting import com.github.mnemotechnician.mkui.extensions.dsl.* @@ -16,7 +15,6 @@ import io.minchat.client.plugin.MinchatPluginHandler import io.minchat.client.plugin.impl.AccountSaverPlugin import io.minchat.client.ui.* import io.minchat.client.ui.chat.ChatFragment -import io.minchat.client.ui.dialog.AbstractModalDialog import io.minchat.client.ui.managers.* import io.minchat.common.MINCHAT_VERSION import io.minchat.rest.* @@ -26,8 +24,6 @@ import mindustry.Vars import mindustry.game.EventType import mindustry.mod.Mod import mindustry.ui.Styles -import java.io.File -import java.net.URL import java.time.ZoneId import java.time.format.DateTimeFormatter import kotlin.concurrent.thread @@ -146,46 +142,6 @@ class MinchatMod : Mod(), CoroutineScope { dontShowInfoAgain = it }.row() }.show() - - // TODO REMOVE AFTER TESTING - object : AbstractModalDialog() { - init { - header.addLabel("AsyncImage test").row() - - val images = listOf( - "https://1000logos.net/wp-content/uploads/2021/05/Google-logo.png", - "file:///home/fox/Downloads/866054901817409567.png", - "file:///home/fox/Downloads/images.png", - "invalid image" - ).map { - it to AsyncImage(this@MinchatMod).also { - body.add(it) - .minSize(48f) - .maxSize(512f) - .row() - } - } - - action("refresh") { - images.forEach { (url, element) -> - if (url.startsWith("file://")) { - element.setFileAsync { - val data = File(url.substringAfter("//")).readBytes() - client.fileCache.getFileOrPut(url, "png") { - delay(Mathf.random(2000L)) - data - } - } - } else { - element.setFileAsync { - val stream = URL(url).openStream() - client.fileCache.getFileOrPut(url, "png") { stream.readBytes() } - } - } - } - } - } - }.show() } MinchatKeybinds.registerDefaultKeybinds() diff --git a/minchat-client/src/main/kotlin/io/minchat/client/ui/AsyncImage.kt b/minchat-client/src/main/kotlin/io/minchat/client/ui/AsyncImage.kt index a922a51..2362e04 100644 --- a/minchat-client/src/main/kotlin/io/minchat/client/ui/AsyncImage.kt +++ b/minchat-client/src/main/kotlin/io/minchat/client/ui/AsyncImage.kt @@ -19,7 +19,7 @@ import kotlin.math.* * * Currently only supports PNG images. */ -class AsyncImage(parentScope: CoroutineScope) : Image(), CoroutineScope { +open class AsyncImage(parentScope: CoroutineScope) : Image(), CoroutineScope { override val coroutineContext = SupervisorJob() + CoroutineExceptionHandler(::reportException) private var fetcherJob: Job? = null diff --git a/minchat-client/src/main/kotlin/io/minchat/client/ui/chat/NormalMessageElement.kt b/minchat-client/src/main/kotlin/io/minchat/client/ui/chat/NormalMessageElement.kt index 99661c3..6e1bedd 100644 --- a/minchat-client/src/main/kotlin/io/minchat/client/ui/chat/NormalMessageElement.kt +++ b/minchat-client/src/main/kotlin/io/minchat/client/ui/chat/NormalMessageElement.kt @@ -1,21 +1,18 @@ package io.minchat.client.ui.chat -import arc.Core import arc.graphics.Color import arc.scene.event.Touchable import arc.scene.ui.Label import arc.util.* import com.github.mnemotechnician.mkui.extensions.dsl.* -import com.github.mnemotechnician.mkui.extensions.elements.* +import com.github.mnemotechnician.mkui.extensions.elements.content import com.github.mnemotechnician.mkui.extensions.runUi import io.minchat.client.Minchat import io.minchat.client.config.MinchatSettings import io.minchat.client.misc.* -import io.minchat.client.ui.AsyncImage import io.minchat.client.ui.MinchatStyle.layoutMargin import io.minchat.client.ui.MinchatStyle.layoutPad import io.minchat.client.ui.dialog.* -import io.minchat.common.entity.User import io.minchat.rest.entity.MinchatMessage import kotlinx.coroutines.* import io.minchat.client.ui.MinchatStyle as Style @@ -90,17 +87,9 @@ class NormalMessageElement( } val nameColorTag = "[#$nameColor]" - val avatar = message.author.avatar ?: User.Avatar.defaultAvatar - if (avatar is User.Avatar.IconAvatar) { - addImage(Core.atlas.find(avatar.iconName), scaling = Scaling.fill) - .size(48f) - } else { - add(AsyncImage(this@NormalMessageElement).apply { - setFileAsync { - message.author.getImageAvatar(false)!! - } - }).size(48f).scaleImage(Scaling.fill) - } + add(UserAvatarElement(message.authorId, message.author.avatar, false)) + .maxSize(40f) + .get().clicked(::showUserDialog) addSpace(width = 5f) diff --git a/minchat-client/src/main/kotlin/io/minchat/client/ui/chat/UserAvatarElement.kt b/minchat-client/src/main/kotlin/io/minchat/client/ui/chat/UserAvatarElement.kt new file mode 100644 index 0000000..6ba5ab5 --- /dev/null +++ b/minchat-client/src/main/kotlin/io/minchat/client/ui/chat/UserAvatarElement.kt @@ -0,0 +1,57 @@ +package io.minchat.client.ui.chat + +import arc.Core +import arc.scene.style.TextureRegionDrawable +import io.minchat.client.Minchat +import io.minchat.client.ui.AsyncImage +import io.minchat.common.entity.User +import kotlinx.coroutines.CoroutineScope + +class UserAvatarElement( + userId: Long, + avatar: User.Avatar?, + val full: Boolean, + parentScope: CoroutineScope +) : AsyncImage(parentScope) { + var userId = userId + set(value) { + field = value + updateAvatar() + } + var avatar = avatar + set(value) { + field = value + updateAvatar() + } + + init { + updateAvatar() + } + + fun updateAvatar() { + val avatar = avatar ?: User.Avatar.defaultAvatar + when (avatar) { + is User.Avatar.IconAvatar -> { + setDrawableAsync(avatar.iconName) { + val region = Core.atlas.find(avatar.iconName).takeIf { Core.atlas.isFound(it) } + ?: error("No region found for icon avatar ${avatar.iconName}!") + + TextureRegionDrawable(region) + } + } + + is User.Avatar.ImageAvatar, is User.Avatar.LocalAvatar -> { + setFileAsync { + Minchat.client.getCacheableAvatar(userId, avatar, full) {} + ?: error("No avatar found for user ${userId} and avatar ${avatar}") + } + } + } + } +} + +fun CoroutineScope.UserAvatarElement( + userId: Long, + avatar: User.Avatar?, + full: Boolean +) = UserAvatarElement(userId, avatar, full, this) diff --git a/minchat-client/src/main/kotlin/io/minchat/client/ui/dialog/Dialogs.kt b/minchat-client/src/main/kotlin/io/minchat/client/ui/dialog/Dialogs.kt index 48d67da..7888b20 100644 --- a/minchat-client/src/main/kotlin/io/minchat/client/ui/dialog/Dialogs.kt +++ b/minchat-client/src/main/kotlin/io/minchat/client/ui/dialog/Dialogs.kt @@ -236,8 +236,7 @@ object Dialogs : CoroutineScope { val cancellable: Boolean, val onSelect: (Int) -> Unit ) : AbstractModalDialog() { - override val addCloseAction get() = cancellable - override val closeButtonText get() = "Cancel" + override val addCloseAction get() = false init { if (message.isNotBlank()) header.addTable(Style.surfaceBackground) { @@ -248,10 +247,18 @@ object Dialogs : CoroutineScope { .minWidth(300f) } - for (i in choices.indices) { + if (cancellable) { + nextActionRow() + action("Cancel") { + hide() + } + } + + for (i in choices.indices.reversed()) { nextActionRow() action(choices[i]) { onSelect(i) + hide() } } } diff --git a/minchat-client/src/main/kotlin/io/minchat/client/ui/dialog/UserDialog.kt b/minchat-client/src/main/kotlin/io/minchat/client/ui/dialog/UserDialog.kt index 65875e1..54b0069 100644 --- a/minchat-client/src/main/kotlin/io/minchat/client/ui/dialog/UserDialog.kt +++ b/minchat-client/src/main/kotlin/io/minchat/client/ui/dialog/UserDialog.kt @@ -1,18 +1,24 @@ package io.minchat.client.ui.dialog +import arc.Core import arc.graphics.Color +import arc.scene.style.TextureRegionDrawable import arc.scene.ui.Label -import arc.util.Align +import arc.scene.ui.layout.Table +import arc.util.* import com.github.mnemotechnician.mkui.extensions.dsl.* import com.github.mnemotechnician.mkui.extensions.elements.* +import io.ktor.utils.io.jvm.javaio.* import io.minchat.client.Minchat import io.minchat.client.misc.* import io.minchat.client.ui.MinchatStyle.buttonMargin import io.minchat.client.ui.MinchatStyle.layoutMargin import io.minchat.client.ui.MinchatStyle.layoutPad +import io.minchat.client.ui.chat.UserAvatarElement import io.minchat.common.entity.* import io.minchat.rest.entity.MinchatUser -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.* +import mindustry.Vars import java.time.Instant import kotlin.random.Random import kotlin.reflect.KMutableProperty0 @@ -119,11 +125,47 @@ abstract class UserDialog( inner class UserEditDialog : AbstractModalDialog() { val user = this@UserDialog.user!! + var newAvatar = user.avatar + set(value) { + field = value + avatarElement.avatar = value + } + + lateinit var avatarElement: UserAvatarElement init { header.addLabel("Editing user ${user.tag} (${user.displayName}).", align = Align.left, wrap = true) .fillX().row() + // Avatar preview & change button + body.addTable(Style.surfaceBackground) { + margin(layoutMargin) + + add(UserAvatarElement(user.id, user.avatar, true)) + .maxSize(128f) + .apply { avatarElement = get() } + + // Actions - to the left of the avatar + addTable { + top() + textButton("Change", Style.InnerActionButton) { + Dialogs.choices( + "What to change your avatar to?", + "Mindustry icon" to { IconAvatarChangeDialog().show() }, + "Image" to { Dialogs.TODO() }, + "Nothing" to { + Dialogs.confirm("Are you sure you want to reset your avatar?") { + newAvatar = null + } + }, + cancellable = true + ) + }.margin(buttonMargin).growX() + }.growX() + }.pad(layoutPad).fillX() + .colspan(2) + .row() + val usernameField = inputField("New nickname", default = user.nickname ?: user.username) { it.length in 3..40 } @@ -131,12 +173,92 @@ abstract class UserDialog( action("Confirm") { hide() launchSafeWithStatus("Editing user ${user.username}...") { - this@UserDialog.user = user.edit( - newNickname = usernameField.content - ) + if (usernameField.content != user.nickname) { + this@UserDialog.user = user.edit( + newNickname = usernameField.content + ) + } + + if (newAvatar != user.avatar) when (val avatar = newAvatar) { + // If an icon avatar, call the icon avatar route. + // If a local one, call the image avatar route. Otherwise, it's an error. + null -> user.setIconAvatar(null) + is User.Avatar.IconAvatar -> user.setIconAvatar(avatar.iconName) + is User.Avatar.LocalAvatar -> { + Vars.ui.loadfrag.apply { + show("Uploading avatar...") + setButton { cancel(CancellationException("Avatar upload was cancelled.")) } + + user.uploadAvatar(avatar.file.inputStream().toByteReadChannel()) { + setProgress(it) + } + + hide() + } + } + is User.Avatar.ImageAvatar -> { + error("Cannot set avatar to a non-local image avatar!") + } + } } }.disabled { !usernameField.isValid } } + + inner class IconAvatarChangeDialog : AbstractModalDialog() { + lateinit var iconsTable: Table + + init { + header.addLabel("Choose an icon.").fillX().row() + + // A search bar + body.textField("", Style.TextInput) { + rebuildWithCriteria(it.trim().replace(' ', '-').takeIf { it.isNotEmpty() }) + }.fillX() + .pad(layoutPad) + .apply { get().hint = "Type to search" } + .row() + + body.addTable(Style.surfaceBackground) { + // The icon list + limitedScrollPane(limitW = false, limitH = true) { + margin(layoutMargin) + iconsTable = this + }.pad(layoutPad) + .fillX() + .height(Core.graphics.height / 3f) + .row() + }.margin(layoutMargin).pad(layoutPad) + .row() + + rebuildWithCriteria(null) + } + + fun rebuildWithCriteria(queryString: String?) { + iconsTable.clear() + + val icons = Core.atlas.regions.toList().filter { + (it.name.endsWith("-full") || it.name.endsWith("-ui")) + && (queryString == null || it.name.contains(queryString, true)) + }.distinctBy { + // Prevent duplicates + it.name.removeSuffix("-ui").removeSuffix("-full") + } + + val iconsPerRow = (Core.graphics.width / 64).coerceAtLeast(2); + for (icon in icons) { + val image = iconsTable.addImage(TextureRegionDrawable(icon), scaling = Scaling.fill) + .size(48f) + .pad(layoutPad) + .rowPer(iconsPerRow) + .get() + + image.clicked { + newAvatar = User.Avatar.IconAvatar(icon.name) + hide() + } + } + } + } } inner class UserDeleteConfirmDialog : AbstractModalDialog() {