From 44e0b7849e94b14c8879c45dd663bdceac70e16c Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 22:14:29 +0200 Subject: [PATCH] Add Android poll voting, results and message reaction components --- .../ui/components/ConversationMessages.kt | 14 +- .../com/utun/chat/ui/components/PollUi.kt | 141 ++++++++++++++++++ 2 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt index 415e3c71..1ed6ad74 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt @@ -37,7 +37,10 @@ fun ConversationMessages(target: ConversationTarget, messages: List, 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 = emptyList(), + onVote: (Message, List, 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, 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) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt new file mode 100644 index 00000000..25a69fca --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt @@ -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, 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, 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, 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, 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)) } }) +}