android: member list screen + link status dots, ipv6 autoconnect fixes
Android chatgui: - New MemberListScreen with member list, detail card, edit dialog - Link status dots (green/red/grey) per address in detail card (1s poll) - Self-node name coloured green - ChatScreen: people icon in TopAppBar IPv6 autoconnect fixes (3 bugs in conn_mgr/topo): - topo_node_sqlite_node_load: load v6 addresses from DB - topo_node_registry_store: merge v6_addrs into existing node - cm_sock_v6_classify: wildcard [::] treated as CM_V6_DIR - cm_add_v6_link: htons(port) was missing Memory fixes: - strdup→u_strdup in bridge (7 instances) - u_realloc cap update before success check (3 instances) - char sql[256]→[512] for truncated SELECT Chat member hash now includes addresses for change detection Linux chatgui: - Link status dots in AccountList detail panel (1s poll) - Self-node name in green in member list
This commit is contained in:
1 parent
6719a1e23c
commit
50156e12af
19 files changed
+1022
-23
No files matched your search
@@ -15,7 +15,7 @@ import com.utun.chat.viewmodel.ChatViewModel
|
||||
import android.content.Intent
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
enum class Screen { ChannelList, Chat, Settings, Logs, QrScan }
|
||||
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -32,7 +32,10 @@ class MainActivity : ComponentActivity() {
|
||||
var joinAutoLink by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
BackHandler(enabled = screen != Screen.ChannelList) {
|
||||
screen = Screen.ChannelList
|
||||
when (screen) {
|
||||
Screen.MemberList -> screen = Screen.Chat
|
||||
else -> screen = Screen.ChannelList
|
||||
}
|
||||
}
|
||||
|
||||
val connected = NativeLib.isReady()
|
||||
@@ -69,10 +72,25 @@ class MainActivity : ComponentActivity() {
|
||||
connected = connected,
|
||||
viewModel = vm,
|
||||
onSendMessage = { vm.sendMessage(it) },
|
||||
onMembersClick = { screen = Screen.MemberList },
|
||||
onBack = { screen = Screen.ChannelList }
|
||||
)
|
||||
}
|
||||
}
|
||||
Screen.MemberList -> {
|
||||
val members by vm.members.collectAsState()
|
||||
val selectedMember by vm.selectedMember.collectAsState()
|
||||
val channel = vm.currentChannel.collectAsState().value
|
||||
if (channel != null) {
|
||||
MemberListScreen(
|
||||
channel = channel,
|
||||
members = members,
|
||||
selectedMember = selectedMember,
|
||||
viewModel = vm,
|
||||
onBack = { screen = Screen.Chat }
|
||||
)
|
||||
}
|
||||
}
|
||||
Screen.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList }, onExit = { finish() })
|
||||
Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList })
|
||||
Screen.QrScan -> {
|
||||
|
||||
@@ -12,6 +12,24 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L
|
||||
val voiceDurationMs: Int = 0, val fileSize: Long = 0,
|
||||
val downloadState: String = "" /* "", "downloading", "downloaded", "error" */)
|
||||
|
||||
data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false,
|
||||
val isAdmin: Boolean = false, val isSuper: Boolean = false,
|
||||
val isStorage: Boolean = false, val nodeType: Int = 0,
|
||||
val rtt: Int = 0, val isSelf: Boolean = false)
|
||||
|
||||
data class MemberDetail(val nodeId: Long, val name: String = "",
|
||||
val online: Boolean = false, val lastSeen: Long = 0,
|
||||
val created: Long = 0, val addresses: List<MemberAddr> = emptyList())
|
||||
|
||||
data class MemberAddr(val family: Int = 0, val protocol: String = "", val address: String = "",
|
||||
val port: Int = 0, val socketId: Int = 0, val rtt: Int = 0)
|
||||
|
||||
data class LinkStatus(val family: Int = 0, val address: String = "", val port: Int = 0,
|
||||
val linkState: Int = 0, val linkStatus: Int = 0)
|
||||
|
||||
data class MemberLinks(val connected: Boolean = false, val initialized: Boolean = false,
|
||||
val linksUp: Int = 0, val links: List<LinkStatus> = emptyList())
|
||||
|
||||
class ChatRepository {
|
||||
private val myNodeId: Long = NativeLib.getMyNodeId()
|
||||
|
||||
@@ -138,5 +156,92 @@ class ChatRepository {
|
||||
fun connectChannel(channelId: String) = NativeLib.connectChannel(channelId)
|
||||
fun isReady() = NativeLib.isReady()
|
||||
|
||||
fun getMembers(channelId: String): List<ChatMember> {
|
||||
val list = mutableListOf<ChatMember>()
|
||||
try {
|
||||
val arr = JSONArray(NativeLib.getChannelMembers(channelId))
|
||||
for (i in 0 until arr.length()) {
|
||||
val obj = arr.getJSONObject(i)
|
||||
list.add(ChatMember(
|
||||
nodeId = obj.getLong("ni"),
|
||||
name = obj.optString("n", ""),
|
||||
online = obj.optInt("on", 0) != 0,
|
||||
isAdmin = obj.optInt("ad", 0) != 0,
|
||||
isSuper = obj.optInt("sp", 0) != 0,
|
||||
isStorage = obj.optInt("st", 0) != 0,
|
||||
nodeType = obj.optInt("tp", 0),
|
||||
rtt = obj.optInt("rt", 0),
|
||||
isSelf = obj.optInt("is", 0) != 0
|
||||
))
|
||||
}
|
||||
} catch (e: Exception) { Log.w("utun-gui", "getMembers error", e) }
|
||||
return list
|
||||
}
|
||||
|
||||
fun getMemberDetail(channelId: String, nodeId: Long): MemberDetail? {
|
||||
try {
|
||||
val obj = JSONObject(NativeLib.getMemberDetail(channelId, nodeId))
|
||||
if (obj.length() == 0) return null
|
||||
val addrs = mutableListOf<MemberAddr>()
|
||||
val arr = obj.optJSONArray("adr")
|
||||
if (arr != null) {
|
||||
for (i in 0 until arr.length()) {
|
||||
val a = arr.getJSONObject(i)
|
||||
addrs.add(MemberAddr(
|
||||
family = a.optInt("fm", 0),
|
||||
protocol = a.optString("pt", ""),
|
||||
address = a.optString("ad", ""),
|
||||
port = a.optInt("po", 0),
|
||||
socketId = a.optInt("si", 0),
|
||||
rtt = a.optInt("rt", 0)
|
||||
))
|
||||
}
|
||||
}
|
||||
return MemberDetail(
|
||||
nodeId = obj.getLong("ni"),
|
||||
name = obj.optString("n", ""),
|
||||
online = obj.optInt("on", 0) != 0,
|
||||
lastSeen = obj.optLong("ls", 0),
|
||||
created = obj.optLong("cr", 0),
|
||||
addresses = addrs
|
||||
)
|
||||
} catch (e: Exception) { Log.w("utun-gui", "getMemberDetail error", e) }
|
||||
return null
|
||||
}
|
||||
|
||||
fun setMemberNick(channelId: String, nodeId: Long, nick: String) =
|
||||
NativeLib.setMemberNick(channelId, nodeId, nick)
|
||||
|
||||
fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) =
|
||||
NativeLib.setMemberFlags(channelId, nodeId, admin, supernode, storage)
|
||||
|
||||
fun getMemberLinks(nodeId: Long): MemberLinks {
|
||||
try {
|
||||
val obj = JSONObject(NativeLib.getMemberLinks(nodeId))
|
||||
if (!obj.optBoolean("cn", false)) return MemberLinks()
|
||||
val links = mutableListOf<LinkStatus>()
|
||||
val arr = obj.optJSONArray("lk")
|
||||
if (arr != null) {
|
||||
for (i in 0 until arr.length()) {
|
||||
val a = arr.getJSONObject(i)
|
||||
links.add(LinkStatus(
|
||||
family = a.optInt("fm", 0),
|
||||
address = a.optString("ad", ""),
|
||||
port = a.optInt("po", 0),
|
||||
linkState = a.optInt("st", 0),
|
||||
linkStatus = a.optInt("ls", 0)
|
||||
))
|
||||
}
|
||||
}
|
||||
return MemberLinks(
|
||||
connected = obj.optBoolean("cn", false),
|
||||
initialized = obj.optBoolean("in", false),
|
||||
linksUp = obj.optInt("lu", 0),
|
||||
links = links
|
||||
)
|
||||
} catch (e: Exception) { Log.w("utun-gui", "getMemberLinks error", e) }
|
||||
return MemberLinks()
|
||||
}
|
||||
|
||||
fun close() {}
|
||||
}
|
||||
@@ -81,6 +81,15 @@ object NativeLib {
|
||||
fun setUdpLogTarget(ip: String, port: Int) { nativeSetUdpLogTarget(ip, port) }
|
||||
fun regenerateKeys() { nativeRegenerateKeys() }
|
||||
|
||||
/* ── Channel members ── */
|
||||
fun getChannelMembers(channelId: String): String = nativeGetChannelMembers(channelId) ?: "[]"
|
||||
fun getMemberDetail(channelId: String, nodeId: Long): String = nativeGetMemberDetail(channelId, nodeId) ?: "{}"
|
||||
fun setMemberNick(channelId: String, nodeId: Long, nick: String) { nativeSetMemberNick(channelId, nodeId, nick) }
|
||||
fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) {
|
||||
nativeSetMemberFlags(channelId, nodeId, admin, supernode, storage)
|
||||
}
|
||||
fun getMemberLinks(nodeId: Long): String = nativeGetMemberLinks(nodeId) ?: "{}"
|
||||
|
||||
/* ── Voice recording ── */
|
||||
fun voiceStart(channelId: String): Boolean = nativeVoiceStart(channelId)
|
||||
fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples)
|
||||
@@ -130,6 +139,13 @@ object NativeLib {
|
||||
private external fun nativePing()
|
||||
private external fun nativeIsResponsive(): Boolean
|
||||
|
||||
/* ── Channel members JNI ── */
|
||||
private external fun nativeGetChannelMembers(channelId: String): String?
|
||||
private external fun nativeGetMemberDetail(channelId: String, nodeId: Long): String?
|
||||
private external fun nativeSetMemberNick(channelId: String, nodeId: Long, nick: String)
|
||||
private external fun nativeSetMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean)
|
||||
private external fun nativeGetMemberLinks(nodeId: Long): String?
|
||||
|
||||
/* ── Voice recording JNI ── */
|
||||
private external fun nativeVoiceStart(channelId: String): Boolean
|
||||
private external fun nativeVoiceFeed(samples: ShortArray): Boolean
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -26,6 +27,7 @@ fun ChatScreen(
|
||||
connected: Boolean,
|
||||
viewModel: ChatViewModel,
|
||||
onSendMessage: (String) -> Unit,
|
||||
onMembersClick: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
@@ -58,6 +60,9 @@ fun ChatScreen(
|
||||
title = { Text(channel.name) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, "Members") }
|
||||
}
|
||||
)
|
||||
LazyColumn(
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.utun.chat.data.Channel
|
||||
import com.utun.chat.data.ChatMember
|
||||
import com.utun.chat.data.MemberDetail
|
||||
import com.utun.chat.data.MemberAddr
|
||||
import com.utun.chat.data.MemberLinks
|
||||
import com.utun.chat.data.LinkStatus
|
||||
import com.utun.chat.viewmodel.ChatViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MemberListScreen(
|
||||
channel: Channel,
|
||||
members: List<ChatMember>,
|
||||
selectedMember: MemberDetail?,
|
||||
viewModel: ChatViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var showEditDialog by remember { mutableStateOf(false) }
|
||||
var editMember by remember { mutableStateOf<ChatMember?>(null) }
|
||||
|
||||
LaunchedEffect(channel.id) { viewModel.refreshMembers(channel.id) }
|
||||
|
||||
val memberLinks by viewModel.memberLinks.collectAsState()
|
||||
|
||||
LaunchedEffect(selectedMember?.nodeId) {
|
||||
val nodeId = selectedMember?.nodeId
|
||||
if (nodeId == null) { viewModel.refreshMemberLinks(0); return@LaunchedEffect }
|
||||
while (isActive) {
|
||||
viewModel.refreshMemberLinks(nodeId)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().navigationBarsPadding()) {
|
||||
TopAppBar(
|
||||
title = { Text("Members (${members.size})") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
|
||||
}
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
items(members.size) { idx ->
|
||||
val m = members[idx]
|
||||
MemberListItem(
|
||||
member = m,
|
||||
isSelected = selectedMember?.nodeId == m.nodeId,
|
||||
onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) },
|
||||
onEdit = {
|
||||
editMember = m
|
||||
showEditDialog = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = selectedMember != null,
|
||||
enter = slideInVertically(initialOffsetY = { it }),
|
||||
exit = slideOutVertically(targetOffsetY = { it })
|
||||
) {
|
||||
selectedMember?.let { detail ->
|
||||
MemberDetailCard(
|
||||
detail = detail,
|
||||
memberLinks = memberLinks,
|
||||
onDismiss = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showEditDialog && editMember != null) {
|
||||
MemberEditDialog(
|
||||
member = editMember!!,
|
||||
onDismiss = { showEditDialog = false },
|
||||
onSave = { nick, admin, supernode, storage ->
|
||||
if (nick != editMember!!.name) viewModel.setMemberNick(channel.id, editMember!!.nodeId, nick)
|
||||
if (admin != editMember!!.isAdmin || supernode != editMember!!.isSuper || storage != editMember!!.isStorage)
|
||||
viewModel.setMemberFlags(channel.id, editMember!!.nodeId, admin, supernode, storage)
|
||||
showEditDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberListItem(
|
||||
member: ChatMember,
|
||||
isSelected: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
onEdit: () -> Unit
|
||||
) {
|
||||
val onlineColor = if (member.online) Color(0xFF4CAF50) else Color(0xFF9E9E9E)
|
||||
val bgColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
color = bgColor,
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(onlineColor)
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = member.name,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (member.isSelf) Color(0xFF4CAF50) else Color.Unspecified
|
||||
)
|
||||
val roles = buildList {
|
||||
if (member.isAdmin) add("Admin")
|
||||
if (member.isSuper) add("Super")
|
||||
if (member.isStorage) add("Storage")
|
||||
}
|
||||
if (roles.isNotEmpty() || member.rtt > 0) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (roles.isNotEmpty())
|
||||
Text(
|
||||
text = roles.joinToString(", "),
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
if (member.rtt > 0) {
|
||||
if (roles.isNotEmpty()) Text(" ", fontSize = 11.sp)
|
||||
Text(
|
||||
text = "${member.rtt / 10}ms",
|
||||
fontSize = 11.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(onClick = onEdit) { Text("Edit") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberDetailCard(
|
||||
detail: MemberDetail,
|
||||
memberLinks: MemberLinks,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
fun linkDotColor(addr: MemberAddr): Color {
|
||||
val lk = memberLinks.links.find {
|
||||
it.family == addr.family && it.address == addr.address && it.port == addr.port
|
||||
} ?: return Color(0xFF9E9E9E)
|
||||
return if (lk.linkState == 3 && lk.linkStatus == 1) Color(0xFF4CAF50) else Color(0xFFE53935)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val dotColor = if (detail.online) Color(0xFF4CAF50) else Color(0xFF9E9E9E)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(dotColor)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(detail.name, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "ID: 0x${detail.nodeId.toString(16).padStart(16, '0')}",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
|
||||
if (detail.lastSeen > 0) {
|
||||
val sec = System.currentTimeMillis() / 1000 - detail.lastSeen
|
||||
val ago = when {
|
||||
sec < 60 -> "${sec}s ago"
|
||||
sec < 3600 -> "${sec / 60}m ago"
|
||||
sec < 86400 -> "${sec / 3600}h ago"
|
||||
else -> "${sec / 86400}d ago"
|
||||
}
|
||||
Text("Last seen: $ago", fontSize = 12.sp, color = Color.Gray)
|
||||
}
|
||||
|
||||
if (detail.created > 0) {
|
||||
val sec = System.currentTimeMillis() / 1000 - detail.created
|
||||
val ago = when {
|
||||
sec < 604800 -> "${sec / 86400}d ago"
|
||||
else -> "${sec / 2592000}mo ago"
|
||||
}
|
||||
Text("Created: $ago", fontSize = 12.sp, color = Color.Gray)
|
||||
}
|
||||
|
||||
if (detail.addresses.isNotEmpty()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Addresses:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||
detail.addresses.forEach { a ->
|
||||
val dcol = linkDotColor(a)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(dcol)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "${a.protocol} ${a.address}:${a.port}" +
|
||||
(if (a.rtt > 0) " (${a.rtt / 10}ms)" else ""),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
color = Color.DarkGray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberEditDialog(
|
||||
member: ChatMember,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (nick: String, admin: Boolean, supernode: Boolean, storage: Boolean) -> Unit
|
||||
) {
|
||||
var nick by remember { mutableStateOf(member.name) }
|
||||
var admin by remember { mutableStateOf(member.isAdmin) }
|
||||
var supernode by remember { mutableStateOf(member.isSuper) }
|
||||
var storage by remember { mutableStateOf(member.isStorage) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Edit ${member.name}") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = nick,
|
||||
onValueChange = { nick = it },
|
||||
label = { Text("Nickname") },
|
||||
enabled = !member.isSelf,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = admin, onCheckedChange = { admin = it })
|
||||
Text("Admin", modifier = Modifier.clickable { admin = !admin })
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = supernode, onCheckedChange = { supernode = it })
|
||||
Text("Supernode", modifier = Modifier.clickable { supernode = !supernode })
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = storage, onCheckedChange = { storage = it })
|
||||
Text("Storage", modifier = Modifier.clickable { storage = !storage })
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSave(nick, admin, supernode, storage) }) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -45,6 +45,15 @@ class ChatViewModel : ViewModel() {
|
||||
private val _generatingKeys = MutableStateFlow(false)
|
||||
val generatingKeys: StateFlow<Boolean> = _generatingKeys
|
||||
|
||||
private val _members = MutableStateFlow<List<ChatMember>>(emptyList())
|
||||
val members: StateFlow<List<ChatMember>> = _members
|
||||
|
||||
private val _selectedMember = MutableStateFlow<MemberDetail?>(null)
|
||||
val selectedMember: StateFlow<MemberDetail?> = _selectedMember
|
||||
|
||||
private val _memberLinks = MutableStateFlow<MemberLinks>(MemberLinks())
|
||||
val memberLinks: StateFlow<MemberLinks> = _memberLinks
|
||||
|
||||
private val _isStuck = MutableStateFlow(false)
|
||||
val isStuck: StateFlow<Boolean> = _isStuck
|
||||
|
||||
@@ -143,6 +152,15 @@ class ChatViewModel : ViewModel() {
|
||||
refreshChannels()
|
||||
}
|
||||
}
|
||||
5 -> { /* MEMBERS_CHANGED: [ch_id_len:1][ch_id:var] */
|
||||
if (repo == null || data == null || data.size < 2) return
|
||||
val chLen = data[0].toInt() and 0xFF
|
||||
if (data.size >= 1 + chLen) {
|
||||
val chId = String(data, 1, chLen)
|
||||
val cur = _currentChannel.value
|
||||
if (cur != null && cur.id == chId) refreshMembers(chId)
|
||||
}
|
||||
}
|
||||
8 -> if (repo != null) refreshChannels() /* CHANNEL_PEERS_ONLINE */
|
||||
14 -> { /* ATTACHMENT_DOWNLOADED: [ch_id_len:1][ch_id:var][msg_id:8] */
|
||||
if (repo == null || data == null || data.size < 2) return
|
||||
@@ -208,6 +226,40 @@ class ChatViewModel : ViewModel() {
|
||||
refreshMessages(channel.id)
|
||||
}
|
||||
|
||||
fun refreshMembers(chId: String) {
|
||||
val r = repo ?: return
|
||||
viewModelScope.launch {
|
||||
_members.value = withContext(Dispatchers.IO) { r.getMembers(chId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun showMemberDetail(channelId: String, nodeId: Long) {
|
||||
val r = repo ?: return
|
||||
viewModelScope.launch {
|
||||
_selectedMember.value = withContext(Dispatchers.IO) { r.getMemberDetail(channelId, nodeId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setMemberNick(channelId: String, nodeId: Long, nick: String) {
|
||||
val r = repo ?: return
|
||||
r.setMemberNick(channelId, nodeId, nick)
|
||||
refreshMembers(channelId)
|
||||
}
|
||||
|
||||
fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) {
|
||||
val r = repo ?: return
|
||||
r.setMemberFlags(channelId, nodeId, admin, supernode, storage)
|
||||
refreshMembers(channelId)
|
||||
}
|
||||
|
||||
fun refreshMemberLinks(nodeId: Long) {
|
||||
if (nodeId == 0L) { _memberLinks.value = MemberLinks(); return }
|
||||
val r = repo ?: return
|
||||
viewModelScope.launch {
|
||||
_memberLinks.value = withContext(Dispatchers.IO) { r.getMemberLinks(nodeId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
val r = repo ?: return
|
||||
val ch = _currentChannel.value ?: return
|
||||
|
||||
Reference in new issue
Block a user