Merge chat polls and reactions into master

This commit is contained in:
evgeny committed 2026-10-06 23:08:07 +02:00
commit 918a3cc061
59 files changed
+3040 -393

No files matched your search

@@ -67,7 +67,8 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L
val imageWidth: Int = 0, val imageHeight: Int = 0,
val videoWidth: Int = 0, val videoHeight: Int = 0, val videoDurationMs: Int = 0,
val downloadState: String = "", /* "", "downloading", "downloaded", "error" */
val deliveryStatusRes: Int = 0, val inviteGroupName: String? = null, val authorNodeId: Long = 0)
val deliveryStatusRes: Int = 0, val inviteGroupName: String? = null, val authorNodeId: Long = 0,
val messageId: String = "", val feedback: FeedbackState? = null)
data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false,
val isAdmin: Boolean = false, val isSuper: Boolean = false,
@@ -382,6 +383,7 @@ class ChatRepository {
list.add(Message(
id = obj.getLong("id"),
messageId = obj.optString("messageId"),
author = obj.getString("author"),
authorNodeId = obj.optLong("authorNodeId"),
text = displayText,
@@ -0,0 +1,43 @@
package com.utun.chat.data
import org.json.JSONArray
import org.json.JSONObject
data class ReactionState(val id: Int, val emoji: String, val count: Int = 0, val mine: Boolean = false)
data class PollOption(val id: Int, val text: String, val count: Int, val percent: Double)
data class PollResponse(val nodeId: Long, val choices: List<Int>, val custom: String)
data class PollState(val question: String, val multiple: Boolean, val allowCustom: Boolean,
val options: List<PollOption>, val voters: Int, val ver: Long,
val myChoices: List<Int>, val myCustom: String, val answers: List<PollResponse>)
data class FeedbackState(val messageId: String, val pending: Boolean, val reactions: List<ReactionState>, val poll: PollState?)
private fun JSONArray?.choices(): List<Int> = if (this == null) emptyList() else (0 until length()).map { getInt(it) }
/** Формат и правила задаёт ядро; интерфейс разбирает готовый снимок. */
fun parseFeedback(json: JSONObject): FeedbackState {
val id = json.getString("message_id")
require(id.length == 64) { "Invalid message ID" }
val reactions = json.getJSONArray("reactions").let { values ->
(0 until values.length()).map { i -> values.getJSONObject(i).let {
ReactionState(it.getInt("id"), it.getString("emoji"), it.getInt("count"), it.getBoolean("mine"))
} }
}
val poll = json.optJSONObject("poll")?.let { value ->
val options = value.getJSONArray("options").let { values ->
(0 until values.length()).map { i -> values.getJSONObject(i).let {
PollOption(it.getInt("id"), it.getString("text"), it.getInt("count"), it.getDouble("percent"))
} }
}
val mine = value.getJSONObject("mine")
val answers = value.getJSONArray("answers").let { values ->
(0 until values.length()).map { i -> values.getJSONObject(i).let { row ->
val answer = row.getJSONObject("answer")
PollResponse(row.getString("node_id").removePrefix("0x").toLong(16),
answer.optJSONArray("choices").choices(), answer.optString("custom"))
} }
}
PollState(value.getString("question"), value.getString("mode") == "multiple", value.getBoolean("allow_custom_answer"),
options, value.getInt("voters"), value.getLong("ver"), mine.optJSONArray("choices").choices(), mine.optString("custom"), answers)
}
return FeedbackState(id, json.optBoolean("pending"), reactions, poll)
}
@@ -97,6 +97,7 @@ object NativeLib {
fun connectMemberNode(channelId: String, nodeId: Long) { nativeConnectMemberNode(channelId, nodeId) }
fun getChannels(): String = nativeGetChannels() ?: "[]"
fun getMessages(channelId: String, limit: Int = 50): String = nativeGetMessages(channelId, limit) ?: "[]"
fun feedbackCommand(json: String, requestId: Long = 0): Boolean = nativeFeedbackCommand(json.toByteArray(Charsets.UTF_8), requestId)
fun clearDatabase(): Boolean = nativeClearDatabase()
fun setDebugLevel(category: String, level: String) { nativeSetDebugLevel(category, level) }
@@ -237,6 +238,7 @@ object NativeLib {
private external fun nativeConnectMemberNode(channelId: String, nodeId: Long)
private external fun nativeGetChannels(): String?
private external fun nativeGetMessages(channelId: String, limit: Int): String?
private external fun nativeFeedbackCommand(json: ByteArray, requestId: Long): Boolean
private external fun nativeSetDebugLevel(category: String, level: String)
private external fun nativeSetChatSetting(name: String, value: String)
private external fun nativeGetChatSettingInt(name: String, def: Int): Int
@@ -37,7 +37,10 @@ fun ConversationMessages(target: ConversationTarget, messages: List<Message>, st
onPlayVoice: (String, Int) -> Unit, onSeekVoice: (String, Int, Int) -> Unit,
onOpenFile: (Message) -> Unit, onPlayVideo: (Message) -> Unit,
onDownloadFile: (Message) -> Unit = {}, onOpenImage: (Message) -> Unit = {},
onInviteLink: (String) -> Unit = {}) {
onInviteLink: (String) -> Unit = {}, reactionCatalog: List<com.utun.chat.data.ReactionState> = emptyList(),
onVote: (Message, List<Int>, String) -> Unit = { _, _, _ -> },
onReact: (Message, Int?) -> Unit = { _, _ -> },
authorName: (Long) -> String = { "0x%016x".format(it) }) {
val context = LocalContext.current
var openFailed by remember { mutableStateOf(false) }
val onLink: (String) -> Unit = { link ->
@@ -77,13 +80,18 @@ fun ConversationMessages(target: ConversationTarget, messages: List<Message>, st
}
LazyColumn(modifier = modifier.then(backgroundModifier), state = state, reverseLayout = true) {
item { Spacer(Modifier.height(8.dp)) }
items(messages.reversed(), key = { "${target.nativeId}:${it.isOutgoing}:${it.id}" }) { message ->
items(messages.reversed(), key = { "${target.nativeId}:${it.messageId.ifEmpty { "${it.isOutgoing}:${it.id}" }}" }) { message ->
val group = (target as? ConversationTarget.Channel)?.id
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) {
if (!message.isOutgoing) UserAvatar(group, message.authorNodeId, message.author,
modifier = Modifier.padding(start = 8.dp, bottom = 4.dp), size = 32.dp)
Box(Modifier.weight(1f)) {
MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile, onOpenFile, onOpenImage, onPlayVideo, onLink)
Column {
if (target is ConversationTarget.Channel && message.contentType == "poll") PollMessageBubble(message, onVote, authorName)
else MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile, onOpenFile, onOpenImage, onPlayVideo, onLink)
if (target is ConversationTarget.Channel && message.messageId.isNotEmpty())
MessageReactions(message, reactionCatalog, onReact)
}
}
if (message.isOutgoing) UserAvatar(group, message.authorNodeId, message.author,
modifier = Modifier.padding(end = 8.dp, bottom = 4.dp), size = 32.dp)
@@ -0,0 +1,141 @@
package com.utun.chat.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.utun.chat.R
import com.utun.chat.data.*
@Composable
fun CreatePollDialog(onDismiss: () -> Unit, onCreate: (String, List<String>, Boolean, Boolean) -> Unit) {
var question by rememberSaveable { mutableStateOf("") }
var variants by rememberSaveable { mutableStateOf("") }
var multiple by rememberSaveable { mutableStateOf(false) }
var custom by rememberSaveable { mutableStateOf(false) }
val options = variants.lines().map { it.trim() }.filter { it.isNotEmpty() }
val bytes = 6 + question.trim().toByteArray().size + options.sumOf { 2 + it.toByteArray().size }
val valid = question.isNotBlank() && question.trim().toByteArray().size <= 512 && options.size in 2..32 &&
options.distinct().size == options.size && options.all { it.toByteArray().size <= 256 } && bytes <= 2048
AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.poll_create)) },
text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(question, { question = it }, label = { Text(stringResource(R.string.poll_question)) })
OutlinedTextField(variants, { variants = it }, label = { Text(stringResource(R.string.poll_options_hint)) }, minLines = 3)
Row(Modifier.clickable { multiple = false }) {
RadioButton(!multiple, onClick = { multiple = false }); Text(stringResource(R.string.poll_single))
}
Row(Modifier.clickable { multiple = true }) {
RadioButton(multiple, onClick = { multiple = true }); Text(stringResource(R.string.poll_multiple))
}
Row(Modifier.clickable { custom = !custom }) {
Checkbox(custom, { custom = it }); Text(stringResource(R.string.poll_allow_custom))
}
if (!valid) Text(stringResource(R.string.poll_invalid_input), style = MaterialTheme.typography.bodySmall)
} },
confirmButton = { TextButton(enabled = valid, onClick = { onCreate(question.trim(), options, multiple, custom); onDismiss() }) {
Text(stringResource(R.string.poll_create))
} },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } })
}
@Composable
fun PollMessageBubble(message: Message, onVote: (Message, List<Int>, String) -> Unit, authorName: (Long) -> String) {
var showVote by rememberSaveable(message.messageId) { mutableStateOf(false) }
val poll = message.feedback?.poll
Card(onClick = { if (poll != null) showVote = true }, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
colors = CardDefaults.cardColors(containerColor = if (message.isOutgoing) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (!message.isOutgoing) Text(message.author, style = MaterialTheme.typography.labelMedium)
if (poll == null) Text(stringResource(R.string.poll_pending))
else {
Text(poll.question, style = MaterialTheme.typography.titleMedium)
Text(stringResource(if (poll.multiple) R.string.poll_multiple else R.string.poll_single),
style = MaterialTheme.typography.labelSmall)
for (option in poll.options) {
Text(option.text)
LinearProgressIndicator(progress = { (option.percent / 100).toFloat().coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth())
Text(stringResource(R.string.poll_option_result, option.count, option.percent),
style = MaterialTheme.typography.labelSmall)
}
Text(stringResource(R.string.poll_voters, poll.voters))
TextButton(onClick = { showVote = true }) { Text(stringResource(R.string.poll_vote_results)) }
}
}
}
if (showVote && poll != null) PollVoteDialog(message, poll, { showVote = false }, onVote, authorName)
}
@Composable
private fun PollVoteDialog(message: Message, poll: PollState, onDismiss: () -> Unit,
onVote: (Message, List<Int>, String) -> Unit, authorName: (Long) -> String) {
var selected by rememberSaveable(message.messageId) { mutableStateOf(ArrayList(poll.myChoices)) }
var custom by rememberSaveable(message.messageId) { mutableStateOf(poll.myCustom) }
var customSelected by rememberSaveable(message.messageId) { mutableStateOf(!poll.multiple && custom.isNotEmpty()) }
val valid = custom.toByteArray().size <= 1024 && (!customSelected || custom.isNotBlank())
AlertDialog(onDismissRequest = onDismiss, title = { Text(poll.question) },
text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
for (option in poll.options) {
val choose = {
customSelected = false
selected = if (!poll.multiple) arrayListOf(option.id)
else ArrayList(if (option.id in selected) selected.filter { it != option.id } else selected + option.id)
}
Row(Modifier.fillMaxWidth().clickable(onClick = choose)) {
if (poll.multiple) Checkbox(option.id in selected, onCheckedChange = { choose() })
else RadioButton(!customSelected && option.id in selected, onClick = choose)
Text(option.text)
}
}
if (poll.allowCustom) {
if (!poll.multiple) Row(Modifier.clickable { customSelected = true; selected = arrayListOf() }) {
RadioButton(customSelected, onClick = { customSelected = true; selected = arrayListOf() })
Text(stringResource(R.string.poll_own_answer))
}
OutlinedTextField(custom, { custom = it }, enabled = poll.multiple || customSelected, isError = !valid,
label = { Text(stringResource(R.string.poll_own_answer)) })
if (!valid) Text(stringResource(R.string.poll_answer_error), color = MaterialTheme.colorScheme.error)
}
Text(stringResource(R.string.poll_voters, poll.voters))
for (answer in poll.answers) {
val parts = answer.choices.mapNotNull { id -> poll.options.find { it.id == id }?.text } +
listOfNotNull(answer.custom.takeIf { it.isNotEmpty() })
Text("${authorName(answer.nodeId)}: ${parts.joinToString(", ")}", style = MaterialTheme.typography.bodySmall)
}
} },
confirmButton = { TextButton(enabled = valid, onClick = {
onVote(message, selected, if (poll.allowCustom && (poll.multiple || customSelected)) custom.trim() else ""); onDismiss()
}) { Text(stringResource(R.string.poll_save)) } },
dismissButton = { Row {
TextButton(onClick = { onVote(message, emptyList(), ""); onDismiss() }) { Text(stringResource(R.string.poll_withdraw)) }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) }
} })
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MessageReactions(message: Message, catalog: List<ReactionState>, onReact: (Message, Int?) -> Unit) {
var picker by rememberSaveable(message.messageId) { mutableStateOf(false) }
FlowRow(Modifier.padding(horizontal = 8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
for (reaction in message.feedback?.reactions.orEmpty()) {
FilterChip(selected = reaction.mine, onClick = { onReact(message, reaction.id) },
label = { Text("${reaction.emoji} ${reaction.count}") })
}
TextButton(onClick = { picker = true }) { Text(stringResource(R.string.poll_react)) }
}
if (picker) AlertDialog(onDismissRequest = { picker = false }, title = { Text(stringResource(R.string.poll_react)) },
text = { FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
for (reaction in catalog) TextButton(onClick = { onReact(message, reaction.id); picker = false }) { Text(reaction.emoji) }
} },
confirmButton = { TextButton(onClick = { onReact(message, null); picker = false }) {
Text(stringResource(R.string.poll_clear_reactions))
} },
dismissButton = { TextButton(onClick = { picker = false }) { Text(stringResource(R.string.close)) } })
}
@@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.Stroke
@@ -51,6 +52,7 @@ import com.utun.chat.data.Message
import com.utun.chat.data.RadioAudioEngine
import com.utun.chat.data.RadioOverlayService
import com.utun.chat.ui.components.ConversationMessages
import com.utun.chat.ui.components.CreatePollDialog
import com.utun.chat.ui.components.ConversationComposer
import com.utun.chat.ui.components.PortableRadioIcon
import com.utun.chat.ui.components.GroupAvatar
@@ -92,6 +94,13 @@ fun ChatScreen(
/* звук сообщений канала */
val soundOn by viewModel.soundOn.collectAsState()
val reactionCatalog by viewModel.reactionCatalog.collectAsState()
val interactionFailed by viewModel.interactionFailed.collectAsState()
var createPoll by rememberSaveable { mutableStateOf(false) }
if (createPoll) CreatePollDialog({ createPoll = false }, viewModel::createPoll)
if (interactionFailed) AlertDialog(onDismissRequest = viewModel::dismissInteractionError,
text = { Text(stringResource(R.string.poll_interaction_failed)) },
confirmButton = { TextButton(onClick = viewModel::dismissInteractionError) { Text(stringResource(R.string.close)) } })
/* save dialog for downloaded files */
var pendingSaveFilePath by remember { mutableStateOf<String?>(null) }
@@ -143,6 +152,7 @@ fun ChatScreen(
Box {
IconButton(onClick = { menu = true }) { Icon(Icons.Filled.MoreVert, stringResource(R.string.options)) }
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(text = { Text(stringResource(R.string.poll_create)) }, onClick = { menu = false; createPoll = true })
DropdownMenuItem(text = { Text(stringResource(R.string.invite)) },
leadingIcon = { Icon(Icons.Filled.Link, null) }, onClick = { menu = false; onInvite() })
DropdownMenuItem(text = { Text(stringResource(R.string.sound)) },
@@ -185,7 +195,8 @@ fun ChatScreen(
onSeekVoice = { path, position, duration -> viewModel.seekVoice(path, position, duration) },
onDownloadFile = { viewModel.downloadAttachment(it) },
onOpenFile = { if (it.filePath.isNotEmpty()) { pendingSaveFilePath = it.filePath; saveFileLauncher.launch(it.text) } },
onOpenImage = onViewPhoto, onPlayVideo = onPlayVideo, onInviteLink = viewModel::openInviteLink
onOpenImage = onViewPhoto, onPlayVideo = onPlayVideo, onInviteLink = viewModel::openInviteLink,
reactionCatalog = reactionCatalog, onVote = viewModel::vote, onReact = viewModel::react, authorName = viewModel::radioName
)
if (radioActive && !overlayShown) {
RadioPttButton(
@@ -21,6 +21,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import org.json.JSONObject
import org.json.JSONArray
private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
@@ -111,6 +113,12 @@ class ChatViewModel : ViewModel() {
private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages: StateFlow<List<Message>> = _messages
private val feedbackCache = mutableMapOf<String, FeedbackState>()
private val _reactionCatalog = MutableStateFlow<List<ReactionState>>(emptyList())
val reactionCatalog: StateFlow<List<ReactionState>> = _reactionCatalog
private val _interactionFailed = MutableStateFlow(false)
val interactionFailed: StateFlow<Boolean> = _interactionFailed
private var nextInteractionId = 0L
private val _currentChannel = MutableStateFlow<Channel?>(null)
val currentChannel: StateFlow<Channel?> = _currentChannel
@@ -284,6 +292,35 @@ class ChatViewModel : ViewModel() {
_dmConversations.value = _dmConversations.value.map { it.copy(online = false) }
} else if (type in listOf(4, 5, 12, 16, 17, 24, 25, 31, 33)) refreshDmCallRoute()
when (type) {
53 -> { /* FEEDBACK_CHANGED: channel, message_id, kind. */
if (data == null || data.size < 34) return
val size = data[0].toInt() and 255
if (data.size != size + 34) { LogManager.addLog("ERROR", "POLL", "invalid change event"); return }
val channel = String(data, 1, size, Charsets.UTF_8)
if (_currentChannel.value?.id != channel) return
val id = data.copyOfRange(size + 1, size + 33).joinToString("") { "%02x".format(it.toInt() and 255) }
sendInteraction(JSONObject().put("cmd", "feedback").put("ch", channel).put("message_id", id), false)
}
54 -> { /* FEEDBACK_RESULT: request_id:8LE, success, UTF-8 JSON. */
if (data == null || data.size < 9) return
try {
val id = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).long
val result = JSONObject(String(data, 9, data.size - 9, Charsets.UTF_8))
if (data[8].toInt() == 0) {
LogManager.addLog("ERROR", "POLL", "command failed: ${result.optString("error")}")
if (id != 0L) _interactionFailed.value = true
} else if (result.has("catalog")) {
val values = result.getJSONArray("catalog")
_reactionCatalog.value = (0 until values.length()).map { index -> values.getJSONObject(index).let {
ReactionState(it.getInt("id"), it.getString("emoji"))
} }
} else if (result.optString("ch") == _currentChannel.value?.id) {
val state = parseFeedback(result)
feedbackCache[state.messageId] = state
_messages.update { rows -> rows.map { if (it.messageId == state.messageId) it.copy(feedback = state) else it } }
}
} catch (e: Exception) { LogManager.addLog("ERROR", "POLL", "invalid result: ${e.message}"); _interactionFailed.value = true }
}
9 -> { /* DB_READY */
if (dbReady) return
repo = ChatRepository()
@@ -937,13 +974,45 @@ class ChatViewModel : ViewModel() {
fun refreshMessages(chId: String) {
val r = repo ?: return
viewModelScope.launch {
_messages.value = withContext(Dispatchers.IO) { r.getMessages(chId) }
val rows = withContext(Dispatchers.IO) { r.getMessages(chId) }
if (_currentChannel.value?.id != chId) return@launch
_messages.value = rows.map { it.copy(feedback = feedbackCache[it.messageId]) }
if (_reactionCatalog.value.isEmpty()) sendInteraction(JSONObject().put("cmd", "reaction_catalog"), false)
for (row in rows) if (row.messageId.length == 64)
sendInteraction(JSONObject().put("cmd", "feedback").put("ch", chId).put("message_id", row.messageId), false)
}
}
private fun sendInteraction(command: JSONObject, userAction: Boolean = true) {
if (!command.has("ch")) command.put("ch", _currentChannel.value?.id ?: return)
if (!NativeLib.feedbackCommand(command.toString(), if (userAction) ++nextInteractionId else 0)) {
LogManager.addLog("ERROR", "POLL", "cannot queue command ${command.optString("cmd")}")
if (userAction) _interactionFailed.value = true
}
}
fun createPoll(question: String, options: List<String>, multiple: Boolean, custom: Boolean) {
sendInteraction(JSONObject().put("cmd", "poll_create").put("question", question)
.put("options", JSONArray(options)).put("mode", if (multiple) "multiple" else "single").put("allow_custom_answer", custom))
}
fun vote(message: Message, choices: List<Int>, custom: String) {
sendInteraction(JSONObject().put("cmd", "vote").put("message_id", message.messageId)
.put("choices", JSONArray(choices)).put("custom", custom))
}
fun react(message: Message, emoji: Int?) {
val command = JSONObject().put("cmd", "react").put("message_id", message.messageId)
if (emoji == null) command.put("clear", true) else command.put("emoji", emoji)
sendInteraction(command)
}
fun dismissInteractionError() { _interactionFailed.value = false }
fun selectChannel(channel: Channel) {
val r = repo ?: return
if (_currentChannel.value?.id != channel.id) {
feedbackCache.clear()
memberDetailJob?.cancel(); memberDetailTarget = null; _selectedMember.value = null
}
_currentChannel.value = channel
@@ -1180,6 +1249,7 @@ class ChatViewModel : ViewModel() {
r.sendMessage(ch.id, text)
viewModelScope.launch {
_messages.value = withContext(Dispatchers.IO) { r.getMessages(ch.id) }
.map { it.copy(feedback = feedbackCache[it.messageId]) }
refreshChannels()
}
}