ios: groups miscellaneous (#843)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
@@ -306,7 +306,7 @@ func apiContactInfo(contactId: Int64) async throws -> ConnectionStats? {
|
|||||||
throw r
|
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))
|
let r = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
|
||||||
if case let .groupMemberInfo(_, _, connStats_) = r { return connStats_ }
|
if case let .groupMemberInfo(_, _, connStats_) = r { return connStats_ }
|
||||||
throw r
|
throw r
|
||||||
@@ -565,9 +565,9 @@ func apiNewGroup(_ gp: GroupProfile) throws -> GroupInfo {
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func addMember(groupId: Int64, contactId: Int64) async {
|
func addMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) async {
|
||||||
do {
|
do {
|
||||||
try await apiAddMember(groupId: groupId, contactId: contactId, memberRole: .admin)
|
try await apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole)
|
||||||
} catch let error {
|
} catch let error {
|
||||||
logger.error("addMember error: \(responseError(error))")
|
logger.error("addMember error: \(responseError(error))")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9)
|
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 {
|
struct ChatInfoToolbar: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
|||||||
@@ -11,19 +11,12 @@ import SimpleXChat
|
|||||||
|
|
||||||
private let memberImageSize: CGFloat = 34
|
private let memberImageSize: CGFloat = 34
|
||||||
|
|
||||||
enum ChatViewSheet: Identifiable {
|
|
||||||
case chatInfo
|
|
||||||
case addMember
|
|
||||||
|
|
||||||
var id: ChatViewSheet { get { self } }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ChatView: View {
|
struct ChatView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@State private var showChatViewSheet: Bool = false
|
@State private var showChatInfoSheet: Bool = false
|
||||||
@State private var chatViewSheet: ChatViewSheet?
|
@State private var showAddMembersSheet: Bool = false
|
||||||
@State private var composeState = ComposeState()
|
@State private var composeState = ComposeState()
|
||||||
@State private var deletingItem: ChatItem? = nil
|
@State private var deletingItem: ChatItem? = nil
|
||||||
@FocusState private var keyboardVisible: Bool
|
@FocusState private var keyboardVisible: Bool
|
||||||
@@ -107,22 +100,15 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
ToolbarItem(placement: .principal) {
|
ToolbarItem(placement: .principal) {
|
||||||
Button {
|
Button {
|
||||||
chatViewSheet = .chatInfo
|
showChatInfoSheet = true
|
||||||
showChatViewSheet = true
|
|
||||||
} label: {
|
} label: {
|
||||||
ChatInfoToolbar(chat: chat)
|
ChatInfoToolbar(chat: chat)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showChatViewSheet) {
|
.sheet(isPresented: $showChatInfoSheet) {
|
||||||
switch chatViewSheet {
|
if case .direct = chat.chatInfo {
|
||||||
case .chatInfo:
|
ChatInfoView(chat: chat, showSheet: $showChatInfoSheet)
|
||||||
if case .direct = chat.chatInfo {
|
} else if case let .group(groupInfo) = chat.chatInfo {
|
||||||
ChatInfoView(chat: chat, showSheet: $showChatViewSheet)
|
GroupChatInfoView(chat: chat, groupInfo: groupInfo, showSheet: $showChatInfoSheet)
|
||||||
} else if case let .group(groupInfo) = chat.chatInfo {
|
|
||||||
GroupChatInfoView(chat: chat, groupInfo: groupInfo, showSheet: $showChatViewSheet)
|
|
||||||
}
|
|
||||||
case .addMember:
|
|
||||||
AddGroupMembersView(chat: chat, showSheet: $showChatViewSheet)
|
|
||||||
default: EmptyView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,8 +118,12 @@ struct ChatView: View {
|
|||||||
callButton(contact, .audio, imageName: "phone")
|
callButton(contact, .audio, imageName: "phone")
|
||||||
callButton(contact, .video, imageName: "video")
|
callButton(contact, .video, imageName: "video")
|
||||||
}
|
}
|
||||||
} else if case .group = chat.chatInfo {
|
} else if case let .group(groupInfo) = chat.chatInfo,
|
||||||
|
groupInfo.canAddMembers {
|
||||||
addMembersButton()
|
addMembersButton()
|
||||||
|
.sheet(isPresented: $showAddMembersSheet) {
|
||||||
|
AddGroupMembersView(chat: chat, groupInfo: groupInfo, showSheet: $showAddMembersSheet)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,8 +140,7 @@ struct ChatView: View {
|
|||||||
|
|
||||||
private func addMembersButton() -> some View {
|
private func addMembersButton() -> some View {
|
||||||
Button {
|
Button {
|
||||||
chatViewSheet = .addMember
|
showAddMembersSheet = true
|
||||||
showChatViewSheet = true
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "person.crop.circle.badge.plus")
|
Image(systemName: "person.crop.circle.badge.plus")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,56 +12,53 @@ import SimpleXChat
|
|||||||
struct AddGroupMembersView: View {
|
struct AddGroupMembersView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
var chat: Chat
|
var chat: Chat
|
||||||
|
var groupInfo: GroupInfo
|
||||||
@Binding var showSheet: Bool
|
@Binding var showSheet: Bool
|
||||||
@State private var contactsToAdd: [Contact] = []
|
@State private var contactsToAdd: [Contact] = []
|
||||||
@State private var selectedContacts = Set<Int64>()
|
@State private var selectedContacts = Set<Int64>()
|
||||||
|
@State private var selectedRole: GroupMemberRole = .admin
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
NavigationView {
|
||||||
ChatInfoToolbar(chat: chat, imageSize: 48)
|
List {
|
||||||
.padding()
|
ChatInfoToolbar(chat: chat, imageSize: 48)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.background(Color(uiColor: .quaternarySystemFill))
|
.listRowBackground(Color.clear)
|
||||||
if (contactsToAdd.isEmpty) {
|
.listRowSeparator(.hidden)
|
||||||
Text("No contacts to add")
|
|
||||||
.foregroundColor(.secondary)
|
if (contactsToAdd.isEmpty) {
|
||||||
.padding()
|
Text("No contacts to add")
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.foregroundColor(.secondary)
|
||||||
} else {
|
.padding()
|
||||||
HStack {
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
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)
|
|
||||||
.listRowBackground(Color.clear)
|
.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)
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
.task {
|
.task {
|
||||||
@@ -78,6 +75,33 @@ struct AddGroupMembersView: View {
|
|||||||
.sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() }
|
.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 {
|
func contactCheckView(_ contact: Contact) -> some View {
|
||||||
let checked = selectedContacts.contains(contact.apiId)
|
let checked = selectedContacts.contains(contact.apiId)
|
||||||
return Button {
|
return Button {
|
||||||
@@ -92,10 +116,11 @@ struct AddGroupMembersView: View {
|
|||||||
.frame(width: 30, height: 30)
|
.frame(width: 30, height: 30)
|
||||||
.padding(.trailing, 2)
|
.padding(.trailing, 2)
|
||||||
Text(ChatInfo.direct(contact: contact).chatViewName)
|
Text(ChatInfo.direct(contact: contact).chatViewName)
|
||||||
|
.foregroundColor(.primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: checked ? "checkmark.circle.fill": "circle")
|
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 {
|
struct AddGroupMembersView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
@State var showSheet = true
|
@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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct GroupMemberInfoView: View {
|
struct GroupMemberInfoView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
|
var groupInfo: GroupInfo
|
||||||
var member: GroupMember
|
var member: GroupMember
|
||||||
@State private var alert: GroupMemberInfoViewAlert? = nil
|
@State private var alert: GroupMemberInfoViewAlert?
|
||||||
|
@State private var connectionStats: ConnectionStats?
|
||||||
|
|
||||||
enum GroupMemberInfoViewAlert: Identifiable {
|
enum GroupMemberInfoViewAlert: Identifiable {
|
||||||
case removeMemberAlert
|
case removeMemberAlert
|
||||||
@@ -26,20 +29,34 @@ struct GroupMemberInfoView: View {
|
|||||||
groupMemberInfoHeader()
|
groupMemberInfoHeader()
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
|
|
||||||
// TODO server status
|
Section("Member") {
|
||||||
|
infoRow("Group", groupInfo.displayName)
|
||||||
Section(header: Text("Info")) {
|
// TODO change role
|
||||||
Text("Role: ") + Text(member.memberRole.text)
|
// localizedInfoRow("Role", member.memberRole.text)
|
||||||
// TODO invited by - need to get contact by contact id
|
// TODO invited by - need to get contact by contact id
|
||||||
Text("Status: ") + Text(member.memberStatus.text)
|
|
||||||
if let conn = member.activeConn {
|
if let conn = member.activeConn {
|
||||||
let connLevelDesc = conn.connLevel == 0 ? "Direct" : "Indirect (\(conn.connLevel))"
|
let connLevelDesc = conn.connLevel == 0 ? "direct" : "indirect (\(conn.connLevel))"
|
||||||
Text("Connection level: \(connLevelDesc)")
|
infoRow("Connection", connLevelDesc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let connStats = connectionStats {
|
||||||
|
Section("Servers") {
|
||||||
|
// TODO network connection status
|
||||||
|
smpServers("receiving via", connStats.rcvServers)
|
||||||
|
smpServers("sending via", connStats.sndServers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
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)
|
.navigationBarHidden(true)
|
||||||
@@ -50,25 +67,50 @@ struct GroupMemberInfoView: View {
|
|||||||
case .removeMemberAlert: return removeMemberAlert()
|
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 {
|
VStack {
|
||||||
ProfileImage(imageStr: member.image, color: Color(uiColor: .tertiarySystemFill))
|
ProfileImage(imageStr: member.image, color: Color(uiColor: .tertiarySystemFill))
|
||||||
.frame(width: 192, height: 192)
|
.frame(width: 192, height: 192)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
.padding()
|
.padding()
|
||||||
Text(member.localDisplayName)
|
Text(member.displayName)
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.padding(.bottom, 2)
|
.padding(.bottom, 2)
|
||||||
Text(member.fullName)
|
if member.fullName != "" && member.fullName != member.displayName {
|
||||||
.font(.title2)
|
Text(member.fullName)
|
||||||
.lineLimit(2)
|
.font(.title2)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.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 {
|
func removeMemberButton() -> some View {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
alert = .removeMemberAlert
|
alert = .removeMemberAlert
|
||||||
@@ -86,7 +128,7 @@ struct GroupMemberInfoView: View {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
_ = try await apiRemoveMember(groupId: member.groupId, memberId: member.groupMemberId)
|
_ = try await apiRemoveMember(groupId: member.groupId, memberId: member.groupMemberId)
|
||||||
// TODO navigate back
|
dismiss()
|
||||||
} catch let error {
|
} catch let error {
|
||||||
logger.error("removeMemberAlert apiRemoveMember error: \(error.localizedDescription)")
|
logger.error("removeMemberAlert apiRemoveMember error: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
@@ -99,6 +141,6 @@ struct GroupMemberInfoView: View {
|
|||||||
|
|
||||||
struct GroupMemberInfoView_Previews: PreviewProvider {
|
struct GroupMemberInfoView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
GroupMemberInfoView(member: GroupMember.sampleData)
|
return GroupMemberInfoView(groupInfo: GroupInfo.sampleData, member: GroupMember.sampleData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,24 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SimpleXChat
|
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 {
|
struct GroupChatInfoView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@ObservedObject var alertManager = AlertManager.shared
|
@ObservedObject var alertManager = AlertManager.shared
|
||||||
@@ -18,6 +36,7 @@ struct GroupChatInfoView: View {
|
|||||||
@State private var members: [GroupMember] = []
|
@State private var members: [GroupMember] = []
|
||||||
@State private var alert: GroupChatInfoViewAlert? = nil
|
@State private var alert: GroupChatInfoViewAlert? = nil
|
||||||
@State private var showAddMembersSheet: Bool = false
|
@State private var showAddMembersSheet: Bool = false
|
||||||
|
@State private var selectedMember: GroupMember? = nil
|
||||||
|
|
||||||
enum GroupChatInfoViewAlert: Identifiable {
|
enum GroupChatInfoViewAlert: Identifiable {
|
||||||
case deleteGroupAlert
|
case deleteGroupAlert
|
||||||
@@ -33,11 +52,20 @@ struct GroupChatInfoView: View {
|
|||||||
groupInfoHeader()
|
groupInfoHeader()
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
|
|
||||||
Section(header: Text("\(members.count) Members")) {
|
Section(header: Text("\(members.count + 1) members")) {
|
||||||
addMembersButton()
|
if (groupInfo.canAddMembers) {
|
||||||
ForEach(members) { member in
|
addMembersButton()
|
||||||
memberView(member)
|
|
||||||
}
|
}
|
||||||
|
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 {
|
Section {
|
||||||
@@ -45,15 +73,19 @@ struct GroupChatInfoView: View {
|
|||||||
if groupInfo.canDelete {
|
if groupInfo.canDelete {
|
||||||
deleteGroupButton()
|
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)
|
.navigationBarHidden(true)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.sheet(isPresented: $showAddMembersSheet) {
|
|
||||||
AddGroupMembersView(chat: chat, showSheet: $showAddMembersSheet)
|
|
||||||
}
|
|
||||||
.alert(item: $alert) { alertItem in
|
.alert(item: $alert) { alertItem in
|
||||||
switch(alertItem) {
|
switch(alertItem) {
|
||||||
case .deleteGroupAlert: return deleteGroupAlert()
|
case .deleteGroupAlert: return deleteGroupAlert()
|
||||||
@@ -69,17 +101,20 @@ struct GroupChatInfoView: View {
|
|||||||
|
|
||||||
func groupInfoHeader() -> some View {
|
func groupInfoHeader() -> some View {
|
||||||
VStack {
|
VStack {
|
||||||
|
let cInfo = chat.chatInfo
|
||||||
ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill))
|
ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill))
|
||||||
.frame(width: 192, height: 192)
|
.frame(width: 192, height: 192)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
.padding()
|
.padding()
|
||||||
Text(chat.chatInfo.localDisplayName)
|
Text(cInfo.displayName)
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.padding(.bottom, 2)
|
.padding(.bottom, 2)
|
||||||
Text(chat.chatInfo.fullName)
|
if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName {
|
||||||
.font(.title2)
|
Text(cInfo.fullName)
|
||||||
.lineLimit(2)
|
.font(.title2)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
@@ -98,28 +133,27 @@ struct GroupChatInfoView: View {
|
|||||||
.foregroundColor(status == .connected ? .green : .secondary)
|
.foregroundColor(status == .connected ? .green : .secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
func memberView(_ member: GroupMember) -> some View {
|
func memberView(_ member: GroupMember, user: Bool = false) -> some View {
|
||||||
NavigationLink {
|
HStack{
|
||||||
GroupMemberInfoView(member: member)
|
ProfileImage(imageStr: member.image)
|
||||||
} label: {
|
.frame(width: 38, height: 38)
|
||||||
HStack{
|
.padding(.trailing, 2)
|
||||||
ProfileImage(imageStr: member.image)
|
// TODO server connection status
|
||||||
.frame(width: 38, height: 38)
|
VStack(alignment: .leading) {
|
||||||
.padding(.trailing, 2)
|
Text(member.chatViewName)
|
||||||
// TODO server connection status
|
.lineLimit(1)
|
||||||
VStack(alignment: .leading) {
|
.foregroundColor(.primary)
|
||||||
Text(member.chatViewName)
|
let s = Text(member.memberStatus.shortText)
|
||||||
.lineLimit(1)
|
(user ? Text ("you: ") + s : s)
|
||||||
Text(member.memberStatus.shortText)
|
.lineLimit(1)
|
||||||
.lineLimit(1)
|
.font(.caption)
|
||||||
.font(.caption)
|
.foregroundColor(.secondary)
|
||||||
.foregroundColor(.secondary)
|
}
|
||||||
}
|
Spacer()
|
||||||
Spacer()
|
let role = member.memberRole
|
||||||
let role = member.memberRole
|
if role == .owner || role == .admin {
|
||||||
if role == .owner || role == .admin {
|
Text(member.memberRole.text)
|
||||||
Text(member.memberRole.text)
|
.foregroundColor(.secondary)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,13 @@ struct AddGroupView: View {
|
|||||||
m.chatId = groupInfo.id
|
m.chatId = groupInfo.id
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
fatalError("Failed to create group: \(responseError(error))")
|
openedSheet = nil
|
||||||
|
AlertManager.shared.showAlert(
|
||||||
|
Alert(
|
||||||
|
title: Text("Failed to create group"),
|
||||||
|
message: Text(responseError(error))
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ struct NewChatButton: View {
|
|||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: 24, height: 24 )
|
.frame(width: 24, height: 24)
|
||||||
}
|
}
|
||||||
.confirmationDialog("Add contact to start a new chat", isPresented: $showAddChat, titleVisibility: .visible) {
|
.confirmationDialog("Add contact to start a new chat", isPresented: $showAddChat, titleVisibility: .visible) {
|
||||||
Button("Create link / QR code") { addContactAction() }
|
Button("Create link / QR code") { addContactAction() }
|
||||||
|
|||||||
@@ -500,8 +500,8 @@ public struct NetCfg: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct ConnectionStats: Codable {
|
public struct ConnectionStats: Codable {
|
||||||
var rcvServers: [String]?
|
public var rcvServers: [String]?
|
||||||
var sndServers: [String]?
|
public var sndServers: [String]?
|
||||||
}
|
}
|
||||||
|
|
||||||
public protocol SelectableItem: Hashable, Identifiable {
|
public protocol SelectableItem: Hashable, Identifiable {
|
||||||
|
|||||||
@@ -444,6 +444,10 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
|
|||||||
return membership.memberRole == .owner || (s == .memRemoved || s == .memLeft || s == .memGroupDeleted || s == .memInvited)
|
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(
|
public static let sampleData = GroupInfo(
|
||||||
groupId: 1,
|
groupId: 1,
|
||||||
localDisplayName: "team",
|
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(
|
public static let sampleData = GroupMember(
|
||||||
groupMemberId: 1,
|
groupMemberId: 1,
|
||||||
groupId: 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 member = "member"
|
||||||
case admin = "admin"
|
case admin = "admin"
|
||||||
case owner = "owner"
|
case owner = "owner"
|
||||||
|
|
||||||
|
public var id: Self { self }
|
||||||
|
|
||||||
public var text: LocalizedStringKey {
|
public var text: LocalizedStringKey {
|
||||||
switch self {
|
switch self {
|
||||||
case .member: return "Member"
|
case .member: return "member"
|
||||||
case .admin: return "Admin"
|
case .admin: return "admin"
|
||||||
case .owner: return "Owner"
|
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 {
|
public enum GroupMemberCategory: String, Decodable {
|
||||||
@@ -576,17 +598,17 @@ public enum GroupMemberStatus: String, Decodable {
|
|||||||
|
|
||||||
public var text: LocalizedStringKey {
|
public var text: LocalizedStringKey {
|
||||||
switch self {
|
switch self {
|
||||||
case .memRemoved: return "Removed"
|
case .memRemoved: return "removed"
|
||||||
case .memLeft: return "Left"
|
case .memLeft: return "left"
|
||||||
case .memGroupDeleted: return "Group deleted"
|
case .memGroupDeleted: return "group deleted"
|
||||||
case .memInvited: return "Invited"
|
case .memInvited: return "invited"
|
||||||
case .memIntroduced: return "Connecting (introduced)"
|
case .memIntroduced: return "connecting (introduced)"
|
||||||
case .memIntroInvited: return "Connecting (introduction invitation)"
|
case .memIntroInvited: return "connecting (introduction invitation)"
|
||||||
case .memAccepted: return "Connecting (accepted)"
|
case .memAccepted: return "connecting (accepted)"
|
||||||
case .memAnnounced: return "Connecting (announced)"
|
case .memAnnounced: return "connecting (announced)"
|
||||||
case .memConnected: return "Connected"
|
case .memConnected: return "connected"
|
||||||
case .memComplete: return "Complete"
|
case .memComplete: return "complete"
|
||||||
case .memCreator: return "Creator"
|
case .memCreator: return "creator"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -745,7 +745,8 @@ processChatCommand = \case
|
|||||||
Nothing -> throwChatError CEGroupMemberNotFound
|
Nothing -> throwChatError CEGroupMemberNotFound
|
||||||
Just m@GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberProfile} -> do
|
Just m@GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberProfile} -> do
|
||||||
let userRole = memberRole (membership :: GroupMember)
|
let userRole = memberRole (membership :: GroupMember)
|
||||||
when (userRole < GRAdmin || userRole < mRole) $ throwChatError CEGroupUserRole
|
canRemove = userRole >= GRAdmin && userRole >= mRole
|
||||||
|
unless canRemove $ throwChatError CEGroupUserRole
|
||||||
withChatLock . procCmd $ do
|
withChatLock . procCmd $ do
|
||||||
when (mStatus /= GSMemInvited) $ do
|
when (mStatus /= GSMemInvited) $ do
|
||||||
msg <- sendGroupMessage gInfo members $ XGrpMemDel mId
|
msg <- sendGroupMessage gInfo members $ XGrpMemDel mId
|
||||||
|
|||||||
@@ -1102,6 +1102,7 @@ testGroupAsync = withTmpFiles $ do
|
|||||||
cath <## "#team: connected to server(s)"
|
cath <## "#team: connected to server(s)"
|
||||||
cath <## "#team: member bob (Bob) is connected"
|
cath <## "#team: member bob (Bob) is connected"
|
||||||
]
|
]
|
||||||
|
threadDelay 500000
|
||||||
print (3 :: Integer)
|
print (3 :: Integer)
|
||||||
withTestChat "bob" $ \bob -> do
|
withTestChat "bob" $ \bob -> do
|
||||||
withNewTestChat "dan" danProfile $ \dan -> do
|
withNewTestChat "dan" danProfile $ \dan -> do
|
||||||
@@ -1120,7 +1121,8 @@ testGroupAsync = withTmpFiles $ do
|
|||||||
[ bob <## "#team: dan joined the group",
|
[ bob <## "#team: dan joined the group",
|
||||||
dan <## "#team: you joined the group"
|
dan <## "#team: you joined the group"
|
||||||
]
|
]
|
||||||
threadDelay 500000
|
threadDelay 1000000
|
||||||
|
threadDelay 500000
|
||||||
print (4 :: Integer)
|
print (4 :: Integer)
|
||||||
withTestChat "alice" $ \alice -> do
|
withTestChat "alice" $ \alice -> do
|
||||||
withTestChat "cath" $ \cath -> do
|
withTestChat "cath" $ \cath -> do
|
||||||
|
|||||||
Reference in New Issue
Block a user