diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 8b4c3f4f3..ac00f6268 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -545,6 +545,16 @@ final class Chat: ObservableObject, Identifiable { self.chatStats = chatStats } + var userCanSend: Bool { + switch chatInfo { + case .direct: return true + case let .group(groupInfo): + let m = groupInfo.membership + return m.memberActive && m.memberRole >= .member + default: return false + } + } + var id: ChatId { get { chatInfo.id } } var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 458397702..e91b33173 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -868,9 +868,15 @@ func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws throw r } -func apiCreateGroupLink(_ groupId: Int64) async throws -> String { - let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId)) - if case let .groupLinkCreated(_, _, connReq) = r { return connReq } +func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (String, GroupMemberRole) { + let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole)) + if case let .groupLinkCreated(_, _, connReq, memberRole) = r { return (connReq, memberRole) } + throw r +} + +func apiGroupLinkMemberRole(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (String, GroupMemberRole) { + let r = await chatSendCmd(.apiGroupLinkMemberRole(groupId: groupId, memberRole: memberRole)) + if case let .groupLink(_, _, connReq, memberRole) = r { return (connReq, memberRole) } throw r } @@ -880,11 +886,11 @@ func apiDeleteGroupLink(_ groupId: Int64) async throws { throw r } -func apiGetGroupLink(_ groupId: Int64) throws -> String? { +func apiGetGroupLink(_ groupId: Int64) throws -> (String, GroupMemberRole)? { let r = chatSendCmdSync(.apiGetGroupLink(groupId: groupId)) switch r { - case let .groupLink(_, _, connReq): - return connReq + case let .groupLink(_, _, connReq, memberRole): + return (connReq, memberRole) case .chatCmdError(_, chatError: .errorStore(storeError: .groupLinkNotFound)): return nil default: throw r @@ -1180,6 +1186,10 @@ func processReceivedMsg(_ res: ChatResponse) async { if active(user) { m.updateGroup(toGroup) } + case let .memberRole(user, groupInfo, _, _, _, _): + if active(user) { + m.updateGroup(groupInfo) + } case let .rcvFileStart(user, aChatItem): chatItemSimpleUpdate(user, aChatItem) case let .rcvFileComplete(user, aChatItem): diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index baac736d1..d80402e15 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -258,36 +258,52 @@ struct ComposeView: View { Image(systemName: "paperclip") .resizable() } - .disabled(composeState.attachmentDisabled) + .disabled(composeState.attachmentDisabled || !chat.userCanSend) .frame(width: 25, height: 25) .padding(.bottom, 12) .padding(.leading, 12) - SendMessageView( - composeState: $composeState, - sendMessage: { - sendMessage() - resetLinkPreview() - }, - sendLiveMessage: sendLiveMessage, - updateLiveMessage: updateLiveMessage, - cancelLiveMessage: { - composeState.liveMessage = nil - chatModel.removeLiveDummy() - }, - voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice), - showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert, - startVoiceMessageRecording: { - Task { - await startVoiceMessageRecording() - } - }, - finishVoiceMessageRecording: finishVoiceMessageRecording, - allowVoiceMessagesToContact: allowVoiceMessagesToContact, - onImagesAdded: { images in if !images.isEmpty { chosenImages = images }}, - keyboardVisible: $keyboardVisible - ) - .padding(.trailing, 12) - .background(.background) + ZStack(alignment: .leading) { + SendMessageView( + composeState: $composeState, + sendMessage: { + sendMessage() + resetLinkPreview() + }, + sendLiveMessage: sendLiveMessage, + updateLiveMessage: updateLiveMessage, + cancelLiveMessage: { + composeState.liveMessage = nil + chatModel.removeLiveDummy() + }, + voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice), + showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert, + startVoiceMessageRecording: { + Task { + await startVoiceMessageRecording() + } + }, + finishVoiceMessageRecording: finishVoiceMessageRecording, + allowVoiceMessagesToContact: allowVoiceMessagesToContact, + onImagesAdded: { images in if !images.isEmpty { chosenImages = images }}, + keyboardVisible: $keyboardVisible + ) + .padding(.trailing, 12) + .background(.background) + .disabled(!chat.userCanSend) + + if (!chat.userCanSend) { + Text("you are observer") + .italic() + .foregroundColor(.secondary) + .padding(.horizontal, 12) + .onTapGesture { + AlertManager.shared.showAlertMsg( + title: "You can't send messages!", + message: "Please contact group admin." + ) + } + } + } } } .onChange(of: composeState.message) { _ in @@ -299,6 +315,13 @@ struct ComposeView: View { } } } + .onChange(of: chat.userCanSend) { canSend in + if !canSend { + cancelCurrentVoiceRecording() + clearCurrentDraft() + clearState() + } + } .confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) { Button("Take picture") { showTakePhoto = true diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index bea381bdc..569be904a 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -17,6 +17,7 @@ struct GroupChatInfoView: View { @ObservedObject private var alertManager = AlertManager.shared @State private var alert: GroupChatInfoViewAlert? = nil @State private var groupLink: String? + @State private var groupLinkMemberRole: GroupMemberRole = .member @State private var showAddMembersSheet: Bool = false @State private var connectionStats: ConnectionStats? @State private var connectionCode: String? @@ -107,7 +108,9 @@ struct GroupChatInfoView: View { } .onAppear { do { - groupLink = try apiGetGroupLink(groupInfo.groupId) + if let link = try apiGetGroupLink(groupInfo.groupId) { + (groupLink, groupLinkMemberRole) = link + } } catch let error { logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))") } @@ -187,7 +190,7 @@ struct GroupChatInfoView: View { private func groupLinkButton() -> some View { NavigationLink { - GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink) + GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole) .navigationBarTitle("Group link") .navigationBarTitleDisplayMode(.large) } label: { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index e280e9f4b..aeef91212 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct GroupLinkView: View { var groupId: Int64 @Binding var groupLink: String? + @Binding var groupLinkMemberRole: GroupMemberRole @State private var creatingLink = false @State private var alert: GroupLinkAlert? @@ -33,6 +34,15 @@ struct GroupLinkView: View { Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") .padding(.bottom) if let groupLink = groupLink { + HStack { + Text("Initial role") + Picker("Initial role", selection: $groupLinkMemberRole) { + ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in + Text(role.text) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) QRCode(uri: groupLink) HStack { Button { @@ -85,6 +95,16 @@ struct GroupLinkView: View { return Alert(title: Text(title), message: Text(error)) } } + .onChange(of: groupLinkMemberRole) { _ in + Task { + do { + _ = try await apiGroupLinkMemberRole(groupId, memberRole: groupLinkMemberRole) + } catch let error { + let a = getErrorAlert(error, "Error updating group link") + alert = .error(title: a.title, error: a.message) + } + } + } .onAppear { if groupLink == nil && !creatingLink { createGroupLink() @@ -100,7 +120,7 @@ struct GroupLinkView: View { let link = try await apiCreateGroupLink(groupId) await MainActor.run { creatingLink = false - groupLink = link + (groupLink, groupLinkMemberRole) = link } } catch let error { logger.error("GroupLinkView apiCreateGroupLink: \(responseError(error))") @@ -120,8 +140,8 @@ struct GroupLinkView_Previews: PreviewProvider { @State var noGroupLink: String? = nil return Group { - GroupLinkView(groupId: 1, groupLink: $groupLink) - GroupLinkView(groupId: 1, groupLink: $noGroupLink) + GroupLinkView(groupId: 1, groupLink: $groupLink, groupLinkMemberRole: Binding.constant(.member)) + GroupLinkView(groupId: 1, groupLink: $noGroupLink, groupLinkMemberRole: Binding.constant(.member)) } } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 7d6d586c5..d7b14ac7e 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -46,7 +46,8 @@ public enum ChatCommand { case apiLeaveGroup(groupId: Int64) case apiListMembers(groupId: Int64) case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile) - case apiCreateGroupLink(groupId: Int64) + case apiCreateGroupLink(groupId: Int64, memberRole: GroupMemberRole) + case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole) case apiDeleteGroupLink(groupId: Int64) case apiGetGroupLink(groupId: Int64) case apiGetUserSMPServers(userId: Int64) @@ -134,7 +135,8 @@ public enum ChatCommand { case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)" case let .apiListMembers(groupId): return "/_members #\(groupId)" case let .apiUpdateGroupProfile(groupId, groupProfile): return "/_group_profile #\(groupId) \(encodeJSON(groupProfile))" - case let .apiCreateGroupLink(groupId): return "/_create link #\(groupId)" + case let .apiCreateGroupLink(groupId, memberRole): return "/_create link #\(groupId) \(memberRole)" + case let .apiGroupLinkMemberRole(groupId, memberRole): return "/_set link role #\(groupId) \(memberRole)" case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)" case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)" case let .apiGetUserSMPServers(userId): return "/_smp \(userId)" @@ -228,6 +230,7 @@ public enum ChatCommand { case .apiListMembers: return "apiListMembers" case .apiUpdateGroupProfile: return "apiUpdateGroupProfile" case .apiCreateGroupLink: return "apiCreateGroupLink" + case .apiGroupLinkMemberRole: return "apiGroupLinkMemberRole" case .apiDeleteGroupLink: return "apiDeleteGroupLink" case .apiGetGroupLink: return "apiGetGroupLink" case .apiGetUserSMPServers: return "apiGetUserSMPServers" @@ -391,8 +394,8 @@ public enum ChatResponse: Decodable, Error { case connectedToGroupMember(user: User, groupInfo: GroupInfo, member: GroupMember) case groupRemoved(user: User, groupInfo: GroupInfo) // unused case groupUpdated(user: User, toGroup: GroupInfo) - case groupLinkCreated(user: User, groupInfo: GroupInfo, connReqContact: String) - case groupLink(user: User, groupInfo: GroupInfo, connReqContact: String) + case groupLinkCreated(user: User, groupInfo: GroupInfo, connReqContact: String, memberRole: GroupMemberRole) + case groupLink(user: User, groupInfo: GroupInfo, connReqContact: String, memberRole: GroupMemberRole) case groupLinkDeleted(user: User, groupInfo: GroupInfo) // receiving file events case rcvFileAccepted(user: User, chatItem: AChatItem) @@ -606,8 +609,8 @@ public enum ChatResponse: Decodable, Error { case let .connectedToGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .groupRemoved(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) - case let .groupLinkCreated(u, groupInfo, connReqContact): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)") - case let .groupLink(u, groupInfo, connReqContact): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)") + case let .groupLinkCreated(u, groupInfo, connReqContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)\nmemberRole: \(memberRole)") + case let .groupLink(u, groupInfo, connReqContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)\nmemberRole: \(memberRole)") case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) case .rcvFileAcceptedSndCancelled: return noDetails diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index e175944d4..49ad79449 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1546,6 +1546,7 @@ public struct GroupMemberRef: Decodable { } public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable { + case observer = "observer" case member = "member" case admin = "admin" case owner = "owner" @@ -1554,6 +1555,7 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Dec public var text: String { switch self { + case .observer: return NSLocalizedString("observer", comment: "member role") case .member: return NSLocalizedString("member", comment: "member role") case .admin: return NSLocalizedString("admin", comment: "member role") case .owner: return NSLocalizedString("owner", comment: "member role") @@ -1562,9 +1564,10 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Dec private var comparisonValue: Int { switch self { - case .member: return 0 - case .admin: return 1 - case .owner: return 2 + case .observer: return 0 + case .member: return 1 + case .admin: return 2 + case .owner: return 3 } }