From 24cb0eb463f8f54daee50d04207153b2786f5ce0 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 2 Nov 2026 21:02:14 +0100 Subject: [PATCH] Refine device group controls and aggregate status indicators --- doc/devgroup_tz.txt | 4 +- .../com/utun/chat/data/DevgroupRepository.kt | 10 +- .../utun/chat/ui/screens/DevgroupDialog.kt | 43 ++---- .../utun/chat/ui/screens/MemberListScreen.kt | 143 +++++++++++------- .../app/src/main/res/values-ru/strings.xml | 12 +- .../app/src/main/res/values/strings.xml | 12 +- tools/chatgui/src/accountdelegate.cpp | 74 ++++++--- tools/chatgui/src/accountdelegate.h | 11 +- tools/chatgui/src/accountlist.cpp | 32 ++-- tools/chatgui/src/devgroupdialog.cpp | 109 ++++++------- tools/chatgui/src/devgroupdialog.h | 7 +- tools/chatgui/src/memberlistmodel.cpp | 48 ++++-- tools/chatgui/src/memberlistmodel.h | 9 +- tools/chatgui/src/memberpropsdialog.cpp | 16 +- tools/chatgui/tests/test_invite_links.cpp | 11 +- tools/chatgui/tests/test_member_detail.cpp | 122 ++++++++++++++- tools/chatgui/tests/test_member_dialogs.cpp | 18 +++ .../chatgui/tests/test_radio_mute_bridge.cpp | 6 +- tools/chatgui/translations/chatgui_en.ts | 19 +-- tools/chatgui/translations/chatgui_ru.ts | 19 +-- 20 files changed, 442 insertions(+), 283 deletions(-) diff --git a/doc/devgroup_tz.txt b/doc/devgroup_tz.txt index 67a6f4ce5..0d422a7cd 100644 --- a/doc/devgroup_tz.txt +++ b/doc/devgroup_tz.txt @@ -14,7 +14,7 @@ X25519 для шифрования и Ed25519 для подписей. Публ Секреты хранятся отдельно от публичного профиля и не теряются при перезапуске; текущий local_identity перезаписывается при старте. 2. Приглашение. -A нажимает «Добавить моё устройство» в свойствах B: приглашение отправляется B, на A показывается PIN из 4 цифр. +A нажимает «Добавить устройство» в свойствах B: приглашение отправляется B, на A показывается PIN из 4 цифр. На B автоматически появляется диалог ввода PIN с экрана A; введённый PIN возвращается A для проверки. Обмен идёт через ETCP-router с обязательными сквозным шифрованием и подписью; BGP обеспечивает маршрут. Приглашение одноразовое, привязано к ключам B и случайному ID, действует 2 минуты, допускает 3 попытки. @@ -41,7 +41,7 @@ A передаёт ключи и профиль группы. B проверяе После восстановления связи и при вступлении в новую группу используется штатный обмен NODEINFO. 5. Выход. -Добровольный выход удаляет локальные ключи и публикует новый собственный снимок с пустым блоком devgroup и увеличенным timestamp. +Кнопка «Отключить от моих устройств» доступна в свойствах self. Выход удаляет локальные ключи и публикует новый собственный снимок с пустым блоком devgroup и увеличенным timestamp. Локальное состояние, снимок node_snapshots и поля nodes меняются атомарно; уведомления и распространение выполняются после commit. Старые сетевые записи не возвращают устройство в группу. Принудительный отзыв устройства со сменой ключей — отдельный этап. diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/DevgroupRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/DevgroupRepository.kt index 49f80e291..38668a38d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/DevgroupRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/DevgroupRepository.kt @@ -9,6 +9,7 @@ import java.nio.ByteOrder data class DeviceInvitation(val id: String, val peer: String, val outgoing: Boolean, val state: Int) data class DevgroupState(val name: String = "", val member: Boolean = false, val invitations: List = emptyList(), val pin: String = "", val failed: Boolean = false, val outgoingVisible: Boolean = false, + val leaving: Boolean = false, val leaveFailed: Boolean = false, val outgoingId: String = "", val accepting: String = "", val dismissed: Set = emptySet()) /** Только main thread; PIN хранится до закрытия диалога, входящие/результаты не пишутся в лог. */ @@ -21,14 +22,14 @@ object DevgroupRepository { if (action == 0 && pending.containsValue(0)) return if (action == 1) mutable.value = mutable.value.copy(outgoingVisible = true, outgoingId = "", pin = "", failed = false) if (action == 2) mutable.value = mutable.value.copy(accepting = text, failed = false) + if (action == 4) mutable.value = mutable.value.copy(leaving = true, leaveFailed = false) val id = ++nextId if (NativeLib.devgroupCommand(action, group, peer, text, pin, id)) pending[id] = action - else { LogManager.addLog("WARN", "Devgroup", "Core rejected request action=$action"); mutable.value = mutable.value.copy(failed = true, accepting = "") } + else { LogManager.addLog("WARN", "Devgroup", "Core rejected request action=$action"); mutable.value = mutable.value.copy(failed = true, accepting = "", leaving = false, leaveFailed = action == 4) } } - fun dismiss() { mutable.value = mutable.value.copy(failed = false) } + fun dismissLeaveError() { mutable.value = mutable.value.copy(leaveFailed = false) } fun closeOutgoing() { mutable.value = mutable.value.copy(outgoingVisible = false, outgoingId = "", pin = "", failed = false) } fun dismissIncoming(id: String) { mutable.value = mutable.value.copy(dismissed = mutable.value.dismissed + id, failed = false) } - fun reopenIncoming(id: String) { mutable.value = mutable.value.copy(dismissed = mutable.value.dismissed - id, failed = false) } fun onEvent(type: Int, data: ByteArray?) { if (type == 13) { pending.clear(); mutable.value = DevgroupState(); return } if (type == 12) { command(); return } @@ -36,7 +37,7 @@ object DevgroupRepository { if (type != 71 || data == null || data.size < 9) return val id = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).long val action = pending.remove(id) ?: return - if (data[8].toInt() != 1) { mutable.value = mutable.value.copy(failed = true, accepting = ""); return } + if (data[8].toInt() != 1) { mutable.value = mutable.value.copy(failed = true, accepting = "", leaving = false, leaveFailed = action == 4); return } val json = JSONObject(String(data, 9, data.size - 9, Charsets.UTF_8)) if (action == 1) { if (mutable.value.outgoingVisible) mutable.value = mutable.value.copy(pin = json.getString("pin"), outgoingId = json.getString("id"), failed = false) @@ -46,6 +47,7 @@ object DevgroupRepository { mutable.value = mutable.value.copy(name = json.getString("name"), member = json.getString("group").isNotEmpty(), invitations = List(invitations.length()) { i -> val entry = invitations.getJSONObject(i) DeviceInvitation(entry.getString("id"), entry.getString("peer"), entry.getInt("outgoing") != 0, entry.getInt("state")) }, + leaving = if (action == 4) false else mutable.value.leaving, accepting = if (action == 2) "" else mutable.value.accepting, failed = if (action == 0) mutable.value.failed else false) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DevgroupDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DevgroupDialog.kt index dcf485564..5c29e4860 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DevgroupDialog.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DevgroupDialog.kt @@ -1,8 +1,6 @@ package com.utun.chat.ui.screens 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.ui.Modifier @@ -12,36 +10,6 @@ import com.utun.chat.R import com.utun.chat.data.DevgroupRepository import kotlinx.coroutines.delay -@Composable -fun DevgroupDialog(channel: String, peer: Long = 0, onDismiss: () -> Unit) { - val state by DevgroupRepository.state.collectAsState() - var name by remember { mutableStateOf("") } - LaunchedEffect(Unit) { while (true) { DevgroupRepository.command(); delay(1000) } } - DisposableEffect(Unit) { onDispose { DevgroupRepository.dismiss() } } - AlertDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.devgroup_title)) }, - text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(if (state.member) state.name else stringResource(R.string.devgroup_none)) - OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text(stringResource(R.string.devgroup_name)) }) - TextButton(onClick = { DevgroupRepository.command(3, text = name) }) { Text(stringResource(R.string.devgroup_rename)) } - if (peer != 0L) TextButton(onClick = { DevgroupRepository.command(1, channel.toULongOrNull()?.toLong() ?: 0, peer) }) { - Text(stringResource(R.string.devgroup_add, peer.toULong().toString(16))) - } - if (state.failed) Text(stringResource(R.string.devgroup_failed), color = MaterialTheme.colorScheme.error) - for (entry in state.invitations) { - val label = when (entry.state) { - 1 -> R.string.devgroup_waiting; 2 -> R.string.devgroup_checking; 3 -> R.string.devgroup_transferring - 4 -> R.string.devgroup_joined; 5 -> R.string.devgroup_rejected; 6 -> R.string.devgroup_expired - else -> R.string.devgroup_unknown - } - Text(entry.peer + " — " + stringResource(label)) - if (!entry.outgoing && entry.state == 1) TextButton(onClick = { DevgroupRepository.reopenIncoming(entry.id) }) { - Text(stringResource(R.string.devgroup_enter_pin)) - } - } - if (state.member) TextButton(onClick = { DevgroupRepository.command(4) }) { Text(stringResource(R.string.devgroup_leave)) } - } }, confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.ok)) } }) -} - private fun deviceInvitationState(state: Int): Int = when (state) { 1 -> R.string.devgroup_waiting; 2 -> R.string.devgroup_checking; 3 -> R.string.devgroup_transferring 4 -> R.string.devgroup_joined; 5 -> R.string.devgroup_rejected; 6 -> R.string.devgroup_expired @@ -64,10 +32,17 @@ fun DevicePairingDialogs() { LaunchedEffect(state.outgoingVisible, current) { if (state.outgoingVisible || current.isNotEmpty()) while (true) { DevgroupRepository.command(); delay(1000) } } + if (state.leaveFailed) { + AlertDialog(onDismissRequest = DevgroupRepository::dismissLeaveError, + title = { Text(stringResource(R.string.devgroup_disconnect)) }, + text = { Text(stringResource(R.string.devgroup_failed)) }, + confirmButton = { TextButton(onClick = DevgroupRepository::dismissLeaveError) { Text(stringResource(android.R.string.ok)) } }) + return + } if (state.outgoingVisible) { val invitation = state.invitations.firstOrNull { it.id == state.outgoingId } AlertDialog(onDismissRequest = DevgroupRepository::closeOutgoing, - title = { Text(stringResource(R.string.devgroup_add_my_device)) }, + title = { Text(stringResource(R.string.devgroup_add_device)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { if (state.pin.isNotEmpty() && (invitation?.state ?: 1) < 4) Text(stringResource(R.string.devgroup_pin, state.pin)) Text(stringResource(deviceInvitationState(invitation?.state ?: 1))) @@ -80,7 +55,7 @@ fun DevicePairingDialogs() { val finished = invitation.state >= 4 val close = { DevgroupRepository.dismissIncoming(current); current = ""; Unit } AlertDialog(onDismissRequest = close, - title = { Text(stringResource(R.string.devgroup_add_my_device)) }, + title = { Text(stringResource(R.string.devgroup_add_device)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(invitation.peer) if (finished || busy) Text(stringResource(deviceInvitationState(invitation.state))) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index 981720a86..da3777301 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt @@ -22,6 +22,7 @@ import com.utun.chat.data.LogManager import androidx.compose.material.icons.automirrored.filled.Message import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Bolt @@ -69,7 +70,6 @@ fun MemberListScreen( ) { var showEditDialog by remember(channel.id) { mutableStateOf(false) } var editMember by remember(channel.id) { mutableStateOf(null) } - var devicesDialog by remember { mutableStateOf(false) } var expandedGroups by rememberSaveable(channel.id) { mutableStateOf(listOf()) } val devices by DevgroupRepository.state.collectAsState() val rows = remember(members, expandedGroups) { @@ -115,33 +115,42 @@ fun MemberListScreen( } ) - TextButton(onClick = { devicesDialog = true }) { - Text(stringResource(R.string.devgroup_title) + if (devices.invitations.any { !it.outgoing && it.state == 1 }) " •" else "") - } LazyColumn( modifier = Modifier .weight(1f) .fillMaxWidth() ) { items(rows, key = { if (it.second) "group:" + it.first.devgroup else "node:" + it.first.nodeId }) { (m, header) -> - if (header) { - val online = members.any { it.devgroup == m.devgroup && it.online && !it.isDeleted } - Text((if (m.devgroup in expandedGroups) "[−] " else "[+] ") + m.devgroupName, - color = if (online) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Bold, - modifier = Modifier.fillMaxWidth().clickable { - expandedGroups = if (m.devgroup in expandedGroups) expandedGroups - m.devgroup else expandedGroups + m.devgroup - }.padding(16.dp)) - } else MemberListItem( - member = m, + val accounts = if (header) members.filter { it.devgroup == m.devgroup && !it.isDeleted } else listOf(m) + val target = if (header) accounts.firstOrNull { it.online } else m + val device = target ?: m + val groupRadio = accounts.any { it.radioActive } + val groupPhoneKnown = accounts.any { it.phoneStatusKnown } + val groupPhoneBusy = accounts.any { it.phoneBusy } + LaunchedEffect(header, target?.nodeId, groupRadio, groupPhoneKnown, groupPhoneBusy) { + if (header) LogManager.addLog("DEBUG", "Members", "Device group: group=${m.devgroup} " + + "target=${target?.nodeId} radio=$groupRadio phoneKnown=$groupPhoneKnown phoneBusy=$groupPhoneBusy speakerColumn=right") + } + MemberListItem( + member = device, + avatarMember = m, + groupName = if (header) m.devgroupName else null, + expanded = m.devgroup in expandedGroups, + actionsEnabled = target != null, + radioEnabled = groupRadio, + phoneStatusKnown = groupPhoneKnown, + phoneBusy = groupPhoneBusy, + onToggleGroup = { + expandedGroups = if (m.devgroup in expandedGroups) expandedGroups - m.devgroup else expandedGroups + m.devgroup + }, group = channel.id, - speaking = radioActive && if (m.isSelf) transmittingGroup == groupId && groupId != null - else radioTalkState.groupId == groupId && m.nodeId in radioTalkState.speakers, - isSelected = selection?.nodeId == m.nodeId, - onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) }, - onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) }, - onRadioCall = { com.utun.chat.data.RadioCallController.start(channel.id, m.nodeId) }, - onRadioMute = { viewModel.toggleMemberRadioMute(channel.id, m.nodeId) } + speaking = target != null && radioActive && if (device.isSelf) transmittingGroup == groupId && groupId != null + else radioTalkState.groupId == groupId && device.nodeId in radioTalkState.speakers, + isSelected = !header && selection?.nodeId == device.nodeId, + onClick = { viewModel.showMemberDetail(channel.id, device.nodeId) }, + onCall = { target?.let { viewModel.startCall(channel.id, it.nodeId, it.name) } }, + onRadioCall = { target?.let { com.utun.chat.data.RadioCallController.start(channel.id, it.nodeId) } }, + onRadioMute = { target?.let { viewModel.toggleMemberRadioMute(channel.id, it.nodeId) } } ) } } @@ -160,7 +169,9 @@ fun MemberListScreen( isSelf = member?.isSelf == true, canEdit = member != null, onFiles = { onFiles(detail.nodeId) }, - onAddDevice = { DevgroupRepository.command(1, channel.id.toULong().toLong(), detail.nodeId) }, + deviceActionEnabled = member != null && !member.isDeleted && !devices.leaving && (!member.isSelf || devices.member), + onDeviceAction = { if (member?.isSelf == true) DevgroupRepository.command(4) + else DevgroupRepository.command(1, channel.id.toULong().toLong(), detail.nodeId) }, onInvite = { viewModel.showPmInvite(detail.nodeId, member?.name ?: detail.name, channel.id) }, onMessage = { viewModel.startDm(channel.id, detail.nodeId, member?.name ?: detail.name) }, onConnect = { @@ -179,9 +190,6 @@ fun MemberListScreen( } } - if (devicesDialog) DevgroupDialog(channel.id, selection?.nodeId?.takeIf { id -> members.any { it.nodeId == id && !it.isSelf } } ?: 0) { - devicesDialog = false - } if (radioMuteFailed) { AlertDialog( onDismissRequest = viewModel::dismissRadioMuteError, @@ -208,7 +216,9 @@ fun MemberListScreen( viewModel.setMemberFlags(channel.id, editingMember.nodeId, admin, supernode, moder, storage, verified, deleted) showEditDialog = false }, - onAddDevice = { DevgroupRepository.command(1, channel.id.toULong().toLong(), editingMember.nodeId) }, + canDisconnect = devices.member && !devices.leaving, + onDeviceAction = { if (editingMember.isSelf) DevgroupRepository.command(4) + else DevgroupRepository.command(1, channel.id.toULong().toLong(), editingMember.nodeId) }, onTransferAdmin = { viewModel.transferAdmin(channel.id, editingMember.nodeId) showEditDialog = false @@ -221,6 +231,14 @@ fun MemberListScreen( @Composable private fun MemberListItem( member: ChatMember, + avatarMember: ChatMember = member, + groupName: String? = null, + expanded: Boolean = false, + actionsEnabled: Boolean = true, + radioEnabled: Boolean = member.radioActive, + phoneStatusKnown: Boolean = member.phoneStatusKnown, + phoneBusy: Boolean = member.phoneBusy, + onToggleGroup: () -> Unit = {}, group: String, speaking: Boolean, isSelected: Boolean = false, @@ -229,6 +247,7 @@ private fun MemberListItem( onRadioCall: () -> Unit, onRadioMute: () -> Unit ) { + val header = groupName != null val onlineColor = when { member.connecting -> Color(0xFFFFEB3B) // yellow: connecting member.online -> Color(0xFF81C784) // green: reachable (direct/indirect/relay) @@ -239,7 +258,7 @@ private fun MemberListItem( Surface( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick), + .clickable(onClick = if (header) onToggleGroup else onClick), color = bgColor, tonalElevation = 0.dp ) { @@ -247,9 +266,15 @@ private fun MemberListItem( modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + if (header) IconButton(onClick = onToggleGroup, modifier = Modifier.size(width = 24.dp, height = 48.dp)) { + Icon(if (expanded) Icons.Filled.Remove else Icons.Filled.Add, + stringResource(if (expanded) R.string.devgroup_collapse else R.string.devgroup_expand), modifier = Modifier.size(18.dp)) + } else Spacer(Modifier.width(24.dp)) + } Box { - UserAvatar(group, member.nodeId, member.name) - if (member.isSelf) Text("✓", modifier = Modifier.align(Alignment.BottomEnd), + UserAvatar(group, avatarMember.nodeId, avatarMember.name) + if (member.isSelf && !header) Text("✓", modifier = Modifier.align(Alignment.BottomEnd), fontSize = 12.sp, color = Color(0xFF81C784)) else Box(Modifier.align(Alignment.BottomEnd).size(10.dp).clip(CircleShape).background(onlineColor)) } @@ -257,33 +282,33 @@ private fun MemberListItem( Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { - if (member.isSuper) { + if (!header && member.isSuper) { Icon(Icons.Filled.Bolt, stringResource(R.string.supernode), tint = Color(0xFFFFC107), modifier = Modifier.size(16.dp)) Spacer(Modifier.width(2.dp)) } - if (member.isAdmin) { + if (!header && member.isAdmin) { Icon(Icons.Filled.Star, stringResource(R.string.admin), tint = Color(0xFFFFD700), modifier = Modifier.size(16.dp)) Spacer(Modifier.width(2.dp)) } - if (member.isModer) { + if (!header && member.isModer) { Icon(Icons.Filled.Shield, stringResource(R.string.moderator), tint = Color(0xFF79B8FF), modifier = Modifier.size(16.dp)) Spacer(Modifier.width(2.dp)) } - if (member.isStorage) { + if (!header && member.isStorage) { Icon(Icons.Filled.Storage, stringResource(R.string.storage), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(2.dp)) } - if (!member.isVerified) { + if (!header && !member.isVerified) { Icon(Icons.Filled.Close, stringResource(R.string.not_verified), tint = Color.Red, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(2.dp)) } Text( - text = member.name, - fontWeight = FontWeight.Medium, + text = groupName ?: member.name, + fontWeight = if (header) FontWeight.Bold else FontWeight.Medium, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, - textDecoration = if (member.isDeleted) TextDecoration.LineThrough else TextDecoration.None, + textDecoration = if (!header && member.isDeleted) TextDecoration.LineThrough else TextDecoration.None, color = if (member.isSelf) Color(0xFF81C784) else Color.Unspecified, modifier = Modifier.weight(1f) ) @@ -297,7 +322,7 @@ private fun MemberListItem( else -> stringResource(R.string.offline) } Text( - text = connectionLabel, fontSize = 11.sp, + text = if (header && actionsEnabled) member.name + " · " + connectionLabel else connectionLabel, fontSize = 11.sp, color = if (member.isSelf || member.online) Color(0xFF81C784) else Color(0xFFAEBCC8) ) } @@ -306,27 +331,29 @@ private fun MemberListItem( if (member.radioMuted) " · " + stringResource(R.string.radio_member_muted) else "" CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { val actionModifier = Modifier.size(width = 36.dp, height = 48.dp) + if (if (header) actionsEnabled else !member.isSelf) { + IconButton(onClick = onRadioCall, enabled = !member.isSelf && actionsEnabled && member.online, modifier = actionModifier) { + val label = stringResource(R.string.radio_call_start) + + if (radioEnabled) " · " + stringResource(R.string.radio_active) else "" + PortableRadioIcon(label, tint = if (radioEnabled) Color(0xFF4CAF50) else LocalContentColor.current) + } + IconButton(onClick = onCall, enabled = !member.isSelf && actionsEnabled && member.online, modifier = actionModifier) { + val status = if (!member.online || !phoneStatusKnown) R.string.phone_status_unknown + else if (phoneBusy) R.string.phone_status_busy else R.string.phone_status_free + Icon(Icons.Filled.Call, stringResource(R.string.call) + " · " + stringResource(status), + tint = if (!member.online || !phoneStatusKnown) LocalContentColor.current + else if (phoneBusy) Color(0xFFE53935) else Color(0xFF4CAF50)) + } + } if (member.isSelf) { Box(actionModifier, contentAlignment = Alignment.Center) { RadioSpeakerIcon(speaking, false, radioStatus) } } else { - IconToggleButton(checked = member.radioMuted, onCheckedChange = { onRadioMute() }, modifier = actionModifier) { + IconToggleButton(checked = member.radioMuted, enabled = actionsEnabled, onCheckedChange = { onRadioMute() }, modifier = actionModifier) { RadioSpeakerIcon(speaking, member.radioMuted, radioStatus + ". " + stringResource(if (member.radioMuted) R.string.radio_member_listen else R.string.radio_member_mute)) } - IconButton(onClick = onRadioCall, enabled = member.online, modifier = actionModifier) { - val label = stringResource(R.string.radio_call_start) + - if (member.radioActive) " · " + stringResource(R.string.radio_active) else "" - PortableRadioIcon(label, tint = if (member.radioActive) Color(0xFF4CAF50) else LocalContentColor.current) - } - IconButton(onClick = onCall, enabled = member.online, modifier = actionModifier) { - val status = if (!member.online || !member.phoneStatusKnown) R.string.phone_status_unknown - else if (member.phoneBusy) R.string.phone_status_busy else R.string.phone_status_free - Icon(Icons.Filled.Call, stringResource(R.string.call) + " · " + stringResource(status), - tint = if (!member.online || !member.phoneStatusKnown) LocalContentColor.current - else if (member.phoneBusy) Color(0xFFE53935) else Color(0xFF4CAF50)) - } } } } @@ -347,7 +374,8 @@ private fun MemberDetailCard( localSocketStates: List, isSelf: Boolean = false, canEdit: Boolean, - onAddDevice: () -> Unit, + deviceActionEnabled: Boolean, + onDeviceAction: () -> Unit, onFiles: () -> Unit, onInvite: () -> Unit, onMessage: () -> Unit, @@ -444,8 +472,10 @@ private fun MemberDetailCard( } } + TextButton(onClick = onDeviceAction, enabled = deviceActionEnabled) { + Text(stringResource(if (isSelf) R.string.devgroup_disconnect else R.string.devgroup_add_device)) + } if (!isSelf) { - TextButton(onClick = onAddDevice) { Text(stringResource(R.string.devgroup_add_my_device)) } Row(verticalAlignment = Alignment.CenterVertically) { TextButton(onClick = onMessage) { Icon(Icons.AutoMirrored.Filled.Message, stringResource(R.string.message)) @@ -584,7 +614,8 @@ private fun MemberEditDialog( onDismiss: () -> Unit, onSave: (nick: String, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean, verified: Boolean, deleted: Boolean) -> Unit, - onAddDevice: () -> Unit, + canDisconnect: Boolean, + onDeviceAction: () -> Unit, onTransferAdmin: () -> Unit ) { var nick by remember { mutableStateOf(member.name) } @@ -610,8 +641,8 @@ private fun MemberEditDialog( modifier = Modifier.fillMaxWidth(), singleLine = true ) - if (!member.isSelf && !member.isDeleted) TextButton(onClick = onAddDevice) { - Text(stringResource(R.string.devgroup_add_my_device)) + TextButton(onClick = onDeviceAction, enabled = !member.isDeleted && (!member.isSelf || canDisconnect)) { + Text(stringResource(if (member.isSelf) R.string.devgroup_disconnect else R.string.devgroup_add_device)) } Spacer(Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index d25aca843..c945919b8 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -673,11 +673,6 @@ КиБ байт/с КиБ/с - Мои устройства - Нет группы устройств - Имя группы устройств - Создать / переименовать - Добавить устройство %1$s PIN: %1$s — введите его на другом устройстве в течение 2 минут. Операция не выполнена. Проверьте соединение и состояние приглашения. Ожидается PIN @@ -687,9 +682,10 @@ Отказ Истёк срок Результат неизвестен - Ввести PIN PIN с экрана приглашающего устройства - Выйти из группы устройств - Добавить моё устройство + Добавить устройство PIN не принят. Попробуйте ещё раз. + Отключить от моих устройств + Развернуть устройства + Свернуть устройства diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index f27cb8724..3aa460661 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -672,11 +672,6 @@ KiB B/s KiB/s - My devices - No device group - Device group name - Create / rename - Add device %1$s PIN: %1$s — enter it on the other device within 2 minutes. Operation failed. Check connection and invitation status. Waiting for PIN @@ -686,9 +681,10 @@ Rejected Expired Result unknown - Enter PIN PIN shown on the inviting device - Leave device group - Add my device + Add device PIN was not accepted. Try again. + Disconnect from my devices + Expand devices + Collapse devices diff --git a/tools/chatgui/src/accountdelegate.cpp b/tools/chatgui/src/accountdelegate.cpp index 11e61c649..8f0222dd5 100644 --- a/tools/chatgui/src/accountdelegate.cpp +++ b/tools/chatgui/src/accountdelegate.cpp @@ -16,7 +16,7 @@ extern "C" { } QRect AccountDelegate::actionRect(const QRect& row, Action action) { - const int offset = action == Action::RadioMute ? 95 : action == Action::RadioCall ? 63 : 31; + const int offset = action == Action::RadioMute ? 31 : action == Action::RadioCall ? 95 : 63; return QRect(row.right() - offset, row.center().y() - 14, 28, 28); } @@ -59,12 +59,13 @@ static void drawRadioMuteButton(QPainter* painter, const QRect& button, bool mut static QRect avatarRect(const QRect& row) { const int size = MemberListModel::kAvatarSize; - return QRect(row.left() + 4, row.top() + (row.height() - size) / 2, size, size); + return QRect(row.left() + GroupedMemberModel::DisclosureWidth + 4, row.top() + (row.height() - size) / 2, size, size); } -AccountDelegate::Action AccountDelegate::actionAt(const QPoint& position, const QRect& row, bool self) { +AccountDelegate::Action AccountDelegate::actionAt(const QPoint& position, const QRect& row, bool showCalls) { if (avatarRect(row).contains(position)) return Action::Avatar; - if (!self) for (Action action : {Action::RadioMute, Action::RadioCall, Action::Call}) + if (actionRect(row, Action::RadioMute).contains(position)) return Action::RadioMute; + if (showCalls) for (Action action : {Action::RadioCall, Action::Call}) if (actionRect(row, action).contains(position)) return action; return Action::None; } @@ -86,11 +87,14 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio else painter->fillRect(option.rect, option.palette.alternateBase()); - if (index.data(GroupedMemberModel::GroupHeaderRole).toBool()) { - QFont font = painter->font(); font.setBold(true); painter->setFont(font); - painter->setPen(index.data(MemberListModel::OnlineRole).toBool() ? QColor("#4CAF50") : option.palette.color(QPalette::Text)); - painter->drawText(option.rect.adjusted(8, 0, -8, 0), Qt::AlignVCenter, index.data().toString()); - painter->restore(); return; + const bool header = index.data(GroupedMemberModel::GroupHeaderRole).toBool(); + if (header) { + const QRectF box(option.rect.left() + 5, option.rect.center().y() - 7, 14, 14); + painter->setRenderHint(QPainter::Antialiasing); painter->setBrush(Qt::NoBrush); + painter->setPen(QPen(option.palette.color(QPalette::Text), 1.3)); painter->drawRoundedRect(box, 2, 2); + painter->drawLine(QPointF(box.left() + 3, box.center().y()), QPointF(box.right() - 3, box.center().y())); + if (!index.data(GroupedMemberModel::GroupExpandedRole).toBool()) + painter->drawLine(QPointF(box.center().x(), box.top() + 3), QPointF(box.center().x(), box.bottom() - 3)); } const int margin = 4; const int iconSz = MemberListModel::kAvatarSize; @@ -98,10 +102,15 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio QIcon icon = qvariant_cast(index.data(Qt::DecorationRole)); if (!icon.isNull()) icon.paint(painter, avatarRect(option.rect)); + if (header) { + const QRect avatar = avatarRect(option.rect); const QColor color(index.data(MemberListModel::OnlineRole).toBool() ? "#4CAF50" : "#9E9E9E"); + painter->setPen(QPen(color, 1)); painter->setBrush(color); painter->drawEllipse(QRect(avatar.right() - 8, avatar.bottom() - 8, 8, 8)); + } - const int x = option.rect.left() + iconSz + margin * 3; + const int x = option.rect.left() + GroupedMemberModel::DisclosureWidth + iconSz + margin * 3; const bool self = index.data(Qt::UserRole + 2).toBool(); - const int w = qMax(0, option.rect.right() - x - margin - (self ? 32 : 96)); + const bool online = index.data(MemberListModel::OnlineRole).toBool(), showCalls = header ? online : !self; + const int w = qMax(0, option.rect.right() - x - margin - (showCalls ? 96 : 32)); const int yMid = option.rect.top() + option.rect.height() / 2; uint8_t flags = (uint8_t)(index.data(Qt::UserRole + 3).toInt() & 0xFF); @@ -109,7 +118,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio QFont nameFont = painter->font(); nameFont.setBold(true); nameFont.setPixelSize(13); - if (flags & MEMBER_FLAG_DELETED) nameFont.setStrikeOut(true); + if (!header && (flags & MEMBER_FLAG_DELETED)) nameFont.setStrikeOut(true); painter->setFont(nameFont); QColor textColor = option.palette.color(QPalette::Text); @@ -119,7 +128,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio painter->save(); painter->setClipRect(QRect(x, option.rect.top(), w, option.rect.height()), Qt::IntersectClip); - const uint8_t visualFlags = flags & (MEMBER_FLAG_ADMIN | MEMBER_FLAG_MODER); + const uint8_t visualFlags = header ? 0 : flags & (MEMBER_FLAG_ADMIN | MEMBER_FLAG_MODER); int flagX = x; if (visualFlags) { FlagPainter::draw(painter, flags, flagX, option.rect.top() + (option.rect.height() - nameFont.pixelSize()) / 2, nameFont.pixelSize()); @@ -142,10 +151,12 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio Qt::AlignLeft | Qt::AlignTop, sub); painter->restore(); - drawRadioMuteButton(painter, actionRect(option.rect, self ? Action::Call : Action::RadioMute), flags & CHAT_MEMBER_FLAG_RADIO_MUTED, + const bool available = !header || index.data(MemberListModel::OnlineRole).toBool(); + painter->save(); if (!available) painter->setOpacity(0.35); + drawRadioMuteButton(painter, actionRect(option.rect, Action::RadioMute), flags & CHAT_MEMBER_FLAG_RADIO_MUTED, index.data(MemberListModel::RadioSpeakingRole).toBool()); - if (!self) { - const QColor color = index.data(MemberListModel::OnlineRole).toBool() ? textColor : + if (showCalls) { + const QColor color = online ? option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text) : option.palette.color(QPalette::Disabled, QPalette::Text); const QRect radio = actionRect(option.rect, Action::RadioCall); const QColor radioColor = flags & CHAT_MEMBER_FLAG_RADIO ? QColor("#4CAF50") : color; @@ -155,12 +166,12 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio drawCallButton(painter, actionRect(option.rect, Action::Call), phoneColor); } painter->restore(); + painter->restore(); } bool AccountDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option, const QModelIndex& index) { Q_UNUSED(model); - if (index.data(GroupedMemberModel::GroupHeaderRole).toBool()) return false; if (event->type() == QEvent::MouseMove) { const auto* mouse = static_cast(event); if (m_pressedIndex.isValid() && (!(mouse->buttons() & Qt::LeftButton) || @@ -175,44 +186,57 @@ bool AccountDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, cons event->type() != QEvent::MouseButtonDblClick) return false; auto* mouse = static_cast(event); if (mouse->button() != Qt::LeftButton) return false; - const Action action = actionAt(mouse->pos(), option.rect, index.data(Qt::UserRole + 2).toBool()); - const bool enabled = action == Action::Avatar || action == Action::RadioMute || index.data(MemberListModel::OnlineRole).toBool(); + const bool header = index.data(GroupedMemberModel::GroupHeaderRole).toBool(); + const bool self = index.data(Qt::UserRole + 2).toBool(), online = index.data(MemberListModel::OnlineRole).toBool(); + Action action = actionAt(mouse->pos(), option.rect, header ? online : !self); + if (header && action == Action::None) action = Action::ToggleGroup; + const bool enabled = action == Action::Avatar || action == Action::ToggleGroup || + (!self && (!header || online) && (action == Action::RadioMute || online)); if (event->type() == QEvent::MouseButtonPress) { m_pressedIndex = action != Action::None && enabled ? index : QModelIndex(); + m_pressedNodeId = index.data(GroupedMemberModel::ActionNodeIdRole).toULongLong(); m_pressedAction = action; m_pressedPosition = mouse->pos(); return action != Action::None; } if (event->type() == QEvent::MouseButtonDblClick) return action != Action::None; const bool handled = action != Action::None || m_pressedIndex.isValid(); const bool activate = enabled && action != Action::None && action == m_pressedAction && m_pressedIndex == index && + m_pressedNodeId == index.data(GroupedMemberModel::ActionNodeIdRole).toULongLong() && (mouse->pos() - m_pressedPosition).manhattanLength() < ClickDistance; m_pressedIndex = QPersistentModelIndex(); m_pressedAction = Action::None; if (activate) { - GUI_DEBUG("AccountDelegate: action activated row=%d action=%d", index.row(), int(action)); + GUI_DEBUG("AccountDelegate: action activated row=%d group=%d action=%d target=%016llx online=%d flags=%02x", + index.row(), header, int(action), (unsigned long long)m_pressedNodeId, online, index.data(Qt::UserRole + 3).toInt()); if (action == Action::Avatar) emit avatarRequested(index); else if (action == Action::RadioMute) emit radioMuteRequested(index); else if (action == Action::RadioCall) emit radioCallRequested(index); else if (action == Action::Call) emit callRequested(index); + else if (action == Action::ToggleGroup) emit groupToggleRequested(index); } return handled; } bool AccountDelegate::helpEvent(QHelpEvent* event, QAbstractItemView* view, const QStyleOptionViewItem& option, const QModelIndex& index) { - if (index.data(GroupedMemberModel::GroupHeaderRole).toBool()) return false; + if (index.data(GroupedMemberModel::GroupHeaderRole).toBool() && event && + event->pos().x() < option.rect.left() + GroupedMemberModel::DisclosureWidth) { + QToolTip::showText(event->globalPos(), QCoreApplication::translate("ChatGui", + index.data(GroupedMemberModel::GroupExpandedRole).toBool() ? "Collapse devices" : "Expand devices"), view); return true; + } const bool self = index.data(Qt::UserRole + 2).toBool(); - if (event && actionRect(option.rect, self ? Action::Call : Action::RadioMute).contains(event->pos())) { + const bool header = index.data(GroupedMemberModel::GroupHeaderRole).toBool(), online = index.data(MemberListModel::OnlineRole).toBool(); + if (event && actionRect(option.rect, Action::RadioMute).contains(event->pos())) { const bool muted = index.data(Qt::UserRole + 3).toInt() & CHAT_MEMBER_FLAG_RADIO_MUTED; const bool speaking = index.data(MemberListModel::RadioSpeakingRole).toBool(); QString text = QCoreApplication::translate("ChatGui", speaking ? "Radio: speaking" : "Radio: silent"); if (muted) text += " · " + QCoreApplication::translate("ChatGui", "Radio: muted"); - if (!index.data(Qt::UserRole + 2).toBool()) text += '\n' + QCoreApplication::translate("ChatGui", + if (!self && (!header || online)) text += '\n' + QCoreApplication::translate("ChatGui", muted ? "Listen to this member in radio" : "Mute this member in radio"); QToolTip::showText(event->globalPos(), text, view); return true; } - if (event && !self) { - const Action action = actionAt(event->pos(), option.rect, false); + if (event && (header ? online : !self)) { + const Action action = actionAt(event->pos(), option.rect, true); if (action == Action::RadioCall || action == Action::Call) { QString text = QCoreApplication::translate("ChatGui", action == Action::RadioCall ? "Call by radio" : "Call peer"); if (action == Action::RadioCall && (index.data(Qt::UserRole + 3).toInt() & CHAT_MEMBER_FLAG_RADIO)) diff --git a/tools/chatgui/src/accountdelegate.h b/tools/chatgui/src/accountdelegate.h index 4818744ab..b6be087dc 100644 --- a/tools/chatgui/src/accountdelegate.h +++ b/tools/chatgui/src/accountdelegate.h @@ -3,7 +3,10 @@ * UserRole+1 — подпись, +2 — self, +3 — CHAT_MEMBER_FLAG_*, +4 — storage; * MemberListModel::OnlineRole/RadioSpeakingRole определяют доступность и индикацию. * Аватар доступен всем, mute — чужим участникам независимо от online, вызовы — online-пирам. - * Собственная строка показывает состояние рации без кнопок mute/вызова. + * Динамик всегда справа. Собственная строка показывает состояние рации без кнопок mute/вызова. + * Под раскрытие зарезервирована общая колонка; заголовок группы получает аватар и действия + * через GroupedMemberModel. Трубка/рация группы видны при наличии online-устройства, + * в том числе self; вызовы себе недоступны. Их состояния общие для группы. * Сигнал только при LeftButton release в том же действии/строке после press: * MouseMove со смещением >=5 по Manhattan или без LeftButton отменяет жест, включая возврат курсора назад. */ #pragma once @@ -30,12 +33,14 @@ signals: void callRequested(const QModelIndex& index); void avatarRequested(const QModelIndex& index); void radioMuteRequested(const QModelIndex& index); + void groupToggleRequested(const QModelIndex& index); private: static constexpr int ClickDistance = 5; - enum class Action { None, Avatar, RadioMute, RadioCall, Call }; + enum class Action { None, Avatar, RadioMute, RadioCall, Call, ToggleGroup }; static QRect actionRect(const QRect& row, Action action); - static Action actionAt(const QPoint& position, const QRect& row, bool self); + static Action actionAt(const QPoint& position, const QRect& row, bool showCalls); QPersistentModelIndex m_pressedIndex; /* строка, на которой начался клик */ QPoint m_pressedPosition; // координаты viewport при press + quint64 m_pressedNodeId = 0; // смена online-устройства между press/release отменяет действие Action m_pressedAction = Action::None; // ожидаемое действие; сбрасывается при release/отмене жеста }; diff --git a/tools/chatgui/src/accountlist.cpp b/tools/chatgui/src/accountlist.cpp index baf1c792a..a03251cc3 100644 --- a/tools/chatgui/src/accountlist.cpp +++ b/tools/chatgui/src/accountlist.cpp @@ -84,41 +84,44 @@ AccountList::AccountList(DbManager* db, QWidget *parent) m_header->setAlignment(Qt::AlignCenter); m_header->setStyleSheet("font-weight: bold; padding: 8px; background: palette(window);"); layout->addWidget(m_header); - auto* devices = new QPushButton(tr("My devices"), this); layout->addWidget(devices); - watchDevgroupButton(devices); - connect(devices, &QPushButton::clicked, this, [this, devices] { devices->setText(tr("My devices")); showDevgroupDialog(this, m_channelId); }); m_grouped = new GroupedMemberModel(m_model, this); m_listView->setModel(m_grouped); auto* delegate = new AccountDelegate(m_listView); m_listView->setItemDelegate(delegate); + connect(delegate, &AccountDelegate::groupToggleRequested, this, [this](const QModelIndex& index) { + if (m_grouped->toggle(index.row())) clearMemberDetail(); + }); connect(delegate, &AccountDelegate::radioMuteRequested, this, [this](const QModelIndex& index) { - if (gui_bridge_toggle_member_radio_mute(m_channelId.toUtf8().constData(), m_model->nodeIdAt(m_grouped->sourceRow(index.row()))) < 0) + const quint64 peer = m_model->nodeIdAt(m_grouped->actionSourceRow(index.row())); + if (!peer) return; + if (gui_bridge_toggle_member_radio_mute(m_channelId.toUtf8().constData(), peer) < 0) QMessageBox::warning(this, QCoreApplication::translate("ChatGui", "Error"), QCoreApplication::translate("ChatGui", "Cannot change radio listening setting")); }); connect(delegate, &AccountDelegate::avatarRequested, this, [this](const QModelIndex& index) { - showAvatarGallery(m_channelId, m_model->nodeIdAt(m_grouped->sourceRow(index.row()))); + showAvatarGallery(m_channelId, m_model->nodeIdAt(m_grouped->avatarSourceRow(index.row()))); }); connect(delegate, &AccountDelegate::radioCallRequested, this, [this](const QModelIndex& index) { - const quint64 peer = m_model->nodeIdAt(m_grouped->sourceRow(index.row())); - if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(m_grouped->sourceRow(index.row()))) { + const int source = m_grouped->actionSourceRow(index.row()); const quint64 peer = m_model->nodeIdAt(source); + if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(source)) { DEBUG_WARN(DEBUG_CATEGORY_RADIO, "member radio call ignored: peer=%016llx online=%d", - (unsigned long long)peer, m_model->isOnlineAt(m_grouped->sourceRow(index.row()))); return; + (unsigned long long)peer, m_model->isOnlineAt(source)); return; } DEBUG_INFO(DEBUG_CATEGORY_RADIO, "member radio call clicked ch=%s peer=%016llx", qPrintable(m_channelId), (unsigned long long)peer); emit radioCallRequested(m_channelId, peer); }); connect(delegate, &AccountDelegate::callRequested, this, [this](const QModelIndex& index) { - const quint64 peer = m_model->nodeIdAt(m_grouped->sourceRow(index.row())); - if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(m_grouped->sourceRow(index.row()))) { + const int source = m_grouped->actionSourceRow(index.row()); const quint64 peer = m_model->nodeIdAt(source); + if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(source)) { DEBUG_WARN(DEBUG_CATEGORY_CALL, "member call ignored: peer=%016llx online=%d", - (unsigned long long)peer, m_model->isOnlineAt(m_grouped->sourceRow(index.row()))); return; + (unsigned long long)peer, m_model->isOnlineAt(source)); return; } DEBUG_INFO(DEBUG_CATEGORY_CALL, "member call clicked ch=%s peer=%016llx", qPrintable(m_channelId), (unsigned long long)peer); - emit callRequested(m_channelId, peer, index.data(Qt::DisplayRole).toString()); + const auto member = m_model->member(peer); + emit callRequested(m_channelId, peer, member ? QString::fromUtf8(member->name) : QString()); }); m_listView->setEditTriggers(QAbstractItemView::NoEditTriggers); m_listView->setSelectionMode(QAbstractItemView::SingleSelection); @@ -223,8 +226,7 @@ void AccountList::setChannel(const QString& channelId) { void AccountList::refresh() { m_model->refresh(); } void AccountList::onMemberClicked(const QModelIndex& index) { - if (!index.isValid()) return; - if (m_grouped->toggle(index.row())) { clearMemberDetail(); return; } + if (!index.isValid() || index.data(GroupedMemberModel::GroupHeaderRole).toBool()) return; quint64 nodeId = m_model->nodeIdAt(m_grouped->sourceRow(index.row())); if (nodeId) showMemberDetail(nodeId); } @@ -537,7 +539,6 @@ void AccountList::onMemberContextMenu(const QPoint& pos) { QPointer alive(this); QMenu menu; connect(this, &QObject::destroyed, &menu, &QMenu::close); - QAction* deviceAction = menu.addAction(tr("Add to my devices")); deviceAction->setEnabled(!self); QAction* pmAction = menu.addAction(QCoreApplication::translate("ChatGui", "Send message")); pmAction->setEnabled(!self); QAction* inviteAction = menu.addAction(QCoreApplication::translate("ChatGui", "Invite to group")); @@ -575,7 +576,6 @@ void AccountList::onMemberContextMenu(const QPoint& pos) { if (chosen == filesAction) { emit filesRequested(channel, nodeId); return; } if (chosen == pmAction) { emit pmRequested(channel, nodeId, name); return; } if (chosen == callAction) { emit callRequested(channel, nodeId, name); return; } - if (chosen == deviceAction) { inviteMyDevice(this, channel, nodeId); return; } if (chosen == connectAction) { auto* request = static_cast(u_calloc(1, sizeof(chat_connect_node_req))); if (!request) { DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member connect request allocation failed"); return; } diff --git a/tools/chatgui/src/devgroupdialog.cpp b/tools/chatgui/src/devgroupdialog.cpp index 08a0a06d0..12a531066 100644 --- a/tools/chatgui/src/devgroupdialog.cpp +++ b/tools/chatgui/src/devgroupdialog.cpp @@ -1,5 +1,4 @@ #include -#include #include "devgroupdialog.h" #include "../transport/gui_bridge.h" #include "debug_ui.h" @@ -8,7 +7,6 @@ #include #include #include -#include #include #include #include @@ -16,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -25,7 +24,7 @@ extern "C" { } class DevgroupDialog; static QPointer active; -static QList> buttons; +static QHash> leaveRequests; static quint64 nextRequest = 0; static QPointer invitationWindow; static quint64 statusRequest = 0, lastAutomaticSnapshot = 0; @@ -39,35 +38,19 @@ class DevgroupDialog : public QDialog { public: static QString tr(const char* text) { return QCoreApplication::translate("DevgroupDialog", text); } void refresh() { send(CHAT_DEVGROUP_STATUS); } - DevgroupDialog(QWidget* parent, const QString& channel, quint64 peer, bool inviting = false) : QDialog(parent), channel(channel), peer(peer), inviting(inviting) { - setAttribute(Qt::WA_DeleteOnClose); setModal(inviting); setWindowTitle(inviting ? tr("Add my device") : tr("My devices")); resize(480, 360); + DevgroupDialog(QWidget* parent, const QString& channel, quint64 peer) : QDialog(parent), channel(channel), peer(peer) { + setAttribute(Qt::WA_DeleteOnClose); setModal(true); setWindowTitle(tr("Add device")); resize(420, 140); auto* layout = new QVBoxLayout(this); - title = new QLabel(this); layout->addWidget(title); - name = new QLineEdit(this); name->setPlaceholderText(tr("Device group name")); layout->addWidget(name); - auto* rename = new QPushButton(tr("Create / rename"), this); layout->addWidget(rename); - connect(rename, &QPushButton::clicked, this, [this] { send(CHAT_DEVGROUP_RENAME, name->text()); }); - if (peer && !inviting) { - auto* invite = new QPushButton(tr("Add device %1").arg(QString::number(peer, 16)), this); layout->addWidget(invite); - connect(invite, &QPushButton::clicked, this, [this] { send(CHAT_DEVGROUP_INVITE); }); - } - status = new QLabel(this); status->setWordWrap(true); status->setTextInteractionFlags(Qt::TextSelectableByMouse); layout->addWidget(status); - list = new QListWidget(this); layout->addWidget(list); - auto* accept = new QPushButton(tr("Enter PIN for selected invitation"), this); layout->addWidget(accept); - connect(accept, &QPushButton::clicked, this, [this] { - const auto* item = list->currentItem(); if (!item || item->data(Qt::UserRole).toString().isEmpty()) return; - const QString id = item->data(Qt::UserRole).toString(); bool ok = false; - const QString pin = QInputDialog::getText(this, tr("Add device"), tr("PIN shown on the inviting device"), QLineEdit::Normal, {}, &ok); - if (ok) send(CHAT_DEVGROUP_ACCEPT, id, pin); - }); - auto* leave = new QPushButton(tr("Leave device group"), this); layout->addWidget(leave); - connect(leave, &QPushButton::clicked, this, [this] { send(CHAT_DEVGROUP_LEAVE); }); + status = new QLabel(tr("Waiting for PIN"), this); status->setWordWrap(true); + status->setTextInteractionFlags(Qt::TextSelectableByMouse); layout->addWidget(status); + auto* close = new QDialogButtonBox(QDialogButtonBox::Close, this); layout->addWidget(close); + connect(close, &QDialogButtonBox::rejected, this, &QDialog::close); auto* timer = new QTimer(this); timer->setInterval(1000); - connect(timer, &QTimer::timeout, this, [this] { if (!gui_bridge_is_uasync_ready()) pending.clear(); - else if (!pending.values().contains(CHAT_DEVGROUP_STATUS)) send(CHAT_DEVGROUP_STATUS); }); - if (inviting) { - title->hide(); name->hide(); rename->hide(); list->hide(); accept->hide(); leave->hide(); - } - timer->start(); send(inviting ? CHAT_DEVGROUP_INVITE : CHAT_DEVGROUP_STATUS); + connect(timer, &QTimer::timeout, this, [this] { + if (!gui_bridge_is_uasync_ready()) { pending.clear(); status->setText(tr("Core unavailable")); } + else if (!pending.values().contains(CHAT_DEVGROUP_STATUS)) refresh(); + }); + timer->start(); send(CHAT_DEVGROUP_INVITE); } void result(const uint8_t* data, int len) { if (len < 9) return; @@ -80,52 +63,45 @@ public: outgoingId = json["id"].toString(); pinText = tr("PIN: %1 — enter it on the other device within 2 minutes.").arg(json["pin"].toString()); status->setText(pinText); return; } - title->setText(json["group"].toString().isEmpty() ? tr("No device group") : json["name"].toString()); - const QString selected = list->currentItem() ? list->currentItem()->data(Qt::UserRole).toString() : QString(); - list->clear(); const QStringList states = {{}, tr("Waiting for PIN"), tr("Checking PIN"), tr("Transferring keys"), tr("Joined"), tr("Rejected"), tr("Expired"), tr("Result unknown")}; for (const auto value : json["invitations"].toArray()) { const auto invitation = value.toObject(); const int state = invitation["state"].toInt(); - if (inviting && invitation["id"].toString() == outgoingId) { - status->setText((state < 4 ? pinText + "\n" : QString()) + states.value(state)); - } - auto* item = new QListWidgetItem(invitation["peer"].toString() + " — " + states.value(state), list); - if (!invitation["outgoing"].toInt() && state == 1) { - item->setData(Qt::UserRole, invitation["id"].toString()); - if (item->data(Qt::UserRole).toString() == selected) list->setCurrentItem(item); - } + if (invitation["id"].toString() == outgoingId) status->setText((state < 4 ? pinText + "\n" : QString()) + states.value(state)); } } private: - void send(chat_devgroup_action action, const QString& text = {}, const QString& pin = {}) { - const auto bytes = text.toUtf8(), code = pin.toUtf8(); - if (bytes.size() >= 128 || bytes.contains('\0') || code.size() >= 5 || code.contains('\0')) { - status->setText(tr("Invalid name or PIN")); return; - } + void send(chat_devgroup_action action) { auto* req = static_cast(u_calloc(1, sizeof(chat_devgroup_request))); if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup UI: allocation failed"); return; } req->inst = gui_bridge_get_inst(); req->request_id = ++nextRequest; req->group = channel.toULongLong(); req->peer = peer; - req->action = action; memcpy(req->text, bytes.constData(), bytes.size()); memcpy(req->pin, code.constData(), code.size()); - const quint64 id = req->request_id; + req->action = action; const quint64 id = req->request_id; if (gui_bridge_post_uasync_owned_fn(chat_devgroup_trampoline, req) < 0) { - memset(req, 0, sizeof(*req)); u_free(req); status->setText(tr("Core unavailable")); - DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: core unavailable"); return; + u_free(req); status->setText(tr("Core unavailable")); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: core unavailable"); return; } pending.insert(id, action); } - QString channel, outgoingId, pinText; quint64 peer; bool inviting; - QLabel *title, *status; QLineEdit* name; QListWidget* list; + QString channel, outgoingId, pinText; quint64 peer; + QLabel* status; QHash pending; }; static void devgroupResult(const uint8_t* data, int len) { if (!len) { refreshInvitations(); - for (const auto& button : buttons) if (button) button->setText(QCoreApplication::translate("AccountList", "My devices") + " •"); if (active) active->refresh(); } else { if (len >= 9) { quint64 id = 0; for (int i = 0; i < 8; ++i) id |= quint64(data[i]) << (8*i); + if (leaveRequests.contains(id)) { + const auto button = leaveRequests.take(id); + if (!data[8]) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: disconnect refused"); + if (button) { button->setEnabled(true); QMessageBox::warning(button->window(), + QCoreApplication::translate("ChatGui", "Disconnect from my devices"), + DevgroupDialog::tr("Operation failed. Check connection and invitation status.")); } + } else DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup UI: own device disconnected"); + return; + } const bool automatic = id == statusRequest || acceptRequests.contains(id); if (id == statusRequest) statusRequest = 0; if (acceptRequests.contains(id)) awaitingPins.remove(acceptRequests.take(id)); @@ -140,16 +116,6 @@ static void devgroupResult(const uint8_t* data, int len) { if (active) active->result(data, len); } } -void watchDevgroupButton(QPushButton* button) { - buttons.erase(std::remove_if(buttons.begin(), buttons.end(), [](const QPointer& b) { return b.isNull(); }), buttons.end()); - buttons.append(button); gui_bridge_set_devgroup_result_cb(devgroupResult); -} -void showDevgroupDialog(QWidget* parent, const QString& channel, quint64 peer) { - if (active) { active->close(); active = nullptr; } - gui_bridge_set_devgroup_result_cb(devgroupResult); - active = new DevgroupDialog(parent, channel, peer); active->show(); -} - static void refreshInvitations() { if (!invitationWindow || statusRequest || !gui_bridge_is_uasync_ready()) return; auto* req = static_cast(u_calloc(1, sizeof(chat_devgroup_request))); @@ -171,7 +137,7 @@ static void automaticInvitations(const QJsonObject& snapshot) { if (pinDialogs.value(id) || dismissedInvites.contains(id) || awaitingPins.contains(id)) continue; auto* dialog = new QInputDialog(invitationWindow); pinDialogs.insert(id, dialog); dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->setObjectName("devgroupIncomingPin"); - dialog->setWindowTitle(DevgroupDialog::tr("Add my device")); + dialog->setWindowTitle(DevgroupDialog::tr("Add device")); dialog->setLabelText(entry["peer"].toString() + "\n" + DevgroupDialog::tr(attemptedPins.contains(id) ? "PIN was not accepted. Try again." : "PIN shown on the inviting device")); dialog->setInputMode(QInputDialog::TextInput); dialog->setWindowModality(Qt::ApplicationModal); @@ -222,5 +188,18 @@ void initDevgroupUi(QWidget* window) { void inviteMyDevice(QWidget* parent, const QString& channel, quint64 peer) { if (active) { active->close(); active = nullptr; } gui_bridge_set_devgroup_result_cb(devgroupResult); - active = new DevgroupDialog(parent, channel, peer, true); active->show(); + active = new DevgroupDialog(parent, channel, peer); active->show(); +} + +void disconnectMyDevice(QPushButton* button) { + auto* req = static_cast(u_calloc(1, sizeof(chat_devgroup_request))); + if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup UI: disconnect allocation failed"); return; } + req->inst = gui_bridge_get_inst(); req->action = CHAT_DEVGROUP_LEAVE; req->request_id = ++nextRequest; + const quint64 id = req->request_id; gui_bridge_set_devgroup_result_cb(devgroupResult); + if (gui_bridge_post_uasync_owned_fn(chat_devgroup_trampoline, req) < 0) { + u_free(req); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: disconnect enqueue failed"); + QMessageBox::warning(button->window(), QCoreApplication::translate("ChatGui", "Disconnect from my devices"), + DevgroupDialog::tr("Core unavailable")); return; + } + leaveRequests.insert(id, button); button->setEnabled(false); } diff --git a/tools/chatgui/src/devgroupdialog.h b/tools/chatgui/src/devgroupdialog.h index e6bd34137..81a15418b 100644 --- a/tools/chatgui/src/devgroupdialog.h +++ b/tools/chatgui/src/devgroupdialog.h @@ -1,4 +1,4 @@ -/* Локальное управление devgroup. Все операции передаются в uasync; секреты не читаются GUI. */ +/* Диалоги сопряжения устройств и отключение self. Все операции передаются в uasync; секреты не читаются GUI. */ #ifndef DEVGROUPDIALOG_H #define DEVGROUPDIALOG_H #include @@ -9,7 +9,6 @@ class QPushButton; void initDevgroupUi(QWidget* window); /* Сразу создать приглашение выбранному устройству и показать PIN. */ void inviteMyDevice(QWidget* parent, const QString& channel, quint64 peer); -/* Помечать кнопку при изменении приглашений; указатель отслеживается через QPointer. */ -void watchDevgroupButton(QPushButton* button); -void showDevgroupDialog(QWidget* parent, const QString& channel, quint64 peer = 0); +/* Отключить self; кнопка блокируется до результата, при отказе восстанавливается и показывается ошибка. */ +void disconnectMyDevice(QPushButton* button); #endif diff --git a/tools/chatgui/src/memberlistmodel.cpp b/tools/chatgui/src/memberlistmodel.cpp index 78d828fcc..ebb6aa221 100644 --- a/tools/chatgui/src/memberlistmodel.cpp +++ b/tools/chatgui/src/memberlistmodel.cpp @@ -350,24 +350,44 @@ bool GroupedMemberModel::toggle(int row) { if (row < 0 || row >= m_rows.size() || m_rows[row] >= 0) return false; const auto group = memberGroup(m_source, -m_rows[row]-1); if (!m_expanded.remove(group)) m_expanded.insert(group); + GUI_DEBUG("Device group: toggled group=%s expanded=%d", group.toHex().constData(), m_expanded.contains(group)); rebuild(); return true; } +int GroupedMemberModel::avatarSourceRow(int row) const { + if (row < 0 || row >= m_rows.size()) return -1; + return m_rows[row] >= 0 ? m_rows[row] : -m_rows[row]-1; +} +int GroupedMemberModel::actionSourceRow(int row) const { + if (row < 0 || row >= m_rows.size()) return -1; + if (m_rows[row] >= 0) return m_rows[row]; + const auto group = memberGroup(m_source, -m_rows[row]-1); + for (int i = 0; i < m_source->rowCount(); ++i) + if (memberGroup(m_source, i) == group && m_source->isOnlineAt(i) && + !(m_source->index(i).data(Qt::UserRole + 3).toInt() & MEMBER_FLAG_DELETED)) return i; + return -1; +} QVariant GroupedMemberModel::data(const QModelIndex& idx, int role) const { if (!idx.isValid() || idx.model() != this || idx.row() < 0 || idx.row() >= m_rows.size()) return {}; - const int row = m_rows[idx.row()]; + const int row = m_rows[idx.row()], target = actionSourceRow(idx.row()); if (role == GroupHeaderRole) return row < 0; - if (row >= 0) { - const auto value = m_source->data(m_source->index(row), role); - return role == Qt::DisplayRole && !memberGroup(m_source, row).isEmpty() ? QVariant(" " + value.toString()) : value; + if (role == ActionNodeIdRole) return QVariant::fromValue(m_source->nodeIdAt(target)); + if (row >= 0) return m_source->data(m_source->index(row), role); + const int avatar = avatarSourceRow(idx.row()); const auto group = memberGroup(m_source, avatar); + const auto member = m_source->member(m_source->nodeIdAt(avatar)); if (!member) return {}; + if (role == GroupExpandedRole) return m_expanded.contains(group); + if (role == Qt::DisplayRole) return QString::fromUtf8(member->devgroup_name); + if (role == Qt::DecorationRole) return m_source->data(m_source->index(avatar), role); + if (role == MemberListModel::OnlineRole) return target >= 0; + if (role == Qt::UserRole + 3) { + const int shared = CHAT_MEMBER_FLAG_RADIO | CHAT_MEMBER_FLAG_PHONE_KNOWN | CHAT_MEMBER_FLAG_PHONE_BUSY; + int flags = target < 0 ? 0 : m_source->index(target).data(role).toInt() & ~shared; + for (int i = 0; i < m_source->rowCount(); ++i) if (memberGroup(m_source, i) == group) { + const int deviceFlags = m_source->index(i).data(role).toInt(); + if (!(deviceFlags & MEMBER_FLAG_DELETED)) flags |= deviceFlags & shared; + } + return flags; } - const auto member = m_source->member(m_source->nodeIdAt(-row-1)); if (!member) return {}; - const auto group = memberGroup(m_source, -row-1); - if (role == Qt::DisplayRole) return (m_expanded.contains(group) ? QStringLiteral("[−] ") : QStringLiteral("[+] ")) + - QString::fromUtf8(member->devgroup_name); - if (role == MemberListModel::OnlineRole) { - for (int i = 0; i < m_source->rowCount(); ++i) - if (memberGroup(m_source, i) == group && m_source->isOnlineAt(i)) return true; - return false; - } - return {}; + if (role == Qt::UserRole + 1) return target < 0 ? QCoreApplication::translate("ChatGui", "offline") : + m_source->data(m_source->index(target), Qt::DisplayRole).toString() + " · " + m_source->data(m_source->index(target), role).toString(); + return target < 0 ? QVariant() : m_source->data(m_source->index(target), role); } diff --git a/tools/chatgui/src/memberlistmodel.h b/tools/chatgui/src/memberlistmodel.h index f3a972974..a83bceeb5 100644 --- a/tools/chatgui/src/memberlistmodel.h +++ b/tools/chatgui/src/memberlistmodel.h @@ -78,15 +78,22 @@ private: QTimer* m_rttTimer; // QObject-child, останавливается при уничтожении модели }; -/* Представление поверх снимков: заголовки не имеют node_id, действия доступны только детям. */ +/* Представление поверх снимков: заголовки не являются устройствами; аватар берётся у первого + * участника группы, кнопки действуют на первое online-устройство. RADIO/PHONE-флаги заголовка + * объединяют состояния всех неудалённых участников; mute/self относятся к цели кнопок. Источник заимствован. */ class GroupedMemberModel : public QAbstractListModel { Q_OBJECT public: static constexpr int GroupHeaderRole = Qt::UserRole + 7; + static constexpr int GroupExpandedRole = Qt::UserRole + 8; + static constexpr int ActionNodeIdRole = Qt::UserRole + 9; // конкретное устройство для кнопок; 0 если в группе нет online + static constexpr int DisclosureWidth = 24; // одинаковая левая колонка во всех строках explicit GroupedMemberModel(MemberListModel* source, QObject* parent = nullptr); int rowCount(const QModelIndex& parent = {}) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; int sourceRow(int row) const; // -1 для заголовка/неверной строки + int actionSourceRow(int row) const; // заголовок: первое online-устройство, -1 если их нет + int avatarSourceRow(int row) const; // заголовок: первое устройство независимо от online bool toggle(int row); // true — обработан заголовок private: void rebuild(bool forceReset = false); diff --git a/tools/chatgui/src/memberpropsdialog.cpp b/tools/chatgui/src/memberpropsdialog.cpp index 98346c47c..30bc2c852 100644 --- a/tools/chatgui/src/memberpropsdialog.cpp +++ b/tools/chatgui/src/memberpropsdialog.cpp @@ -41,14 +41,14 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId, QString curNick; bool curVerified = false; bool curDeleted = false; - bool curStorage = false; + bool curStorage = false, inDeviceGroup = false; m_currentVer = 0; bool loaded = false; if (m_db && m_db->m_db) { QString tbl = m_db->peersTableName(channelId); QByteArray sql = QString( - "SELECT p.adm_tags,p.local_nick,length(c.ed25519_privkey) FROM \"%1\" p " - "JOIN channels c ON c.channel_id=? WHERE p.node_id=?" + "SELECT p.adm_tags,p.local_nick,length(c.ed25519_privkey),length(n.devgroup_ed) FROM \"%1\" p " + "JOIN channels c ON c.channel_id=? LEFT JOIN nodes n ON n.node_id=p.node_id WHERE p.node_id=?" ).arg(tbl).toUtf8(); sqlite3_stmt* st = nullptr; int rc = sqlite3_prepare_v2(m_db->m_db, sql.constData(), -1, &st, nullptr); @@ -66,7 +66,7 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId, curDeleted = tags.value("deleted").toString() == "yes"; curStorage = tags.value("storage").toString() == "yes"; curNick = DbManager::colText(st, 1); - m_haveAdminKey = sqlite3_column_int(st, 2) == 32; + m_haveAdminKey = sqlite3_column_int(st, 2) == 32; inDeviceGroup = sqlite3_column_int(st, 3) == 32; } int finish = sqlite3_finalize(st); loaded = rc == SQLITE_ROW && finish == SQLITE_OK; if (!loaded) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member properties: read ch=%s node=%016llx rc=%d finalize=%d: %s", @@ -124,9 +124,11 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId, layout->addWidget(hint); } - auto* addDevice = new QPushButton(QCoreApplication::translate("ChatGui", "Add my device"), this); - addDevice->setObjectName("memberAddMyDevice"); addDevice->setEnabled(loaded && !isOwn && !curDeleted); - connect(addDevice, &QPushButton::clicked, this, [this] { inviteMyDevice(this, m_channelId, m_nodeId); }); + auto* addDevice = new QPushButton(QCoreApplication::translate("ChatGui", isOwn ? "Disconnect from my devices" : "Add device"), this); + addDevice->setObjectName("memberAddMyDevice"); addDevice->setEnabled(loaded && !curDeleted && (!isOwn || inDeviceGroup)); + connect(addDevice, &QPushButton::clicked, this, [this, addDevice] { + if (m_isOwn) disconnectMyDevice(addDevice); else inviteMyDevice(this, m_channelId, m_nodeId); + }); layout->addWidget(addDevice); m_transferBtn = new QPushButton(QCoreApplication::translate("ChatGui", "Transfer admin rights"), this); diff --git a/tools/chatgui/tests/test_invite_links.cpp b/tools/chatgui/tests/test_invite_links.cpp index 7559f9984..fee766643 100644 --- a/tools/chatgui/tests/test_invite_links.cpp +++ b/tools/chatgui/tests/test_invite_links.cpp @@ -468,7 +468,7 @@ private slots: QSignalSpy radios(&delegate, &AccountDelegate::radioCallRequested); QSignalSpy calls(&delegate, &AccountDelegate::callRequested); const QRect row = view.visualRect(model.index(0, 0)); - const QPoint radio(row.right() - 50, row.center().y()), phone(row.right() - 17, row.center().y()); + const QPoint radio(row.right() - 82, row.center().y()), phone(row.right() - 50, row.center().y()); QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, radio); QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, phone); QCOMPARE(radios.count(), 0); QCOMPARE(calls.count(), 0); @@ -507,7 +507,7 @@ private slots: QPainter painter(&image); delegate.paint(&painter, option, model.index(0, 0)); painter.end(); int count = 0; for (int y = 0; y < image.height(); ++y) - for (int x = image.width() - 31; x < image.width(); ++x) count += image.pixelColor(x, y) == color; + for (int x = image.width() - 64; x < image.width() - 36; ++x) count += image.pixelColor(x, y) == color; return count; }; const int known = CHAT_MEMBER_FLAG_PHONE_KNOWN, busy = CHAT_MEMBER_FLAG_PHONE_BUSY; @@ -540,7 +540,8 @@ private slots: action == 2 ? &AccountDelegate::callRequested : &AccountDelegate::radioMuteRequested; QSignalSpy activated(&delegate, signal); const QRect row = view.visualRect(model.index(0, 0)); - const QPoint radio(action == 0 ? row.left() + 21 : row.right() - (action == 1 ? 50 : action == 2 ? 17 : 82), row.center().y()); + const QPoint radio(action == 0 ? row.left() + GroupedMemberModel::DisclosureWidth + 21 : + row.right() - (action == 1 ? 82 : action == 2 ? 50 : 18), row.center().y()); QTest::mousePress(view.viewport(), Qt::LeftButton, Qt::NoModifier, radio); for (QPoint point : {radio + QPoint(-15, 0), radio}) { QMouseEvent move(QEvent::MouseMove, point, view.viewport()->mapToGlobal(point), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); @@ -566,14 +567,14 @@ private slots: }); const QModelIndex index = model.index(0, 0); const QRect row = view.visualRect(index); - const QPoint button(row.right() - 82, row.center().y()); + const QPoint button(row.right() - 18, row.center().y()); auto colorCount = [&](QRgb color) { QImage image(view.viewport()->size(), QImage::Format_RGB32); image.fill(Qt::white); QStyleOptionViewItem option; option.initFrom(&view); option.rect = row; QPainter painter(&image); delegate.paint(&painter, option, index); painter.end(); int count = 0; for (int y = row.top(); y <= row.bottom(); ++y) - for (int x = row.right() - 95; x < row.right() - 67; ++x) count += image.pixel(x, y) == color; + for (int x = row.right() - 31; x < row.right() - 3; ++x) count += image.pixel(x, y) == color; return count; }; QVERIFY(colorCount(QColor("#9E9E9E").rgb()) > 0); // Молчащий участник серый; зелёный означает передачу. diff --git a/tools/chatgui/tests/test_member_detail.cpp b/tools/chatgui/tests/test_member_detail.cpp index 930c1b72c..346b83ce7 100644 --- a/tools/chatgui/tests/test_member_detail.cpp +++ b/tools/chatgui/tests/test_member_detail.cpp @@ -1,5 +1,6 @@ /* Настоящие Qt-карточка и модель; очередь ядра задерживает ответы для проверки смены выбора. */ #include "../src/accountlist.h" +#include "../src/accountdelegate.h" #include "../src/devgroupdialog.h" #include #include @@ -13,6 +14,8 @@ #include #include #include +#include +#include #include #include #include @@ -37,12 +40,13 @@ static bool rejectOwned = false; static int ownedPosts = 0; static std::vector memberRequests; static std::vector connectRequests; +static std::vector muteRequests; extern "C" UTUN_INSTANCE* gui_bridge_get_inst() { return &instance; } extern "C" int gui_bridge_is_uasync_ready() { return ready; } extern "C" void gui_bridge_set_member_detail_cb(gui_member_detail_fn cb) { detailCallback = cb; } extern "C" void gui_bridge_set_radio_mute_result_cb(gui_dm_messages_fn) {} -extern "C" int gui_bridge_toggle_member_radio_mute(const char*, uint64_t) { return 0; } +extern "C" int gui_bridge_toggle_member_radio_mute(const char*, uint64_t node) { muteRequests.push_back(node); return 0; } extern "C" void gui_bridge_post_uasync_fn(void (*fn)(void*), void* arg) { if (fn == chat_core_collect_member_detail_trampoline) requests.push_back(*static_cast(arg)); if (fn == chat_core_request_member_list_trampoline || fn == chat_core_request_member_rtt_trampoline) @@ -132,7 +136,7 @@ class MemberDetailTest : public QObject { } private slots: void init() { - requests.clear(); memberRequests.clear(); connectRequests.clear(); ownedPosts = 0; rejectOwned = false; ready = true; + requests.clear(); memberRequests.clear(); connectRequests.clear(); muteRequests.clear(); ownedPosts = 0; rejectOwned = false; ready = true; debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); } @@ -173,6 +177,17 @@ private slots: QVERIFY(shown); } + void disconnectSelfUsesLeaveCommandAndDisablesButton() { + QWidget parent; QPushButton button("Disconnect from my devices", &parent); devgroupRequests.clear(); + disconnectMyDevice(&button); + QVERIFY(!button.isEnabled()); QVERIFY(!devgroupRequests.empty()); + QCOMPARE(devgroupRequests.back().action, CHAT_DEVGROUP_LEAVE); + QCOMPARE(devgroupRequests.back().peer, uint64_t(0)); + QByteArray bytes(9, 0); mf_put64(reinterpret_cast(bytes.data()), devgroupRequests.back().request_id); bytes[8] = 1; + bytes += "{}"; devgroupCallback(reinterpret_cast(bytes.constData()), bytes.size()); + QVERIFY(!button.isEnabled()); + } + void actualRouteText_data() { QTest::addColumn("up"); QTest::addColumn("hops"); QTest::addColumn("localNat"); QTest::addColumn("peerNat"); QTest::addColumn("rtt"); QTest::addColumn("online"); QTest::addColumn("compact"); QTest::addColumn("detail"); @@ -224,6 +239,109 @@ private slots: QVERIFY(grouped.toggle(0)); QCOMPARE(grouped.rowCount(), 2); } + void devgroupControlsFollowFirstOnlineDeviceAndKeepFirstAvatar() { + AccountList panel; panel.resize(380, 350); panel.show(); + auto first = row(7, false), second = row(8), third = row(9); + for (auto* bytes : {&first, &second, &third}) { + memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11); + } + fill(panel, "123", first + second + third + row(10)); + auto* view = panel.findChild(); + auto* grouped = qobject_cast(view->model()); QVERIFY(grouped); + QSignalSpy calls(&panel, &AccountList::callRequested), radioCalls(&panel, &AccountList::radioCallRequested); + const auto group = grouped->index(0); + QCOMPARE(group.data(Qt::DisplayRole).toString(), QString("My devices")); + QCOMPARE(group.data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(8)); + QCOMPARE(grouped->avatarSourceRow(0), 0); QCOMPARE(grouped->sourceRow(0), -1); + const auto avatar = qvariant_cast(group.data(Qt::DecorationRole)).pixmap(34, 34).toImage(); + QCOMPARE(avatar, qvariant_cast(panel.model()->index(0).data(Qt::DecorationRole)).pixmap(34, 34).toImage()); + QTest::qWait(10); + const QRect rect = view->visualRect(group); + const QPoint call(rect.right() - 49, rect.center().y()), radio(rect.right() - 81, rect.center().y()); + const QPoint mute(rect.right() - 17, rect.center().y()); + QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, call); + QCOMPARE(calls.size(), 1); QCOMPARE(calls.last()[1].toULongLong(), quint64(8)); + QCOMPARE(calls.last()[2].toString(), QString("peer8")); + QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, radio); + QCOMPARE(radioCalls.size(), 1); QCOMPARE(radioCalls.last()[1].toULongLong(), quint64(8)); + QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, mute); + QCOMPARE(muteRequests.size(), size_t(1)); QCOMPARE(muteRequests.back(), uint64_t(8)); + QCOMPARE(grouped->rowCount(), 2); // Кнопки не раскрывают группу. + QTest::mousePress(view->viewport(), Qt::LeftButton, Qt::NoModifier, call); + second[8] = 0; // Устройство уходит offline между press и release. + panel.onMemberUpdated("123", reinterpret_cast(second.constData())); + QTest::mouseRelease(view->viewport(), Qt::LeftButton, Qt::NoModifier, call); + QCOMPARE(calls.size(), 1); + QCOMPARE(group.data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(9)); + QCOMPARE(qvariant_cast(group.data(Qt::DecorationRole)).pixmap(34, 34).toImage(), avatar); + QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, call); + QCOMPARE(calls.size(), 2); QCOMPARE(calls.last()[1].toULongLong(), quint64(9)); + QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(rect.left() + 12, rect.center().y())); + QCOMPARE(grouped->rowCount(), 5); QVERIFY(grouped->index(0).data(GroupedMemberModel::GroupExpandedRole).toBool()); + const QString screenshot = qEnvironmentVariable("UTUN_TEST_GROUP_SCREENSHOT"); + if (!screenshot.isEmpty()) QVERIFY(panel.grab().save(screenshot)); + third[8] = 0; panel.onMemberUpdated("123", reinterpret_cast(third.constData())); + QCOMPARE(grouped->index(0).data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(0)); + QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, call); + QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, mute); + QCOMPARE(calls.size(), 2); QCOMPARE(muteRequests.size(), size_t(1)); + } + + void devgroupIndicatorsCombineDevicesEvenWhenFirstOnlineIsSelf() { + MemberListModel source; source.setChannel("123", true); + GroupedMemberModel grouped(&source); + auto self = row(7, true, 0, 0xffff, 255, 0, true), busy = row(8), removed = row(9); + for (auto* bytes : {&self, &busy, &removed}) { + memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11); + } + const int active = CHAT_MEMBER_FLAG_RADIO | CHAT_MEMBER_FLAG_PHONE_KNOWN | CHAT_MEMBER_FLAG_PHONE_BUSY; + busy[10] = char(active | CHAT_MEMBER_FLAG_RADIO_MUTED); + removed[10] = char(active | CHAT_MEMBER_FLAG_DELETED); + const auto bytes = self + busy + removed; + source.onMemberList("123", 3, reinterpret_cast(bytes.constData()), bytes.size()); + const auto header = grouped.index(0); + QVERIFY(header.data(Qt::UserRole + 2).toBool()); + QCOMPARE(header.data(Qt::UserRole + 3).toInt(), active); // mute относится только к цели кнопок. + AccountDelegate delegate; + QStyleOptionViewItem option; option.rect = QRect(0, 0, 340, MemberListModel::kRowHeight); + const QColor background("#FFFFFF"); + option.palette.setColor(QPalette::AlternateBase, background); + auto image = [&] { + QImage result(option.rect.size(), QImage::Format_RGB32); result.fill(background); + QPainter painter(&result); delegate.paint(&painter, option, header); painter.end(); return result; + }; + auto colors = [](const QImage& image, int rightOffset, const QColor& color) { + int count = 0; + for (int y = 0; y < image.height(); ++y) + for (int x = image.width() - rightOffset - 1; x < image.width() - rightOffset + 27; ++x) + count += image.pixelColor(x, y) == color; + return count; + }; + const auto activeImage = image(); + QVERIFY(colors(activeImage, 95, QColor("#4CAF50")) > 0); + QVERIFY(colors(activeImage, 63, QColor("#E53935")) > 0); + QVERIFY(colors(activeImage, 31, QColor("#9E9E9E")) > 0); + QSignalSpy calls(&delegate, &AccountDelegate::callRequested), toggles(&delegate, &AccountDelegate::groupToggleRequested); + for (int offset : {81, 49, 17}) { + const QPoint point(option.rect.right() - offset, option.rect.center().y()); + QMouseEvent press(QEvent::MouseButtonPress, point, point, Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); + QMouseEvent release(QEvent::MouseButtonRelease, point, point, Qt::LeftButton, Qt::NoButton, Qt::NoModifier); + delegate.editorEvent(&press, &grouped, option, header); delegate.editorEvent(&release, &grouped, option, header); + } + QCOMPARE(calls.size(), 0); QCOMPARE(toggles.size(), 0); // Индикаторы self не вызывают себя и не раскрывают группу. + busy[10] = CHAT_MEMBER_FLAG_PHONE_KNOWN; + source.onMemberUpdated("123", reinterpret_cast(busy.constData())); + QCOMPARE(header.data(Qt::UserRole + 3).toInt(), int(CHAT_MEMBER_FLAG_PHONE_KNOWN)); + QCOMPARE(colors(image(), 63, QColor("#E53935")), 0); // Удалённый busy-аккаунт не влияет на группу. + QCOMPARE(colors(image(), 95, QColor("#4CAF50")), 0); // self не окрашивает выключенную рацию в зелёный. + self[8] = 0; busy[8] = 0; + source.onMemberUpdated("123", reinterpret_cast(self.constData())); + source.onMemberUpdated("123", reinterpret_cast(busy.constData())); + QVERIFY(!header.data(MemberListModel::OnlineRole).toBool()); + const auto offlineImage = image(); + for (int offset : {95, 63}) QCOMPARE(colors(offlineImage, offset, background), 28 * offlineImage.height()); + } + void flatModelPreservesRowsSnapshotsAndPersistentIndexes() { MemberListModel model; QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::QtTest); diff --git a/tools/chatgui/tests/test_member_dialogs.cpp b/tools/chatgui/tests/test_member_dialogs.cpp index db937aabf..0510f710b 100644 --- a/tools/chatgui/tests/test_member_dialogs.cpp +++ b/tools/chatgui/tests/test_member_dialogs.cpp @@ -15,6 +15,7 @@ extern "C" { #include "utun_instance.h" #include "chat/chat_core.h" +#include "chat/chat_devgroup.h" #include "debug_config.h" #include "mem.h" } @@ -138,6 +139,23 @@ private slots: } void init() { allocationBaseline = u_get_allocated_count(); } void cleanup() { QCoreApplication::processEvents(); QCOMPARE(u_get_allocated_count(), allocationBaseline); } + void deviceActionMatchesSelfAndDisconnectsCommittedGroup() { + MemberCore core; QVERIFY(core.start()); + QVERIFY(!core.run([&] { return chat_devgroup_rename(core.instance, "Devices"); })); + auto dialog = makeDialog(core, true); + auto* button = dialog->findChild("memberAddMyDevice"); QVERIFY(button && button->isEnabled()); + QCOMPARE(button->text(), QString("Disconnect from my devices")); button->click(); QVERIFY(!button->isEnabled()); + QCOMPARE(core.run([&] { + devgroup_record record; uint8_t ed[32], x[32]; return devgroup_load_local(core.instance, &record, ed, x); + }), 0); + QCoreApplication::processEvents(); + auto ungrouped = makeDialog(core, true); + QVERIFY(!ungrouped->findChild("memberAddMyDevice")->isEnabled()); + core.db.setMyNodeId(0); auto other = makeDialog(core, true); + button = other->findChild("memberAddMyDevice"); QVERIFY(button && button->isEnabled()); + QCOMPARE(button->text(), QString("Add device")); + } + void saves_data() { QTest::addColumn("properties"); QTest::addColumn("failure"); for (bool properties : {false, true}) for (const char* failure : {"none", "prepare", "step", "commit", "missing"}) diff --git a/tools/chatgui/tests/test_radio_mute_bridge.cpp b/tools/chatgui/tests/test_radio_mute_bridge.cpp index 1237ca2bb..499f3c377 100644 --- a/tools/chatgui/tests/test_radio_mute_bridge.cpp +++ b/tools/chatgui/tests/test_radio_mute_bridge.cpp @@ -150,14 +150,14 @@ private slots: connect(&delegate, &AccountDelegate::radioMuteRequested, &view, [&](const QModelIndex&) { accepted = gui_bridge_toggle_member_radio_mute(probe.channel.toUtf8().constData(), 42); }); - const QRect row = view.visualRect(source); const QPoint button(row.right() - 82, row.center().y()); + const QRect row = view.visualRect(source); const QPoint button(row.right() - 18, row.center().y()); auto iconImage = [&] { - return view.viewport()->grab().toImage().copy(QRect(row.right() - 95, row.center().y() - 14, 28, 28)); + return view.viewport()->grab().toImage().copy(QRect(row.right() - 31, row.center().y() - 14, 28, 28)); }; auto iconHasColor = [&](const QColor& color) { const QImage image = view.viewport()->grab().toImage(); for (int y = row.top(); y <= row.bottom(); ++y) - for (int x = row.right() - 95; x < row.right() - 67; ++x) if (image.pixelColor(x, y) == color) return true; + for (int x = row.right() - 31; x < row.right() - 3; ++x) if (image.pixelColor(x, y) == color) return true; return false; }; QVERIFY(iconHasColor(QColor("#9E9E9E"))); QVERIFY(!iconHasColor(QColor("#4CAF50"))); diff --git a/tools/chatgui/translations/chatgui_en.ts b/tools/chatgui/translations/chatgui_en.ts index 2d742485f..657e14bf0 100644 --- a/tools/chatgui/translations/chatgui_en.ts +++ b/tools/chatgui/translations/chatgui_en.ts @@ -3,7 +3,10 @@ ChatGui - Add my deviceAdd my device + Expand devicesExpand devices + Collapse devicesCollapse devices + Disconnect from my devicesDisconnect from my devices + Add deviceAdd device Lookback time: Lookback time: @@ -2861,19 +2864,12 @@ See log: DevgroupDialog - Add my deviceAdd my device - PIN was not accepted. Try again.PIN was not accepted. Try again. - My devicesMy devices - Device group nameDevice group name - Create / renameCreate / rename - Add device %1Add device %1 - Enter PIN for selected invitationEnter PIN for selected invitation + Disconnect from my devicesDisconnect from my devices Add deviceAdd device + PIN was not accepted. Try again.PIN was not accepted. Try again. PIN shown on the inviting devicePIN shown on the inviting device - Leave device groupLeave device group Operation failed. Check connection and invitation status.Operation failed. Check connection and invitation status. PIN: %1 — enter it on the other device within 2 minutes.PIN: %1 — enter it on the other device within 2 minutes. - No device groupNo device group Waiting for PINWaiting for PIN Checking PINChecking PIN Transferring keysTransferring keys @@ -2881,12 +2877,9 @@ See log: RejectedRejected ExpiredExpired Result unknownResult unknown - Invalid name or PINInvalid name or PIN Core unavailableCore unavailable AccountList - My devicesMy devices - Add to my devicesAdd to my devices diff --git a/tools/chatgui/translations/chatgui_ru.ts b/tools/chatgui/translations/chatgui_ru.ts index 677d3351f..e47bad242 100644 --- a/tools/chatgui/translations/chatgui_ru.ts +++ b/tools/chatgui/translations/chatgui_ru.ts @@ -3,7 +3,10 @@ ChatGui - Add my deviceДобавить моё устройство + Expand devicesРазвернуть устройства + Collapse devicesСвернуть устройства + Disconnect from my devicesОтключить от моих устройств + Add deviceДобавить устройство Lookback time: Время анализа: @@ -2871,19 +2874,12 @@ See log: DevgroupDialog - Add my deviceДобавить моё устройство + Disconnect from my devicesОтключить от моих устройств + Add deviceДобавить устройство PIN was not accepted. Try again.PIN не принят. Попробуйте ещё раз. - My devicesМои устройства - Device group nameИмя группы устройств - Create / renameСоздать / переименовать - Add device %1Добавить устройство %1 - Enter PIN for selected invitationВвести PIN выбранного приглашения - Add deviceДобавление устройства PIN shown on the inviting devicePIN с экрана приглашающего устройства - Leave device groupВыйти из группы устройств Operation failed. Check connection and invitation status.Операция не выполнена. Проверьте соединение и состояние приглашения. PIN: %1 — enter it on the other device within 2 minutes.PIN: %1 — введите его на другом устройстве в течение 2 минут. - No device groupНет группы устройств Waiting for PINОжидается PIN Checking PINПроверка PIN Transferring keysПередача ключей @@ -2891,12 +2887,9 @@ See log: RejectedОтказ ExpiredИстёк срок Result unknownРезультат неизвестен - Invalid name or PINНеверное имя или PIN Core unavailableЯдро недоступно AccountList - My devicesМои устройства - Add to my devicesДобавить в мои устройства