Browse Source

android: restore live member/connection updates lost in Kotlin source restore

- ChatRepository: MemberWire, parseMemberWire, cleanSocketName, parseMemberDetail, LinkStatus.linkId, LocalSocketState.linksUp/linksDown
- ChatViewModel: handlers 24/25/22, fix 16 (selectedMember update), 17 (in-place sync + status!=1 refresh), moder param
- MemberListScreen: compact header, no-dot addresses, TextAlign.Start connections, cleanSocketName, fonts 13sp, spacing 4dp, self *[up/down]
- SettingsScreen: linksCount -> *[linksUp/linksDown]
- NativeLib: moder in setMemberFlags JNI
topo_upd
evgeny 2 months ago
parent
commit
5a42741d06
  1. 190
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  2. 6
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  3. 214
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
  4. 5
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  5. 83
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

190
tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt

@ -9,6 +9,8 @@ import java.nio.ByteOrder
const val EVT_NODEINFO_UPDATED = 16 const val EVT_NODEINFO_UPDATED = 16
const val EVT_LINK_STATUS_CHANGED = 17 const val EVT_LINK_STATUS_CHANGED = 17
const val EVT_LOCAL_SOCKETS = 19 const val EVT_LOCAL_SOCKETS = 19
const val EVT_CONN_LIST = 20
const val EVT_CONN_METRICS = 21
data class NodeStatus( data class NodeStatus(
val nodeId: Long = 0, val nodeId: Long = 0,
@ -19,7 +21,8 @@ data class NodeStatus(
val bgpPresence: Boolean = false, val bgpPresence: Boolean = false,
val bgpUp: Boolean = false, val bgpUp: Boolean = false,
val bestRtt: Int = 0, val bestRtt: Int = 0,
val rttType: Int = 0 // 0=interface, 1=nat, 2=real val bgpRtt: Int = 0,
val bgpNodes: Int = 0
) { ) {
val online: Boolean get() = (directPresence && directUp) || (indirectPresence && indirectUp) val online: Boolean get() = (directPresence && directUp) || (indirectPresence && indirectUp)
val hasPresence: Boolean get() = directPresence || indirectPresence || bgpPresence val hasPresence: Boolean get() = directPresence || indirectPresence || bgpPresence
@ -27,13 +30,14 @@ data class NodeStatus(
fun parseNodeStatus(data: ByteArray): NodeStatus? { fun parseNodeStatus(data: ByteArray): NodeStatus? {
if (data.size < 12) return null if (data.size < 12) return null
val buf = ByteBuffer.wrap(data, 0, 12).order(ByteOrder.LITTLE_ENDIAN) val buf = ByteBuffer.wrap(data, 0, data.size).order(ByteOrder.LITTLE_ENDIAN)
val nodeId = buf.getLong() val nodeId = buf.getLong()
val presence = buf.get().toInt() and 0xFF val presence = buf.get().toInt() and 0xFF
val up = buf.get().toInt() and 0xFF val up = buf.get().toInt() and 0xFF
val rttPacked = buf.getShort().toInt() and 0xFFFF val rttPacked = buf.getShort().toInt() and 0xFFFF
val bestRtt = rttPacked and 0x3FFF val bestRtt = if (rttPacked == 0xFFFF) -1 else rttPacked
val rttType = (rttPacked shr 14) and 3 val bgpRtt = if (data.size >= 16) buf.getShort().toInt() and 0xFFFF else 0
val bgpNodes = if (data.size >= 16) buf.getShort().toInt() and 0xFFFF else 0
return NodeStatus( return NodeStatus(
nodeId = nodeId, nodeId = nodeId,
directPresence = (presence and 1) != 0, directPresence = (presence and 1) != 0,
@ -43,7 +47,8 @@ fun parseNodeStatus(data: ByteArray): NodeStatus? {
bgpPresence = (presence and 4) != 0, bgpPresence = (presence and 4) != 0,
bgpUp = (up and 4) != 0, bgpUp = (up and 4) != 0,
bestRtt = bestRtt, bestRtt = bestRtt,
rttType = rttType bgpRtt = bgpRtt,
bgpNodes = bgpNodes
) )
} }
@ -57,25 +62,34 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L
data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false, data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false,
val isAdmin: Boolean = false, val isSuper: Boolean = false, val isAdmin: Boolean = false, val isSuper: Boolean = false,
val isStorage: Boolean = false, val nodeType: Int = 0, val isModer: Boolean = false, val isStorage: Boolean = false,
val rtt: Int = 0, val rttType: Int = 0, val isSelf: Boolean = false, val nodeType: Int = 0,
val rtt: Int = 0, val isSelf: Boolean = false,
val directUp: Boolean = false, val indirectUp: Boolean = false, val directUp: Boolean = false, val indirectUp: Boolean = false,
val directPresence: Boolean = false, val indirectPresence: Boolean = false, val directPresence: Boolean = false, val indirectPresence: Boolean = false,
val bgpPresence: Boolean = false) val bgpPresence: Boolean = false, val bgpRtt: Int = 0,
val bgpNodes: Int = 0) {
val flags: Int get() = (if (isSuper) 1 else 0) or (if (isAdmin) 2 else 0) or (if (isModer) 4 else 0)
}
data class MemberDetail(val nodeId: Long, val name: String = "", data class MemberDetail(val nodeId: Long, val name: String = "",
val online: Boolean = false, val lastSeen: Long = 0, val online: Boolean = false, val lastSeen: Long = 0,
val created: Long = 0, val addresses: List<MemberAddr> = emptyList(), val created: Long = 0, val addresses: List<MemberAddr> = emptyList(),
val bestRtt: Int = 0, val rttType: Int = 0, val bestRtt: Int = 0,
val directPresence: Boolean = false, val directUp: Boolean = false, val directPresence: Boolean = false, val directUp: Boolean = false,
val indirectPresence: Boolean = false, val indirectUp: Boolean = false, val indirectPresence: Boolean = false, val indirectUp: Boolean = false,
val bgpPresence: Boolean = false) val bgpPresence: Boolean = false, val bgpRtt: Int = 0,
val bgpNodes: Int = 0)
data class MemberAddr(val family: Int = 0, val protocol: String = "", val address: String = "", 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) 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, data class LinkStatus(val linkId: Int = 0, val localFamily: Int = 0, val localAddr: String = "", val localPort: Int = 0,
val linkState: Int = 0, val linkStatus: Int = 0) val remoteFamily: Int = 0, val remoteAddr: String = "", val remotePort: Int = 0,
val linkState: Int = 0, val linkStatus: Int = 0,
val isServer: Boolean = false, val socketName: String = "") {
val stateLabel: String get() = when (linkState) { 2 -> "UP"; 1 -> "CONNECTING"; else -> "DOWN" }
}
data class MemberLinks(val connected: Boolean = false, val initialized: Boolean = false, data class MemberLinks(val connected: Boolean = false, val initialized: Boolean = false,
val linksUp: Int = 0, val links: List<LinkStatus> = emptyList()) val linksUp: Int = 0, val links: List<LinkStatus> = emptyList())
@ -83,11 +97,128 @@ data class MemberLinks(val connected: Boolean = false, val initialized: Boolean
data class LocalSocketState( data class LocalSocketState(
val sockId: Int = 0, val ifName: String = "", val bindIp: String = "", val sockId: Int = 0, val ifName: String = "", val bindIp: String = "",
val port: Int = 0, val protocol: String = "", val state: Int = 0, val port: Int = 0, val protocol: String = "", val state: Int = 0,
val linksCount: Int = 0 val linksUp: Int = 0, val linksDown: Int = 0
) { ) {
val stateLabel: String get() = when (state) { 2 -> "UP"; 1 -> "CONNECTING"; else -> "DOWN" } val stateLabel: String get() = when (state) { 2 -> "UP"; 1 -> "CONNECTING"; else -> "DOWN" }
} }
data class ConnInfo(
val nodeId: Long, val name: String, val linksUp: Boolean = false,
val initialized: Boolean = false, val proxyClient: Boolean = false,
val proxyServer: Boolean = false, val linkCount: Int = 0, val rtt: Int = 0
) {
val status: String get() = if (linksUp) "UP" else if (initialized) "init" else "DOWN"
val rttDisplay: String get() = if (rtt > 0) String.format("%.1f ms", rtt / 10f) else "-"
val proxyRole: String get() = when { proxyClient && proxyServer -> "CS"; proxyClient -> "C"; proxyServer -> "S"; else -> "" }
}
/* Format: [count:2][entry:46B]* */
fun parseConnList(data: ByteArray): List<ConnInfo> {
if (data.size < 2) return emptyList()
val buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
val count = buf.getShort().toInt() and 0xFFFF
val list = mutableListOf<ConnInfo>()
for (i in 0 until count) {
if (buf.position() + 46 > data.size) break
val nodeId = buf.getLong()
val flags = buf.get().toInt() and 0xFF
val linkCount = buf.get().toInt() and 0xFF
val rtt = buf.getShort().toInt() and 0xFFFF
buf.getShort()
val nameBytes = ByteArray(32); buf.get(nameBytes)
val name = String(nameBytes).trimEnd('\u0000').trimEnd()
if (nodeId == 0L) continue
list.add(ConnInfo(
nodeId = nodeId, name = name.ifEmpty { "%016X".format(nodeId) },
linksUp = (flags and 1) != 0, initialized = (flags and 2) != 0,
proxyClient = (flags and 4) != 0, proxyServer = (flags and 8) != 0,
linkCount = linkCount, rtt = rtt
))
}
return list
}
/* ── Member wire format (79 bytes, from chat_member.h) ── */
data class MemberWire(
val nodeId: Long, val online: Int, val isSelf: Boolean,
val flags: Int, val rtt: Int, val natType: Int,
val storage: Boolean, val name: String
)
fun parseMemberWire(data: ByteArray, offset: Int = 0): MemberWire? {
if (offset + 79 > data.size) return null
val buf = ByteBuffer.wrap(data, offset, 79).order(ByteOrder.LITTLE_ENDIAN)
val nodeId = buf.getLong()
val online = buf.get().toInt() and 0xFF
val isSelf = buf.get().toInt() != 0
val flags = buf.get().toInt() and 0xFF
val rtt = buf.getShort().toInt() and 0xFFFF
val natType = buf.get().toInt() and 0xFF
val storage = buf.get().toInt() != 0
val nameBytes = ByteArray(64); buf.get(nameBytes)
val name = String(nameBytes).trimEnd('\u0000')
return MemberWire(nodeId, online, isSelf, flags, rtt, natType, storage, name)
}
fun MemberWire.toChatMember(): ChatMember = ChatMember(
nodeId = nodeId, name = name, online = online != 0,
isAdmin = (flags and 2) != 0, isSuper = (flags and 1) != 0,
isModer = (flags and 4) != 0, isStorage = storage,
rtt = if (rtt == 0xFFFF) 0 else rtt, isSelf = isSelf
)
fun cleanSocketName(raw: String): String =
raw.removePrefix("as_").replace(Regex("_v[46]_(tcp|udp)$"), " $1")
/* ── MEMBER_DETAIL (event 22) link format: 43 bytes per link ── */
private const val MEMBER_DETAIL_LINK_SIZE = 43
private fun addrFromBytes(family: Int, bytes: ByteArray): String {
if (family == 4 && bytes.size >= 4)
return "${bytes[0].toInt() and 0xFF}.${bytes[1].toInt() and 0xFF}.${bytes[2].toInt() and 0xFF}.${bytes[3].toInt() and 0xFF}"
if (family == 6 && bytes.size >= 16)
return bytes.take(16).joinToString(":") { "%02x".format(it) }
return "?"
}
fun parseMemberDetail(data: ByteArray): MemberLinks? {
if (data.size < 12) return null
val buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
buf.getLong()
val flags = buf.get().toInt() and 0xFF
buf.getShort()
val linkCount = buf.get().toInt() and 0xFF
val links = mutableListOf<LinkStatus>()
var offset = 12
for (i in 0 until linkCount) {
if (offset + MEMBER_DETAIL_LINK_SIZE > data.size) break
val lbuf = ByteBuffer.wrap(data, offset, MEMBER_DETAIL_LINK_SIZE).order(ByteOrder.LITTLE_ENDIAN)
val isTcp = lbuf.get().toInt() and 0xFF
val state = lbuf.get().toInt() and 0xFF
val status = lbuf.get().toInt() and 0xFF
val tcpReady = lbuf.get().toInt() and 0xFF
val isServer = lbuf.get().toInt() and 0xFF
val lf = lbuf.get().toInt() and 0xFF; val la = ByteArray(16); lbuf.get(la)
val lp = lbuf.getShort().toInt() and 0xFFFF
val rf = lbuf.get().toInt() and 0xFF; val ra = ByteArray(16); lbuf.get(ra)
val rp = lbuf.getShort().toInt() and 0xFFFF
val effState = if (isTcp != 0 && tcpReady != 0) 3 else state
val effStatus = if (isTcp != 0 && tcpReady != 0) 1 else status
links.add(LinkStatus(
localFamily = lf, localAddr = addrFromBytes(lf, la), localPort = lp,
remoteFamily = rf, remoteAddr = addrFromBytes(rf, ra), remotePort = rp,
linkState = effState, linkStatus = effStatus, isServer = isServer != 0
))
offset += MEMBER_DETAIL_LINK_SIZE
}
return MemberLinks(
connected = (flags and 1) != 0, initialized = (flags and 2) != 0,
linksUp = if ((flags and 4) != 0) 1 else 0, links = links
)
}
class ChatRepository { class ChatRepository {
private val myNodeId: Long = NativeLib.getMyNodeId() private val myNodeId: Long = NativeLib.getMyNodeId()
@ -133,20 +264,18 @@ class ChatRepository {
var displayText = text var displayText = text
var fileSize = 0L var fileSize = 0L
/* Parse download state from localAttrs */
var downloadState = "" var downloadState = ""
val localAttrs = obj.optString("localAttrs", "") val localAttrs = obj.optString("localAttrs", "")
if (filePath.isNotEmpty()) downloadState = "downloaded" if (filePath.isNotEmpty()) downloadState = "downloaded"
else if (localAttrs.contains("\"st\":\"fl\"")) downloadState = "downloaded" else if (localAttrs.contains("\"st\":\"fl\"")) downloadState = "downloaded"
/* Parse base64 waveform from voice message text (format: "wf=<100 chars A-Za-z0-9+/>;dur=1234;") */
var waveform = emptyList<Float>() var waveform = emptyList<Float>()
var voiceDurationMs = 0 var voiceDurationMs = 0
if (contentType == "audio/opus" && text.startsWith("wf=")) { if (contentType == "audio/opus" && text.startsWith("wf=")) {
try { try {
val wfEnd = text.indexOf(";dur=") val wfEnd = text.indexOf(";dur=")
if (wfEnd > 0) { if (wfEnd > 0) {
val wfRaw = text.substring(3, wfEnd) // 100 base64 chars val wfRaw = text.substring(3, wfEnd)
waveform = if (wfRaw.length >= 50) { waveform = if (wfRaw.length >= 50) {
wfRaw.map { c -> wfRaw.map { c ->
val idx = when (c) { val idx = when (c) {
@ -158,7 +287,6 @@ class ChatRepository {
(idx.toFloat() / 63f).coerceIn(0f, 1f) (idx.toFloat() / 63f).coerceIn(0f, 1f)
} }
} else { } else {
/* old CSV format fallback */
val parts = wfRaw.split(",") val parts = wfRaw.split(",")
if (parts.size >= 2) { if (parts.size >= 2) {
parts.map { s -> parts.map { s ->
@ -174,7 +302,6 @@ class ChatRepository {
} catch (_: Exception) {} } catch (_: Exception) {}
} }
/* Parse file attachment: "filename.ext|file_size|block_size|num_blocks|sigs" */
if (contentType == "application/octet-stream") { if (contentType == "application/octet-stream") {
try { try {
val pipeIdx = text.indexOf('|') val pipeIdx = text.indexOf('|')
@ -226,6 +353,7 @@ class ChatRepository {
online = obj.optInt("on", 0) != 0, online = obj.optInt("on", 0) != 0,
isAdmin = obj.optInt("ad", 0) != 0, isAdmin = obj.optInt("ad", 0) != 0,
isSuper = obj.optInt("sp", 0) != 0, isSuper = obj.optInt("sp", 0) != 0,
isModer = obj.optInt("md", 0) != 0,
isStorage = obj.optInt("st", 0) != 0, isStorage = obj.optInt("st", 0) != 0,
nodeType = obj.optInt("tp", 0), nodeType = obj.optInt("tp", 0),
rtt = obj.optInt("rt", 0), rtt = obj.optInt("rt", 0),
@ -270,8 +398,8 @@ class ChatRepository {
fun setMemberNick(channelId: String, nodeId: Long, nick: String) = fun setMemberNick(channelId: String, nodeId: Long, nick: String) =
NativeLib.setMemberNick(channelId, nodeId, nick) NativeLib.setMemberNick(channelId, nodeId, nick)
fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) = fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean) =
NativeLib.setMemberFlags(channelId, nodeId, admin, supernode, storage) NativeLib.setMemberFlags(channelId, nodeId, admin, supernode, moder, storage)
fun getMemberLinks(nodeId: Long): MemberLinks { fun getMemberLinks(nodeId: Long): MemberLinks {
try { try {
@ -283,11 +411,17 @@ class ChatRepository {
for (i in 0 until arr.length()) { for (i in 0 until arr.length()) {
val a = arr.getJSONObject(i) val a = arr.getJSONObject(i)
links.add(LinkStatus( links.add(LinkStatus(
family = a.optInt("fm", 0), linkId = a.optInt("id", 0),
address = a.optString("ad", ""), localFamily = a.optInt("lf", 0),
port = a.optInt("po", 0), localAddr = a.optString("la", ""),
localPort = a.optInt("lp", 0),
remoteFamily = a.optInt("rf", 0),
remoteAddr = a.optString("ra", ""),
remotePort = a.optInt("rp", 0),
linkState = a.optInt("st", 0), linkState = a.optInt("st", 0),
linkStatus = a.optInt("ls", 0) linkStatus = a.optInt("ls", 0),
isServer = a.optInt("is", 0) != 0,
socketName = a.optString("sn", "")
)) ))
} }
} }
@ -307,16 +441,20 @@ class ChatRepository {
val list = mutableListOf<LocalSocketState>() val list = mutableListOf<LocalSocketState>()
for (i in 0 until arr.length()) { for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i) val obj = arr.getJSONObject(i)
val socketName = obj.optString("nm", "")
val cleanName = socketName.removePrefix("as_").replace(Regex("_v[46]_(tcp|udp)$"), "")
list.add(LocalSocketState( list.add(LocalSocketState(
sockId = obj.optInt("si", 0), sockId = obj.optInt("si", 0),
ifName = obj.optString("nm", ""), ifName = cleanName,
bindIp = obj.optString("ad", ""), bindIp = obj.optString("ad", ""),
port = obj.optInt("po", 0), port = obj.optInt("po", 0),
protocol = obj.optString("pt", ""), protocol = obj.optString("pt", ""),
state = obj.optInt("st", 0), state = obj.optInt("st", 0),
linksCount = obj.optInt("lk", 0) linksUp = obj.optInt("lu", 0),
linksDown = obj.optInt("ld", 0)
)) ))
} }
list.sortWith(compareBy<LocalSocketState> { it.ifName }.thenBy { it.bindIp }.thenBy { it.protocol }.thenBy { it.port })
return list return list
} catch (e: Exception) { Log.w("utun-gui", "getLocalSockets error", e) } } catch (e: Exception) { Log.w("utun-gui", "getLocalSockets error", e) }
return emptyList() return emptyList()

6
tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt

@ -81,8 +81,8 @@ object NativeLib {
fun getChannelMembers(channelId: String): String = nativeGetChannelMembers(channelId) ?: "[]" fun getChannelMembers(channelId: String): String = nativeGetChannelMembers(channelId) ?: "[]"
fun getMemberDetail(channelId: String, nodeId: Long): String = nativeGetMemberDetail(channelId, nodeId) ?: "{}" fun getMemberDetail(channelId: String, nodeId: Long): String = nativeGetMemberDetail(channelId, nodeId) ?: "{}"
fun setMemberNick(channelId: String, nodeId: Long, nick: String) { nativeSetMemberNick(channelId, nodeId, nick) } fun setMemberNick(channelId: String, nodeId: Long, nick: String) { nativeSetMemberNick(channelId, nodeId, nick) }
fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) { fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean) {
nativeSetMemberFlags(channelId, nodeId, admin, supernode, storage) nativeSetMemberFlags(channelId, nodeId, admin, supernode, moder, storage)
} }
fun getMemberLinks(nodeId: Long): String = nativeGetMemberLinks(nodeId) ?: "{}" fun getMemberLinks(nodeId: Long): String = nativeGetMemberLinks(nodeId) ?: "{}"
@ -145,7 +145,7 @@ object NativeLib {
private external fun nativeGetChannelMembers(channelId: String): String? private external fun nativeGetChannelMembers(channelId: String): String?
private external fun nativeGetMemberDetail(channelId: String, nodeId: Long): String? private external fun nativeGetMemberDetail(channelId: String, nodeId: Long): String?
private external fun nativeSetMemberNick(channelId: String, nodeId: Long, nick: 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 nativeSetMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean)
private external fun nativeGetMemberLinks(nodeId: Long): String? private external fun nativeGetMemberLinks(nodeId: Long): String?
/* ── Local sockets JNI ── */ /* ── Local sockets JNI ── */

214
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt

@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.utun.chat.data.Channel import com.utun.chat.data.Channel
@ -29,6 +30,7 @@ import com.utun.chat.data.MemberDetail
import com.utun.chat.data.MemberAddr import com.utun.chat.data.MemberAddr
import com.utun.chat.data.MemberLinks import com.utun.chat.data.MemberLinks
import com.utun.chat.data.LinkStatus import com.utun.chat.data.LinkStatus
import com.utun.chat.data.LocalSocketState
import com.utun.chat.viewmodel.ChatViewModel import com.utun.chat.viewmodel.ChatViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -46,6 +48,7 @@ fun MemberListScreen(
LaunchedEffect(channel.id) { viewModel.refreshMembers(channel.id) } LaunchedEffect(channel.id) { viewModel.refreshMembers(channel.id) }
val memberLinks by viewModel.memberLinks.collectAsState() val memberLinks by viewModel.memberLinks.collectAsState()
val localSockets by viewModel.localSockets.collectAsState()
LaunchedEffect(selectedMember?.nodeId) { LaunchedEffect(selectedMember?.nodeId) {
val nodeId = selectedMember?.nodeId val nodeId = selectedMember?.nodeId
@ -89,6 +92,7 @@ fun MemberListScreen(
MemberDetailCard( MemberDetailCard(
detail = detail, detail = detail,
memberLinks = memberLinks, memberLinks = memberLinks,
localSocketStates = localSockets,
isSelf = members.any { it.nodeId == detail.nodeId && it.isSelf }, isSelf = members.any { it.nodeId == detail.nodeId && it.isSelf },
onDismiss = { } onDismiss = { }
) )
@ -100,10 +104,10 @@ fun MemberListScreen(
MemberEditDialog( MemberEditDialog(
member = editMember!!, member = editMember!!,
onDismiss = { showEditDialog = false }, onDismiss = { showEditDialog = false },
onSave = { nick, admin, supernode, storage -> onSave = { nick, admin, supernode, moder, storage ->
if (nick != editMember!!.name) viewModel.setMemberNick(channel.id, editMember!!.nodeId, nick) if (nick != editMember!!.name) viewModel.setMemberNick(channel.id, editMember!!.nodeId, nick)
if (admin != editMember!!.isAdmin || supernode != editMember!!.isSuper || storage != editMember!!.isStorage) if (admin != editMember!!.isAdmin || supernode != editMember!!.isSuper || moder != editMember!!.isModer || storage != editMember!!.isStorage)
viewModel.setMemberFlags(channel.id, editMember!!.nodeId, admin, supernode, storage) viewModel.setMemberFlags(channel.id, editMember!!.nodeId, admin, supernode, moder, storage)
showEditDialog = false showEditDialog = false
} }
) )
@ -188,20 +192,22 @@ private fun MemberListItem(
} }
} }
fun formatAddr(addr: String): String {
if (addr.isEmpty() || addr == "?") return addr
val trimmed = addr.trim('[', ']')
if (trimmed.all { it == '0' || it == ':' }) return "[::]"
if (trimmed == "::") return "[::]"
return trimmed
}
@Composable @Composable
private fun MemberDetailCard( private fun MemberDetailCard(
detail: MemberDetail, detail: MemberDetail,
memberLinks: MemberLinks, memberLinks: MemberLinks,
localSocketStates: List<LocalSocketState>,
isSelf: Boolean = false, isSelf: Boolean = false,
onDismiss: () -> Unit 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( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -220,106 +226,139 @@ private fun MemberDetailCard(
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text(detail.name, fontWeight = FontWeight.Bold, fontSize = 15.sp) Text(detail.name, fontWeight = FontWeight.Bold, fontSize = 15.sp)
if (isSelf) {
Spacer(Modifier.width(4.dp))
Text("✓", fontSize = 12.sp, color = Color(0xFF4CAF50))
}
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( val headerParts = mutableListOf("0x${detail.nodeId.toString(16).padStart(16, '0')}")
text = "ID: 0x${detail.nodeId.toString(16).padStart(16, '0')}",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = Color.Gray
)
if (detail.lastSeen > 0) { if (detail.lastSeen > 0) {
val sec = System.currentTimeMillis() / 1000 - detail.lastSeen val sec = System.currentTimeMillis() / 1000 - detail.lastSeen
val ago = when { val ago = when {
sec < 60 -> "${sec}s ago" sec < 60 -> "${sec}s"
sec < 3600 -> "${sec / 60}m ago" sec < 3600 -> "${sec / 60}m"
sec < 86400 -> "${sec / 3600}h ago" sec < 86400 -> "${sec / 3600}h"
else -> "${sec / 86400}d ago" else -> "${sec / 86400}d"
} }
Text("Last seen: $ago", fontSize = 12.sp, color = Color.Gray) headerParts.add("seen $ago")
} }
if (detail.created > 0) { if (detail.created > 0) {
val sec = System.currentTimeMillis() / 1000 - detail.created val sec = System.currentTimeMillis() / 1000 - detail.created
val ago = when { val ago = when {
sec < 604800 -> "${sec / 86400}d ago" sec < 604800 -> "${sec / 86400}d"
else -> "${sec / 2592000}mo ago" else -> "${sec / 2592000}mo"
} }
Text("Created: $ago", fontSize = 12.sp, color = Color.Gray) headerParts.add("created $ago")
} }
Text(
text = headerParts.joinToString(" · "),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = Color.Gray
)
if (detail.addresses.isNotEmpty()) { if (isSelf) {
Spacer(Modifier.height(6.dp)) if (localSocketStates.isNotEmpty()) {
Text("Addresses:", fontSize = 13.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.height(6.dp))
detail.addresses.forEach { a -> Text("Listen sockets:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
if (isSelf) { localSocketStates.forEach { sk ->
Text( val dot = if (sk.state == 2) Color(0xFF4CAF50) else Color(0xFF9E9E9E)
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
)
} else {
val dcol = linkDotColor(a)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Box( Box(Modifier.size(8.dp).clip(CircleShape).background(dot))
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(dcol)
)
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text( Text(
text = "${a.protocol} ${a.address}:${a.port}" + text = "*[${sk.linksUp}/${sk.linksDown}] ${sk.protocol} ${sk.ifName} ${sk.bindIp}:${sk.port}",
(if (a.rtt > 0) " (${a.rtt / 10}ms)" else ""),
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 11.sp, fontSize = 13.sp,
color = Color.DarkGray color = Color.DarkGray
) )
} }
} }
} }
} } else {
val rttTypeName = when (detail.rttType) { 0 -> "interface" 1 -> "nat" 2 -> "real" else -> "?" } if (detail.addresses.isNotEmpty()) {
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text("Connection:", fontSize = 13.sp, fontWeight = FontWeight.Medium) Text("Addresses:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
if (isSelf) { detail.addresses.forEach { a ->
Row(verticalAlignment = Alignment.CenterVertically) { Text(
Text("✓", fontSize = 12.sp, color = Color(0xFF4CAF50)) text = "${a.protocol} ${a.address}:${a.port}",
Spacer(Modifier.width(4.dp)) fontFamily = FontFamily.Monospace,
Text("self (local)", fontSize = 12.sp, color = Color(0xFF4CAF50)) fontSize = 13.sp,
color = Color.DarkGray
)
}
} }
} else if (detail.directPresence) {
Row(verticalAlignment = Alignment.CenterVertically) { if (memberLinks.links.isNotEmpty()) {
Box(Modifier.size(8.dp).clip(CircleShape) Spacer(Modifier.height(6.dp))
.background(if (detail.directUp) Color(0xFF4CAF50) else Color(0xFFE53935))) Text("Connections:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.width(6.dp)) memberLinks.links.forEachIndexed { idx, lk ->
Text("Direct: ${if (detail.directUp) "UP" else "DOWN"}" + val dcol = if (lk.linkState == 3 && lk.linkStatus == 1) Color(0xFF4CAF50) else Color(0xFFE53935)
(if (detail.directUp && detail.bestRtt > 0) " ${detail.bestRtt}ms ($rttTypeName)" else ""), val arrow = if (lk.isServer) "◀" else "▶"
fontSize = 12.sp, color = Color.DarkGray) val arrowColor = if (lk.isServer) Color(0xFFFF9800) else Color(0xFF2196F3)
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(dcol))
Spacer(Modifier.width(6.dp))
Text("${formatAddr(lk.localAddr)}:${lk.localPort}",
modifier = Modifier.weight(1f),
textAlign = TextAlign.Start,
fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = Color.DarkGray)
Spacer(Modifier.width(4.dp))
Text(arrow, fontWeight = FontWeight.Bold, color = arrowColor, fontSize = 16.sp)
Spacer(Modifier.width(4.dp))
Text("${formatAddr(lk.remoteAddr)}:${lk.remotePort}",
modifier = Modifier.weight(1f),
textAlign = TextAlign.Start,
fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = Color.DarkGray)
}
val cleanSn = com.utun.chat.data.cleanSocketName(lk.socketName)
if (cleanSn.isNotEmpty()) {
Row {
Spacer(Modifier.width(14.dp))
Text(cleanSn, fontSize = 12.sp, color = Color.Gray)
}
}
}
if (idx < memberLinks.links.lastIndex) Spacer(Modifier.height(4.dp))
}
} }
}
if (detail.indirectPresence) { Spacer(Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Text("Connection:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
Box(Modifier.size(8.dp).clip(CircleShape) if (detail.directPresence) {
.background(if (detail.indirectUp) Color(0xFF4CAF50) else Color(0xFFE53935))) Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.width(6.dp)) Box(Modifier.size(8.dp).clip(CircleShape)
Text("Indirect: ${if (detail.indirectUp) "UP" else "DOWN"}", .background(if (detail.directUp) Color(0xFF4CAF50) else Color(0xFFE53935)))
fontSize = 12.sp, color = Color.DarkGray) Spacer(Modifier.width(6.dp))
Text("Direct: ${if (detail.directUp) "UP" else "DOWN"}" +
(if (detail.directUp && detail.bestRtt > 0) " ${detail.bestRtt / 10}ms" else ""),
fontSize = 13.sp, color = Color.DarkGray)
}
} }
} if (detail.indirectPresence) {
if (detail.bgpPresence) { Row(verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically) { Box(Modifier.size(8.dp).clip(CircleShape)
Box(Modifier.size(8.dp).clip(CircleShape).background(Color(0xFF4CAF50))) .background(if (detail.indirectUp) Color(0xFF4CAF50) else Color(0xFFE53935)))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("BGP: visible", fontSize = 12.sp, color = Color.DarkGray) Text("Indirect: ${if (detail.indirectUp) "UP" else "DOWN"}",
fontSize = 13.sp, color = Color.DarkGray)
}
}
if (detail.bgpPresence) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(Color(0xFF4CAF50)))
Spacer(Modifier.width(6.dp))
Text("BGP: ${detail.bgpNodes} nodes" +
(if (detail.bestRtt > 0) " ${detail.bestRtt}ms" else ""),
fontSize = 13.sp, color = Color.DarkGray)
}
}
if (!detail.directPresence && !detail.indirectPresence && !detail.bgpPresence) {
Text("offline", fontSize = 13.sp, color = Color.Gray)
} }
}
if (!isSelf && !detail.directPresence && !detail.indirectPresence && !detail.bgpPresence) {
Text("offline", fontSize = 12.sp, color = Color.Gray)
} }
} }
} }
@ -329,11 +368,12 @@ private fun MemberDetailCard(
private fun MemberEditDialog( private fun MemberEditDialog(
member: ChatMember, member: ChatMember,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (nick: String, admin: Boolean, supernode: Boolean, storage: Boolean) -> Unit onSave: (nick: String, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean) -> Unit
) { ) {
var nick by remember { mutableStateOf(member.name) } var nick by remember { mutableStateOf(member.name) }
var admin by remember { mutableStateOf(member.isAdmin) } var admin by remember { mutableStateOf(member.isAdmin) }
var supernode by remember { mutableStateOf(member.isSuper) } var supernode by remember { mutableStateOf(member.isSuper) }
var moder by remember { mutableStateOf(member.isModer) }
var storage by remember { mutableStateOf(member.isStorage) } var storage by remember { mutableStateOf(member.isStorage) }
AlertDialog( AlertDialog(
@ -358,6 +398,10 @@ private fun MemberEditDialog(
Checkbox(checked = supernode, onCheckedChange = { supernode = it }) Checkbox(checked = supernode, onCheckedChange = { supernode = it })
Text("Supernode", modifier = Modifier.clickable { supernode = !supernode }) Text("Supernode", modifier = Modifier.clickable { supernode = !supernode })
} }
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = moder, onCheckedChange = { moder = it })
Text("Moderator", modifier = Modifier.clickable { moder = !moder })
}
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = storage, onCheckedChange = { storage = it }) Checkbox(checked = storage, onCheckedChange = { storage = it })
Text("Storage", modifier = Modifier.clickable { storage = !storage }) Text("Storage", modifier = Modifier.clickable { storage = !storage })
@ -365,7 +409,7 @@ private fun MemberEditDialog(
} }
}, },
confirmButton = { confirmButton = {
TextButton(onClick = { onSave(nick, admin, supernode, storage) }) { Text("Save") } TextButton(onClick = { onSave(nick, admin, supernode, moder, storage) }) { Text("Save") }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") } TextButton(onClick = onDismiss) { Text("Cancel") }

5
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt

@ -233,9 +233,10 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
Text(s.stateLabel, style = MaterialTheme.typography.labelSmall, color = stateColor) Text(s.stateLabel, style = MaterialTheme.typography.labelSmall, color = stateColor)
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text(s.protocol, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(s.protocol, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (s.linksCount > 0) { if (s.linksUp + s.linksDown > 0) {
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text("${s.linksCount} links", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text("*[${s.linksUp}/${s.linksDown}]", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
} }

83
tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

@ -205,15 +205,82 @@ class ChatViewModel : ViewModel() {
_nodeStatuses.value = _nodeStatuses.value + (ns.nodeId to ns) _nodeStatuses.value = _nodeStatuses.value + (ns.nodeId to ns)
val cur = _currentChannel.value val cur = _currentChannel.value
if (cur != null) refreshMembers(cur.id) if (cur != null) refreshMembers(cur.id)
val sel = _selectedMember.value
if (sel != null && sel.nodeId == ns.nodeId)
_selectedMember.value = sel.copy(
online = ns.online, bestRtt = ns.bestRtt,
directPresence = ns.directPresence, directUp = ns.directUp,
indirectPresence = ns.indirectPresence, indirectUp = ns.indirectUp,
bgpPresence = ns.bgpPresence, bgpRtt = ns.bgpRtt, bgpNodes = ns.bgpNodes)
} }
17 -> { /* LINK_STATUS_CHANGED: [peer_id:8][link_id:1][state:1][status:1][links_up:1][initialized:1] */ 17 -> { /* LINK_STATUS_CHANGED: [peer_id:8][link_id:1][state:1][status:1][links_up:1][initialized:1] */
if (data == null || data.size < 13) return if (data == null || data.size < 13) return
val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN) val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val nodeId = buf.getLong() val nodeId = buf.getLong()
val linkId = buf.get().toInt() and 0xFF
val state = buf.get().toInt() and 0xFF
val status = buf.get().toInt() and 0xFF
val linksUp = buf.get().toInt() and 0xFF
val initialized = buf.get().toInt() and 0xFF
LogManager.addLog("DEBUG", "VM", "LINK_STATUS_CHANGED node=0x%x link=%d st=%d ls=%d up=%d init=%d"
.format(nodeId, linkId, state, status, linksUp, initialized.toInt()))
val curNs = _nodeStatuses.value[nodeId] ?: NodeStatus(nodeId = nodeId)
_nodeStatuses.value = _nodeStatuses.value + (nodeId to curNs.copy(
directUp = linksUp > 0, directPresence = initialized != 0))
val detail = _selectedMember.value val detail = _selectedMember.value
if (detail != null && detail.nodeId == nodeId) refreshMemberLinks(nodeId) if (detail != null && detail.nodeId == nodeId) {
_selectedMember.value = detail.copy(
directUp = linksUp > 0, directPresence = initialized != 0,
online = initialized != 0 && linksUp > 0)
val curLinks = _memberLinks.value
val updated = curLinks.links.map { lk ->
if (lk.linkId == linkId) lk.copy(linkState = state, linkStatus = status) else lk
}
val found = curLinks.links.any { it.linkId == linkId }
_memberLinks.value = curLinks.copy(linksUp = linksUp, links = updated)
if (!found || status != 1) refreshMemberLinks(nodeId)
}
val cur = _currentChannel.value
if (cur != null) refreshMembers(cur.id)
} }
19 -> { if (repo != null) refreshLocalSockets() } /* LOCAL_SOCKETS */ 19 -> { if (repo != null) refreshLocalSockets() } /* LOCAL_SOCKETS */
24 -> { /* MEMBER_UPDATED: [ch_id_len:1][ch_id:var][count:1][member:79B] */
if (data == null || data.size < 3 + 79) return
val cl = data[0].toInt() and 0xFF
val w = parseMemberWire(data, 1 + cl + 1) ?: return
val m = w.toChatMember()
val cur = _currentChannel.value
if (cur != null && cur.id == String(data, 1, cl)) {
_members.value = _members.value.toMutableList().apply {
val idx = indexOfFirst { it.nodeId == w.nodeId }
if (idx >= 0) set(idx, m) else add(m)
}
}
val sel = _selectedMember.value
if (sel != null && sel.nodeId == w.nodeId)
_selectedMember.value = sel.copy(online = w.online != 0)
}
25 -> { /* MEMBER_REMOVED: [ch_id_len:1][ch_id:var][node_id:8] */
if (data == null || data.size < 10) return
val cl = data[0].toInt() and 0xFF
val buf = java.nio.ByteBuffer.wrap(data, 1 + cl, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val nodeId = buf.getLong()
val cur = _currentChannel.value
if (cur != null && cur.id == String(data, 1, cl))
_members.value = _members.value.filter { it.nodeId != nodeId }
if (_selectedMember.value?.nodeId == nodeId)
_selectedMember.value = null
}
22 -> { /* MEMBER_DETAIL: бинарный снапшот links+sockets */
if (data == null || data.size < 12) return
val ml = parseMemberDetail(data) ?: return
val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val nodeId = buf.getLong()
if (_selectedMember.value?.nodeId == nodeId) {
_memberLinks.value = ml
_selectedMember.value = _selectedMember.value?.copy(online = ml.connected && ml.linksUp > 0)
}
}
} }
} }
@ -262,10 +329,11 @@ class ChatViewModel : ViewModel() {
_members.value = raw.map { m -> _members.value = raw.map { m ->
val ns = statuses[m.nodeId] val ns = statuses[m.nodeId]
if (ns != null) m.copy( if (ns != null) m.copy(
online = ns.online, rtt = ns.bestRtt, rttType = ns.rttType, online = ns.online, rtt = ns.bestRtt,
directUp = ns.directUp, indirectUp = ns.indirectUp, directUp = ns.directUp, indirectUp = ns.indirectUp,
directPresence = ns.directPresence, indirectPresence = ns.indirectPresence, directPresence = ns.directPresence, indirectPresence = ns.indirectPresence,
bgpPresence = ns.bgpPresence bgpPresence = ns.bgpPresence, bgpRtt = ns.bgpRtt,
bgpNodes = ns.bgpNodes
) else m ) else m
} }
} }
@ -277,10 +345,11 @@ class ChatViewModel : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
val d = withContext(Dispatchers.IO) { r.getMemberDetail(channelId, nodeId) } val d = withContext(Dispatchers.IO) { r.getMemberDetail(channelId, nodeId) }
if (d != null && ns != null) _selectedMember.value = d.copy( if (d != null && ns != null) _selectedMember.value = d.copy(
online = ns.online, bestRtt = ns.bestRtt, rttType = ns.rttType, online = ns.online, bestRtt = ns.bestRtt,
directPresence = ns.directPresence, directUp = ns.directUp, directPresence = ns.directPresence, directUp = ns.directUp,
indirectPresence = ns.indirectPresence, indirectUp = ns.indirectUp, indirectPresence = ns.indirectPresence, indirectUp = ns.indirectUp,
bgpPresence = ns.bgpPresence bgpPresence = ns.bgpPresence, bgpRtt = ns.bgpRtt,
bgpNodes = ns.bgpNodes
) )
else _selectedMember.value = d else _selectedMember.value = d
} }
@ -292,9 +361,9 @@ class ChatViewModel : ViewModel() {
refreshMembers(channelId) refreshMembers(channelId)
} }
fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) { fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean) {
val r = repo ?: return val r = repo ?: return
r.setMemberFlags(channelId, nodeId, admin, supernode, storage) r.setMemberFlags(channelId, nodeId, admin, supernode, moder, storage)
refreshMembers(channelId) refreshMembers(channelId)
} }

Loading…
Cancel
Save