Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions android/assets/jsons/translations/template.properties
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,9 @@ Welcome to Chat! =
Successfully connected to WebSocket server! =
Authentication issue detected! You have to set a password to use Chat. =
Type something... =
Everyone =
To: =
private =

Error: [error] =
WebSocket connection closed. Cause: [cause] =
Expand Down
42 changes: 30 additions & 12 deletions core/src/com/unciv/logic/multiplayer/chat/ChatStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,21 @@ import java.util.LinkedList
import java.util.Queue
import java.util.UUID

data class Chat(
data class ChatMessageEntry(
val civName: String,
val message: String,
/** True for private messages (protocol v2); false = public. */
val isPrivate: Boolean = false,
)

class Chat(
val gameId: UUID,
) {
var unreadCount = 0

// <civName, message> pairs
private val messages: MutableList<Pair<String, String>> = mutableListOf(INITIAL_MESSAGE)
private val messages: MutableList<ChatMessageEntry> = mutableListOf(
ChatMessageEntry(INITIAL_MESSAGE.first, INITIAL_MESSAGE.second)
)

val length: Int get() = messages.size

Expand All @@ -26,22 +34,28 @@ data class Chat(
*
* The server will relay it back if a delivery was acknowledged and that is when we should display it.
*/
fun requestMessageSend(civName: String, message: String) {
fun requestMessageSend(
civName: String,
message: String,
userId: String? = null,
) {
Gdx.app.postRunnable {
ChatWebSocket.requestMessageSend(Message.Chat(civName, message, gameId.toString()))
ChatWebSocket.requestMessageSend(
Message.Chat(civName, message, gameId.toString(), userId)
)
}
}

/**
* Although public, this should only be called when a ChatMessageReceivedEvent is received once.
*/
fun addMessage(civName: String, message: String) {
messages.add(Pair(civName, message))
fun addMessage(civName: String, message: String, isPrivate: Boolean = false) {
messages.add(ChatMessageEntry(civName, message, isPrivate))
}

fun forEachMessage(action: (String, String) -> Unit) {
for ((civName, message) in messages) {
action(civName, message)
fun forEachMessage(action: (ChatMessageEntry) -> Unit) {
for (entry in messages) {
action(entry)
}
}

Expand Down Expand Up @@ -91,9 +105,13 @@ object ChatStore {
}

val chat = chatPopup?.chat ?: getChatByGameId(gameId)
chat.addMessage(incomingChatMsg.civName, incomingChatMsg.message)
chat.addMessage(incomingChatMsg.civName, incomingChatMsg.message, incomingChatMsg.isPrivate)
if (gameId.equals(chatPopup?.chat?.gameId)) {
chatPopup?.addMessage(incomingChatMsg.civName, incomingChatMsg.message)
chatPopup?.addMessage(
incomingChatMsg.civName,
incomingChatMsg.message,
isPrivate = incomingChatMsg.isPrivate,
)
}

if (chatPopup == null && incomingChatMsg.civName != "System") {
Expand Down
14 changes: 12 additions & 2 deletions core/src/com/unciv/logic/multiplayer/chat/ChatWebSocket.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ sealed class Message {
@Serializable
@SerialName("chat")
data class Chat(
val civName: String, val message: String, val gameId: String
val civName: String,
val message: String,
val gameId: String,
/** Multiplayer player UUID of the recipient; null = broadcast to the game. */
val userId: String? = null,
) : Message()

@Serializable
Expand All @@ -59,7 +63,12 @@ sealed class Response {
@Serializable
@SerialName("chat")
data class Chat(
val civName: String, val message: String, val gameId: String? = null
val civName: String,
val message: String,
val gameId: String? = null,
/** True when this message was delivered as a private message (protocol v2). */
@SerialName("private")
val isPrivate: Boolean = false,
) : Response()

@Serializable
Expand Down Expand Up @@ -103,6 +112,7 @@ object ChatWebSocket {
// DO NOT OMIT
// if omitted the "type" field will be missing from all outgoing messages
classDiscriminatorMode = ClassDiscriminatorMode.ALL_JSON_OBJECTS
ignoreUnknownKeys = true
})
}
}
Expand Down
94 changes: 77 additions & 17 deletions core/src/com/unciv/ui/screens/worldscreen/chat/ChatPopup.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import com.badlogic.gdx.scenes.scene2d.ui.Button
import com.badlogic.gdx.scenes.scene2d.ui.ImageButton
import com.badlogic.gdx.scenes.scene2d.ui.Label
import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane
import com.badlogic.gdx.scenes.scene2d.ui.SelectBox
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Align
import com.unciv.UncivGame
import com.unciv.logic.multiplayer.chat.Chat
import com.unciv.logic.multiplayer.chat.ChatStore
import com.unciv.models.translations.tr
import com.unciv.ui.components.extensions.coerceLightnessAtLeast
import com.unciv.ui.components.extensions.setItems
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.widgets.UncivTextField
Expand All @@ -28,18 +30,28 @@ private val civChatColorsMap = mapOf<String, Color>(
"Server" to Color.DARK_GRAY,
)

private class ChatRecipient(
val displayName: String,
val userId: String?,
) {
override fun toString() = displayName.tr()
}

class ChatPopup(
val chat: Chat,
private val worldScreen: WorldScreen,
) : Popup(screen = worldScreen, scrollable = Scrollability.None) {
companion object {
// the percentage of the minimum lightness allowed for a civName
const val CIVNAME_COLOR_MIN_LIGHTNESS = 0.55f
/** Server chat protocol version that supports private messages. */
const val PRIVATE_MESSAGE_CHAT_VERSION = 2
}

private val chatTable = Table(skin)
private val scrollPane = ScrollPane(chatTable, skin)
private val messageField = UncivTextField(hint = "Type something...")
private var recipientSelect: SelectBox<ChatRecipient>? = null

init {
ChatStore.chatPopup = this
Expand All @@ -49,6 +61,7 @@ class ChatPopup(
* Layout:
* | ChatHeader | CloseButton |
* | ChatTable (colSpan = 2) |
* | RecipientSelect (optional, colSpan = 2) |
* | MessageField | SendButton |
*/

Expand Down Expand Up @@ -77,6 +90,21 @@ class ChatPopup(
.size(0.5f * worldScreen.stage.width, 0.5f * worldScreen.stage.height)
.expand().fill().row()

if (supportsPrivateMessages()) {
val recipients = buildRecipientOptions()
if (recipients.size > 1) {
val select = SelectBox<ChatRecipient>(skin)
select.setItems(recipients)
select.selected = recipients.first()
recipientSelect = select
// Keep label + dropdown together on the left (popup is a 2-column table).
val toRow = Table(skin)
toRow.add("To:".toLabel()).padLeft(5f).padRight(8f)
toRow.add(select).minWidth(220f).left()
add(toRow).colspan(2).left().padBottom(5f).row()
}
}

// Input: | MessageField | SendButton |
add(messageField).expandX().fillX()
val sendButton = Button(skin)
Expand All @@ -99,35 +127,67 @@ class ChatPopup(
})
}

fun sendMessage() {
val message = messageField.text.trim()
private fun supportsPrivateMessages(): Boolean =
UncivGame.Current.onlineMultiplayer.multiplayerServer.getFeatureSet().chatVersion >=
PRIVATE_MESSAGE_CHAT_VERSION

private fun ownCivNameAndId(): Pair<String, String?> {
val userId = UncivGame.Current.settings.multiplayer.getUserId()
val currentPlayerCiv = worldScreen.gameInfo.currentPlayerCiv
val civName = if (currentPlayerCiv.playerId == userId) {
currentPlayerCiv.civID
} else {
// what do I do if someone is a spectator?
worldScreen.gameInfo.civilizations.firstOrNull { civ -> civ.playerId == userId }?.civID
?: "Unknown"
if (currentPlayerCiv.playerId == userId) {
return currentPlayerCiv.civID to userId
}
val ownCiv = worldScreen.gameInfo.civilizations.firstOrNull { civ -> civ.playerId == userId }
return (ownCiv?.civID ?: "Unknown") to ownCiv?.playerId
}

private fun buildRecipientOptions(): List<ChatRecipient> {
val (ownCivName, ownPlayerId) = ownCivNameAndId()
if (ownCivName == "Unknown" || ownPlayerId.isNullOrBlank()) {
return listOf(ChatRecipient("Everyone", null))
}

if (message.isNotEmpty()) {
chat.requestMessageSend(civName, message)
messageField.setText("")
val options = mutableListOf(ChatRecipient("Everyone", null))
for (civ in worldScreen.gameInfo.civilizations) {
if (!civ.isMajorCiv()) continue
if (!civ.isHuman()) continue
if (civ.playerId.isBlank()) continue
if (civ.playerId == ownPlayerId) continue
options.add(ChatRecipient(civ.civID, civ.playerId))
}
return options
}

fun sendMessage() {
val message = messageField.text.trim()
if (message.isEmpty()) return

val (civName, _) = ownCivNameAndId()
val recipient = recipientSelect?.selected
chat.requestMessageSend(
civName = civName,
message = message,
userId = recipient?.userId,
)
messageField.setText("")
}

fun addMessage(
senderCivName: String,
message: String,
suffix: String? = null,
isPrivate: Boolean = false,
scroll: Boolean = true
) {
val line = Label(
"${senderCivName.tr()}${if (suffix != null) " [${suffix.tr()}]" else ""}: ${message.tr()}",
skin
).apply {
val namePart = buildString {
append(senderCivName.tr())
if (suffix != null) append(" [${suffix.tr()}]")
if (isPrivate) append(" (${"private".tr()})")
append(": ")
append(message.tr())
}

val line = Label(namePart, skin).apply {
wrap = true

val civNameColor =
Expand All @@ -144,8 +204,8 @@ class ChatPopup(

private fun populateChat() {
chatTable.clearChildren()
chat.forEachMessage { civName, message ->
addMessage(civName, message)
chat.forEachMessage { entry ->
addMessage(entry.civName, entry.message, isPrivate = entry.isPrivate)
}
ChatStore.pollGlobalMessages { civName, message ->
addMessage(civName, message, suffix = "one time")
Expand Down
Loading
Loading