From b0aba715477261dee0c38cedf4f4a373b488bd48 Mon Sep 17 00:00:00 2001
From: spacedandy-agents-simplex
<342056592+spacedandy-agents-simplex@users.noreply.github.com>
Date: Fri, 23 Oct 2026 10:42:55 +0000
Subject: [PATCH] ui: channel owners can make subscribers moderators, and
moderators see the subscriber list
Core already lets a channel owner make a subscriber a moderator; the role picker offered only subscriber and contributor. It now offers moderator too (not admin, by decision). Moderators see the full member list the owner sees, so they can reach the subscribers they may block (subscribers can't post, so there was no other way to reach them), and member info is headed Moderator or Contributor instead of Subscriber for those roles.
Run: 001-channel-signing-moderators, slice 4
---
apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift | 2 +-
apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift | 4 ++--
apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift | 2 ++
apps/ios/SimpleXChat/ChatTypes.swift | 5 +----
.../commonMain/kotlin/chat/simplex/common/model/ChatModel.kt | 5 +----
.../simplex/common/views/chat/group/ChannelMembersView.kt | 4 ++--
.../simplex/common/views/chat/group/GroupChatInfoView.kt | 4 ++--
.../simplex/common/views/chat/group/GroupMemberInfoView.kt | 2 ++
.../common/src/commonMain/moko-resources/base/strings.xml | 2 ++
9 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift
index 6b7f5b65fc..e4d788b47c 100644
--- a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift
@@ -26,7 +26,7 @@ struct ChannelMembersView: View {
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
let subscriberCount = groupInfo.groupSummary.publicMemberCount ?? Int64(members.count + 1)
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
- if groupInfo.isOwner {
+ if groupInfo.canModerate {
List {
Section(header: Text(subscriberCountStr(subscriberCount)).foregroundColor(theme.colors.secondary)) {
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
index 1be8070259..d2644e600f 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
@@ -125,7 +125,7 @@ struct GroupChatInfoView: View {
Label("Share via chat", systemImage: "arrowshape.turn.up.forward")
}
}
- if groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole >= .owner }) {
+ if groupInfo.canModerate || members.contains(where: { $0.wrapped.memberRole >= .owner }) {
channelMembersButton()
}
if groupInfo.membership.memberRole >= .moderator {
@@ -734,7 +734,7 @@ struct GroupChatInfoView: View {
}
private func channelMembersButton() -> some View {
- let label: LocalizedStringKey = groupInfo.isOwner ? "Subscribers" : "Owners & contributors"
+ let label: LocalizedStringKey = groupInfo.canModerate ? "Subscribers" : "Owners & contributors"
return NavigationLink {
ChannelMembersView(chat: chat, groupInfo: groupInfo)
.navigationTitle(label)
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
index ebbdc6b6a3..d529bc4e76 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
@@ -38,6 +38,8 @@ struct GroupMemberInfoView: View {
switch groupMember.wrapped.memberRole {
case .relay: "Relay"
case .owner: "Owner"
+ case .moderator: "Moderator"
+ case .member: "Contributor"
default: "Subscriber"
}
} else {
diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift
index 68300193d5..60d8ca1da8 100644
--- a/apps/ios/SimpleXChat/ChatTypes.swift
+++ b/apps/ios/SimpleXChat/ChatTypes.swift
@@ -3414,10 +3414,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
if groupInfo.useRelays && !groupInfo.isOwner { return nil }
let userRole = groupInfo.membership.memberRole
if groupInfo.useRelays {
- // TODO [relays]: for now owners can only set observer/member in channels.
- // Restore the full Owner-excluded picker when moderator/admin promotion is supported:
- // return GroupMemberRole.supportedRoles.filter { $0 <= userRole && $0 != .owner }
- return [.observer, .member]
+ return [.observer, .member, .moderator]
} else {
return GroupMemberRole.supportedRoles.filter { $0 <= userRole }
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
index 094615eee1..8a0f18d5de 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
@@ -2987,10 +2987,7 @@ data class GroupMember (
else if (groupInfo.useRelays && !groupInfo.isOwner) null
else groupInfo.membership.memberRole.let { userRole ->
if (groupInfo.useRelays)
- // TODO [relays]: for now owners can only set observer/member in channels.
- // Restore the full Owner-excluded picker when moderator/admin promotion is supported:
- // GroupMemberRole.selectableRoles.filter { it <= userRole && it != GroupMemberRole.Owner }
- listOf(GroupMemberRole.Observer, GroupMemberRole.Member)
+ listOf(GroupMemberRole.Observer, GroupMemberRole.Member, GroupMemberRole.Moderator)
else
GroupMemberRole.selectableRoles.filter { it <= userRole }
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt
index ea841acc86..3ad2906759 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt
@@ -44,7 +44,7 @@ fun ModalData.ChannelMembersView(
val s = searchText.value.text.trim().lowercase()
val subscriberCount = groupInfo.groupSummary.publicMemberCount ?: (members.size + 1).toLong()
val oneHandUI = remember { ChatController.appPrefs.oneHandUI.state }
- val title = if (groupInfo.isOwner) {
+ val title = if (groupInfo.canModerate) {
generalGetString(MR.strings.channel_members_title_subscribers)
} else {
generalGetString(MR.strings.channel_members_section_owners)
@@ -57,7 +57,7 @@ fun ModalData.ChannelMembersView(
item {
AppBarTitle(title)
}
- if (groupInfo.isOwner) {
+ if (groupInfo.canModerate) {
val showSearch = members.size > 8
item {
SectionView(title = subscriberCountStr(subscriberCount)) {
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt
index 973b684d38..8cbbeb18c7 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt
@@ -651,7 +651,7 @@ fun ModalData.GroupChatInfoLayout(
ModalManager.closeAllModalsEverywhere()
}
}
- if (groupInfo.isOwner || activeSortedMembers.any { it.memberRole >= GroupMemberRole.Owner }) {
+ if (groupInfo.canModerate || activeSortedMembers.any { it.memberRole >= GroupMemberRole.Owner }) {
anyTopSectionRowShow = true
ChannelMembersButton(chat.remoteHostId, groupInfo, showMemberInfo)
}
@@ -1295,7 +1295,7 @@ private fun ShareViaChatButton(onClick: () -> Unit) {
@Composable
private fun ChannelMembersButton(rhId: Long?, groupInfo: GroupInfo, showMemberInfo: (GroupMember, GroupRelay?) -> Unit) {
- val title = if (groupInfo.isOwner) {
+ val title = if (groupInfo.canModerate) {
stringResource(MR.strings.channel_members_title_subscribers)
} else {
stringResource(MR.strings.channel_members_section_owners)
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt
index ac1c6915b4..dc06d15ea6 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt
@@ -594,6 +594,8 @@ fun GroupMemberInfoLayout(
when (member.memberRole) {
GroupMemberRole.Relay -> stringResource(MR.strings.member_info_section_title_relay)
GroupMemberRole.Owner -> stringResource(MR.strings.member_info_section_title_owner)
+ GroupMemberRole.Moderator -> stringResource(MR.strings.member_info_section_title_moderator)
+ GroupMemberRole.Member -> stringResource(MR.strings.member_info_section_title_contributor)
else -> stringResource(MR.strings.member_info_section_title_subscriber)
}
} else {
diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml
index 793d72244b..b4d12e357f 100644
--- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml
@@ -3090,6 +3090,8 @@
Relay
Owner
+ Moderator
+ Contributor
Subscriber
Channel
Relay link