ios: groups miscellaneous (#843)

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
JRoberts
2022-07-27 11:16:07 +04:00
committed by GitHub
parent a4aaf36774
commit aa7e377bce
12 changed files with 265 additions and 144 deletions

View File

@@ -306,7 +306,7 @@ func apiContactInfo(contactId: Int64) async throws -> ConnectionStats? {
throw r
}
func apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64) async throws -> ConnectionStats? {
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> ConnectionStats? {
let r = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberInfo(_, _, connStats_) = r { return connStats_ }
throw r
@@ -565,9 +565,9 @@ func apiNewGroup(_ gp: GroupProfile) throws -> GroupInfo {
throw r
}
func addMember(groupId: Int64, contactId: Int64) async {
func addMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) async {
do {
try await apiAddMember(groupId: groupId, contactId: contactId, memberRole: .admin)
try await apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole)
} catch let error {
logger.error("addMember error: \(responseError(error))")
}

View File

@@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat
let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9)
let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2 )
let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2)
struct ChatInfoToolbar: View {
@Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat

View File

@@ -11,19 +11,12 @@ import SimpleXChat
private let memberImageSize: CGFloat = 34
enum ChatViewSheet: Identifiable {
case chatInfo
case addMember
var id: ChatViewSheet { get { self } }
}
struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
@State private var showChatViewSheet: Bool = false
@State private var chatViewSheet: ChatViewSheet?
@State private var showChatInfoSheet: Bool = false
@State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState()
@State private var deletingItem: ChatItem? = nil
@FocusState private var keyboardVisible: Bool
@@ -107,22 +100,15 @@ struct ChatView: View {
}
ToolbarItem(placement: .principal) {
Button {
chatViewSheet = .chatInfo
showChatViewSheet = true
showChatInfoSheet = true
} label: {
ChatInfoToolbar(chat: chat)
}
.sheet(isPresented: $showChatViewSheet) {
switch chatViewSheet {
case .chatInfo:
if case .direct = chat.chatInfo {
ChatInfoView(chat: chat, showSheet: $showChatViewSheet)
} else if case let .group(groupInfo) = chat.chatInfo {
GroupChatInfoView(chat: chat, groupInfo: groupInfo, showSheet: $showChatViewSheet)
}
case .addMember:
AddGroupMembersView(chat: chat, showSheet: $showChatViewSheet)
default: EmptyView()
.sheet(isPresented: $showChatInfoSheet) {
if case .direct = chat.chatInfo {
ChatInfoView(chat: chat, showSheet: $showChatInfoSheet)
} else if case let .group(groupInfo) = chat.chatInfo {
GroupChatInfoView(chat: chat, groupInfo: groupInfo, showSheet: $showChatInfoSheet)
}
}
}
@@ -132,8 +118,12 @@ struct ChatView: View {
callButton(contact, .audio, imageName: "phone")
callButton(contact, .video, imageName: "video")
}
} else if case .group = chat.chatInfo {
} else if case let .group(groupInfo) = chat.chatInfo,
groupInfo.canAddMembers {
addMembersButton()
.sheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo, showSheet: $showAddMembersSheet)
}
}
}
}
@@ -150,8 +140,7 @@ struct ChatView: View {
private func addMembersButton() -> some View {
Button {
chatViewSheet = .addMember
showChatViewSheet = true
showAddMembersSheet = true
} label: {
Image(systemName: "person.crop.circle.badge.plus")
}

View File

@@ -12,56 +12,53 @@ import SimpleXChat
struct AddGroupMembersView: View {
@EnvironmentObject var chatModel: ChatModel
var chat: Chat
var groupInfo: GroupInfo
@Binding var showSheet: Bool
@State private var contactsToAdd: [Contact] = []
@State private var selectedContacts = Set<Int64>()
@State private var selectedRole: GroupMemberRole = .admin
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ChatInfoToolbar(chat: chat, imageSize: 48)
.padding()
.frame(maxWidth: .infinity, alignment: .center)
.background(Color(uiColor: .quaternarySystemFill))
if (contactsToAdd.isEmpty) {
Text("No contacts to add")
.foregroundColor(.secondary)
.padding()
.frame(maxWidth: .infinity, alignment: .center)
} else {
HStack {
let count = selectedContacts.count
Button {
Task {
for contactId in selectedContacts {
await addMember(groupId: chat.chatInfo.apiId, contactId: contactId)
}
showSheet = false
}
} label: {
Label(
count > 0 ? "Invite \(count) member(s)" : "Invite new members",
systemImage: "plus")
}
.disabled(count < 1)
Spacer()
if count > 0 {
Button {
selectedContacts.removeAll()
} label: {
Label("Clear", systemImage: "multiply")
}
}
}
.padding(.horizontal)
.padding(.bottom, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(uiColor: .quaternarySystemFill))
List(contactsToAdd) { contact in
contactCheckView(contact)
NavigationView {
List {
ChatInfoToolbar(chat: chat, imageSize: 48)
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
if (contactsToAdd.isEmpty) {
Text("No contacts to add")
.foregroundColor(.secondary)
.padding()
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
} else {
let count = selectedContacts.count
Section {
rolePicker()
inviteMembersButton()
.disabled(count < 1)
} footer: {
if (count >= 1) {
HStack {
Button { selectedContacts.removeAll() } label: { Text("Clear") }
Spacer()
Text("\(count) contact(s) selected")
}
} else {
Text("No contacts selected")
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
Section {
ForEach(contactsToAdd) { contact in
contactCheckView(contact)
}
}
}
.listStyle(.plain)
}
.navigationBarHidden(true)
}
.frame(maxHeight: .infinity, alignment: .top)
.task {
@@ -78,6 +75,33 @@ struct AddGroupMembersView: View {
.sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() }
}
func inviteMembersButton() -> some View {
Button {
Task {
for contactId in selectedContacts {
await addMember(groupId: chat.chatInfo.apiId, contactId: contactId, memberRole: selectedRole)
}
showSheet = false
}
} label: {
HStack {
Text("Invite to group")
Image(systemName: "checkmark")
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
func rolePicker() -> some View {
Picker("New member role", selection: $selectedRole) {
ForEach(GroupMemberRole.allCases) { role in
if role <= groupInfo.membership.memberRole {
Text(role.text)
}
}
}
}
func contactCheckView(_ contact: Contact) -> some View {
let checked = selectedContacts.contains(contact.apiId)
return Button {
@@ -92,10 +116,11 @@ struct AddGroupMembersView: View {
.frame(width: 30, height: 30)
.padding(.trailing, 2)
Text(ChatInfo.direct(contact: contact).chatViewName)
.foregroundColor(.primary)
.lineLimit(1)
Spacer()
Image(systemName: checked ? "checkmark.circle.fill": "circle")
.foregroundColor(checked ? .accentColor : .secondary)
.foregroundColor(checked ? .accentColor : Color(uiColor: .tertiaryLabel))
}
}
}
@@ -104,6 +129,6 @@ struct AddGroupMembersView: View {
struct AddGroupMembersView_Previews: PreviewProvider {
static var previews: some View {
@State var showSheet = true
return AddGroupMembersView(chat: Chat(chatInfo: ChatInfo.sampleData.group), showSheet: $showSheet)
return AddGroupMembersView(chat: Chat(chatInfo: ChatInfo.sampleData.group), groupInfo: GroupInfo.sampleData, showSheet: $showSheet)
}
}

View File

@@ -11,8 +11,11 @@ import SimpleXChat
struct GroupMemberInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
var groupInfo: GroupInfo
var member: GroupMember
@State private var alert: GroupMemberInfoViewAlert? = nil
@State private var alert: GroupMemberInfoViewAlert?
@State private var connectionStats: ConnectionStats?
enum GroupMemberInfoViewAlert: Identifiable {
case removeMemberAlert
@@ -26,20 +29,34 @@ struct GroupMemberInfoView: View {
groupMemberInfoHeader()
.listRowBackground(Color.clear)
// TODO server status
Section(header: Text("Info")) {
Text("Role: ") + Text(member.memberRole.text)
Section("Member") {
infoRow("Group", groupInfo.displayName)
// TODO change role
// localizedInfoRow("Role", member.memberRole.text)
// TODO invited by - need to get contact by contact id
Text("Status: ") + Text(member.memberStatus.text)
if let conn = member.activeConn {
let connLevelDesc = conn.connLevel == 0 ? "Direct" : "Indirect (\(conn.connLevel))"
Text("Connection level: \(connLevelDesc)")
let connLevelDesc = conn.connLevel == 0 ? "direct" : "indirect (\(conn.connLevel))"
infoRow("Connection", connLevelDesc)
}
}
if let connStats = connectionStats {
Section("Servers") {
// TODO network connection status
smpServers("receiving via", connStats.rcvServers)
smpServers("sending via", connStats.sndServers)
}
}
Section {
removeMemberButton()
if member.canRemove(userRole: groupInfo.membership.memberRole) && member.memberStatus != .memRemoved {
removeMemberButton()
}
}
Section("For console") {
infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)")
}
}
.navigationBarHidden(true)
@@ -50,25 +67,50 @@ struct GroupMemberInfoView: View {
case .removeMemberAlert: return removeMemberAlert()
}
}
.task {
do {
let stats = try await apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
await MainActor.run { connectionStats = stats }
} catch let error {
logger.error("apiGroupMemberInfo error: \(responseError(error))")
}
}
}
func groupMemberInfoHeader() -> some View {
private func groupMemberInfoHeader() -> some View {
VStack {
ProfileImage(imageStr: member.image, color: Color(uiColor: .tertiarySystemFill))
.frame(width: 192, height: 192)
.padding(.top, 12)
.padding()
Text(member.localDisplayName)
Text(member.displayName)
.font(.largeTitle)
.lineLimit(1)
.padding(.bottom, 2)
Text(member.fullName)
.font(.title2)
.lineLimit(2)
if member.fullName != "" && member.fullName != member.displayName {
Text(member.fullName)
.font(.title2)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
@ViewBuilder private func smpServers(_ title: LocalizedStringKey, _ servers: [String]?) -> some View {
if let servers = servers,
servers.count > 0 {
infoRow(title, serverHost(servers[0]))
}
}
private func serverHost(_ s: String) -> String {
if let i = s.range(of: "@")?.lowerBound {
return String(s[i...].dropFirst())
} else {
return s
}
}
func removeMemberButton() -> some View {
Button(role: .destructive) {
alert = .removeMemberAlert
@@ -86,7 +128,7 @@ struct GroupMemberInfoView: View {
Task {
do {
_ = try await apiRemoveMember(groupId: member.groupId, memberId: member.groupMemberId)
// TODO navigate back
dismiss()
} catch let error {
logger.error("removeMemberAlert apiRemoveMember error: \(error.localizedDescription)")
}
@@ -99,6 +141,6 @@ struct GroupMemberInfoView: View {
struct GroupMemberInfoView_Previews: PreviewProvider {
static var previews: some View {
GroupMemberInfoView(member: GroupMember.sampleData)
return GroupMemberInfoView(groupInfo: GroupInfo.sampleData, member: GroupMember.sampleData)
}
}

View File

@@ -9,6 +9,24 @@
import SwiftUI
import SimpleXChat
func infoRow(_ title: LocalizedStringKey, _ value: String) -> some View {
HStack {
Text(title)
Spacer()
Text(value)
.foregroundStyle(.secondary)
}
}
func localizedInfoRow(_ title: LocalizedStringKey, _ value: LocalizedStringKey) -> some View {
HStack {
Text(title)
Spacer()
Text(value)
.foregroundStyle(.secondary)
}
}
struct GroupChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var alertManager = AlertManager.shared
@@ -18,6 +36,7 @@ struct GroupChatInfoView: View {
@State private var members: [GroupMember] = []
@State private var alert: GroupChatInfoViewAlert? = nil
@State private var showAddMembersSheet: Bool = false
@State private var selectedMember: GroupMember? = nil
enum GroupChatInfoViewAlert: Identifiable {
case deleteGroupAlert
@@ -33,11 +52,20 @@ struct GroupChatInfoView: View {
groupInfoHeader()
.listRowBackground(Color.clear)
Section(header: Text("\(members.count) Members")) {
addMembersButton()
ForEach(members) { member in
memberView(member)
Section(header: Text("\(members.count + 1) members")) {
if (groupInfo.canAddMembers) {
addMembersButton()
}
memberView(groupInfo.membership, user: true)
ForEach(members) { member in
Button { selectedMember = member } label: { memberView(member) }
}
}
.sheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo, showSheet: $showAddMembersSheet)
}
.sheet(item: $selectedMember) { member in
GroupMemberInfoView(groupInfo: groupInfo, member: member)
}
Section {
@@ -45,15 +73,19 @@ struct GroupChatInfoView: View {
if groupInfo.canDelete {
deleteGroupButton()
}
leaveGroupButton()
if (groupInfo.membership.memberStatus != .memLeft) {
leaveGroupButton()
}
}
Section(header: Text("For console")) {
infoRow("Local name", chat.chatInfo.localDisplayName)
infoRow("Database ID", "\(chat.chatInfo.apiId)")
}
}
.navigationBarHidden(true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.sheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, showSheet: $showAddMembersSheet)
}
.alert(item: $alert) { alertItem in
switch(alertItem) {
case .deleteGroupAlert: return deleteGroupAlert()
@@ -69,17 +101,20 @@ struct GroupChatInfoView: View {
func groupInfoHeader() -> some View {
VStack {
let cInfo = chat.chatInfo
ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill))
.frame(width: 192, height: 192)
.padding(.top, 12)
.padding()
Text(chat.chatInfo.localDisplayName)
Text(cInfo.displayName)
.font(.largeTitle)
.lineLimit(1)
.padding(.bottom, 2)
Text(chat.chatInfo.fullName)
.font(.title2)
.lineLimit(2)
if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName {
Text(cInfo.fullName)
.font(.title2)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
@@ -98,28 +133,27 @@ struct GroupChatInfoView: View {
.foregroundColor(status == .connected ? .green : .secondary)
}
func memberView(_ member: GroupMember) -> some View {
NavigationLink {
GroupMemberInfoView(member: member)
} label: {
HStack{
ProfileImage(imageStr: member.image)
.frame(width: 38, height: 38)
.padding(.trailing, 2)
// TODO server connection status
VStack(alignment: .leading) {
Text(member.chatViewName)
.lineLimit(1)
Text(member.memberStatus.shortText)
.lineLimit(1)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
let role = member.memberRole
if role == .owner || role == .admin {
Text(member.memberRole.text)
}
func memberView(_ member: GroupMember, user: Bool = false) -> some View {
HStack{
ProfileImage(imageStr: member.image)
.frame(width: 38, height: 38)
.padding(.trailing, 2)
// TODO server connection status
VStack(alignment: .leading) {
Text(member.chatViewName)
.lineLimit(1)
.foregroundColor(.primary)
let s = Text(member.memberStatus.shortText)
(user ? Text ("you: ") + s : s)
.lineLimit(1)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
let role = member.memberRole
if role == .owner || role == .admin {
Text(member.memberRole.text)
.foregroundColor(.secondary)
}
}
}

View File

@@ -85,7 +85,13 @@ struct AddGroupView: View {
m.chatId = groupInfo.id
}
} catch {
fatalError("Failed to create group: \(responseError(error))")
openedSheet = nil
AlertManager.shared.showAlert(
Alert(
title: Text("Failed to create group"),
message: Text(responseError(error))
)
)
}
}

View File

@@ -28,7 +28,7 @@ struct NewChatButton: View {
Image(systemName: "plus.circle.fill")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24 )
.frame(width: 24, height: 24)
}
.confirmationDialog("Add contact to start a new chat", isPresented: $showAddChat, titleVisibility: .visible) {
Button("Create link / QR code") { addContactAction() }

View File

@@ -500,8 +500,8 @@ public struct NetCfg: Codable {
}
public struct ConnectionStats: Codable {
var rcvServers: [String]?
var sndServers: [String]?
public var rcvServers: [String]?
public var sndServers: [String]?
}
public protocol SelectableItem: Hashable, Identifiable {

View File

@@ -444,6 +444,10 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
return membership.memberRole == .owner || (s == .memRemoved || s == .memLeft || s == .memGroupDeleted || s == .memInvited)
}
public var canAddMembers: Bool {
return membership.memberRole >= .admin && membership.memberActive
}
public static let sampleData = GroupInfo(
groupId: 1,
localDisplayName: "team",
@@ -524,6 +528,10 @@ public struct GroupMember: Identifiable, Decodable {
}
}
public func canRemove(userRole: GroupMemberRole) -> Bool {
return userRole >= .admin && userRole >= memberRole
}
public static let sampleData = GroupMember(
groupMemberId: 1,
groupId: 1,
@@ -539,18 +547,32 @@ public struct GroupMember: Identifiable, Decodable {
)
}
public enum GroupMemberRole: String, Decodable {
public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable {
case member = "member"
case admin = "admin"
case owner = "owner"
public var id: Self { self }
public var text: LocalizedStringKey {
switch self {
case .member: return "Member"
case .admin: return "Admin"
case .owner: return "Owner"
case .member: return "member"
case .admin: return "admin"
case .owner: return "owner"
}
}
private var comparisonValue: Int {
switch self {
case .member: return 0
case .admin: return 1
case .owner: return 2
}
}
public static func < (lhs: Self, rhs: Self) -> Bool {
return lhs.comparisonValue < rhs.comparisonValue
}
}
public enum GroupMemberCategory: String, Decodable {
@@ -576,17 +598,17 @@ public enum GroupMemberStatus: String, Decodable {
public var text: LocalizedStringKey {
switch self {
case .memRemoved: return "Removed"
case .memLeft: return "Left"
case .memGroupDeleted: return "Group deleted"
case .memInvited: return "Invited"
case .memIntroduced: return "Connecting (introduced)"
case .memIntroInvited: return "Connecting (introduction invitation)"
case .memAccepted: return "Connecting (accepted)"
case .memAnnounced: return "Connecting (announced)"
case .memConnected: return "Connected"
case .memComplete: return "Complete"
case .memCreator: return "Creator"
case .memRemoved: return "removed"
case .memLeft: return "left"
case .memGroupDeleted: return "group deleted"
case .memInvited: return "invited"
case .memIntroduced: return "connecting (introduced)"
case .memIntroInvited: return "connecting (introduction invitation)"
case .memAccepted: return "connecting (accepted)"
case .memAnnounced: return "connecting (announced)"
case .memConnected: return "connected"
case .memComplete: return "complete"
case .memCreator: return "creator"
}
}