From aa97242af9f9aa768b3ab82913b0fa2ef5ae983c Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Thu, 20 Nov 2025 18:37:06 +0100 Subject: [PATCH 1/5] feat: Split participants in room's call in room info view Signed-off-by: Ivan Sein --- .../RoomInfoParticipantsSection.swift | 162 +++++++++++------- 1 file changed, 99 insertions(+), 63 deletions(-) diff --git a/NextcloudTalk/Rooms/RoomInfo/RoomInfoParticipantsSection.swift b/NextcloudTalk/Rooms/RoomInfo/RoomInfoParticipantsSection.swift index 3a9e98a93..8b3f0c3bc 100644 --- a/NextcloudTalk/Rooms/RoomInfo/RoomInfoParticipantsSection.swift +++ b/NextcloudTalk/Rooms/RoomInfo/RoomInfoParticipantsSection.swift @@ -36,10 +36,39 @@ struct RoomInfoParticipantsSection: View { } private var participantCountText: String { - return participants == nil ? "" : String.localizedStringWithFormat(NSLocalizedString("%ld participants", comment: ""), participants?.count ?? 0) + if participants == nil { + return "" + } else if room.hasCall { + return String.localizedStringWithFormat(NSLocalizedString("Other participants", comment: "")) + } else { + return String.localizedStringWithFormat(NSLocalizedString("%ld participants", comment: ""), participants?.count ?? 0) + } + } + + private var participantsInCallCountText: String { + return participants == nil ? "" : String.localizedStringWithFormat(NSLocalizedString("%ld participants in the call", comment: ""), participantsInCall.count, participants?.count ?? 0) + } + + private var participantsInCall: [NCRoomParticipant] { + return participants?.filter { $0.inCall.contains(.inCall) } ?? [] } var body: (some View)? { + if room.hasCall { + Section(participantsInCallCountText) { + if let participants = Binding($participants) { + ForEach(participants, id: \.self) { $participant in + if participant.inCall.contains(.inCall) { + participantView(participant: $participant) + } + } + } else { + ProgressView() + .listRowInsets(nil) + } + } + } + if room.canAddParticipants { Section(participantCountText) { Button(action: addParticipants) { @@ -62,68 +91,8 @@ struct RoomInfoParticipantsSection: View { Section(room.canAddParticipants ? "" : participantCountText) { if let participants = Binding($participants) { ForEach(participants, id: \.self) { $participant in - Menu { - if room.canModerate, participant.canBeModerated { - if participant.canBeDemoted { - Button { - self.changeModerationPermission(forParticipant: participant, canModerate: false) - } label: { - Label(NSLocalizedString("Demote from moderator", comment: ""), systemImage: "person") - } - } - - if participant.canBePromoted { - Button { - self.changeModerationPermission(forParticipant: participant, canModerate: true) - } label: { - Label(NSLocalizedString("Promote to moderator", comment: ""), systemImage: "crown") - } - } - } - - if participant.canBeNotifiedAboutCall, room.permissions.contains(.startCall), room.participantFlags != [] { - Button { - self.sendCallNotification(forParticipant: participant) - } label: { - Label(NSLocalizedString("Send call notification", comment: ""), systemImage: "bell") - } - } - - if participant.actorType == .email { - Button { - self.resendInvitation(forParticipant: participant) - } label: { - Label(NSLocalizedString("Resend invitation", comment: ""), systemImage: "envelope") - } - } - - if room.canModerate, participant.canBeModerated { - if participant.canBeBanned { - Button(role: .destructive) { - participantToBan = participant - banConfirmationShown = true - } label: { - Label(NSLocalizedString("Ban participant", comment: ""), systemImage: "person.badge.minus") - } - .foregroundStyle(.primary) - .disabled(isBanActionRunning) - } - - Button(role: .destructive) { - Task { - await removeParticipant(participant: participant) - } - } label: { - Label(getRemoveLabel(forParticipant: participant), systemImage: "trash") - } - } - } label: { - ContactsTableViewCellWrapper(room: $room, participant: $participant) - .frame(height: 72) // Height set in the XIB file - } - .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 12)) - .alignmentGuide(.listRowSeparatorLeading) { _ in - 72 + if !room.hasCall || participant.inCall.isEmpty { + participantView(participant: $participant) } } } else { @@ -259,4 +228,71 @@ struct RoomInfoParticipantsSection: View { self.getParticipants() } } + + func participantView(participant: Binding) -> some View { + let wrappedParticipant = participant.wrappedValue + return Menu { + if room.canModerate, wrappedParticipant.canBeModerated { + if wrappedParticipant.canBeDemoted { + Button { + self.changeModerationPermission(forParticipant: wrappedParticipant, canModerate: false) + } label: { + Label(NSLocalizedString("Demote from moderator", comment: ""), systemImage: "person") + } + } + + if wrappedParticipant.canBePromoted { + Button { + self.changeModerationPermission(forParticipant: wrappedParticipant, canModerate: true) + } label: { + Label(NSLocalizedString("Promote to moderator", comment: ""), systemImage: "crown") + } + } + } + + if wrappedParticipant.canBeNotifiedAboutCall, room.permissions.contains(.startCall), room.participantFlags.contains(.inCall) { + Button { + self.sendCallNotification(forParticipant: wrappedParticipant) + } label: { + Label(NSLocalizedString("Send call notification", comment: ""), systemImage: "bell") + } + } + + if wrappedParticipant.actorType == .email { + Button { + self.resendInvitation(forParticipant: wrappedParticipant) + } label: { + Label(NSLocalizedString("Resend invitation", comment: ""), systemImage: "envelope") + } + } + + if room.canModerate, wrappedParticipant.canBeModerated { + if wrappedParticipant.canBeBanned { + Button(role: .destructive) { + participantToBan = wrappedParticipant + banConfirmationShown = true + } label: { + Label(NSLocalizedString("Ban participant", comment: ""), systemImage: "person.badge.minus") + } + .foregroundStyle(.primary) + .disabled(isBanActionRunning) + } + + Button(role: .destructive) { + Task { + await removeParticipant(participant: wrappedParticipant) + } + } label: { + Label(getRemoveLabel(forParticipant: wrappedParticipant), systemImage: "trash") + } + } + } label: { + ContactsTableViewCellWrapper(room: $room, participant: participant) + .frame(height: 72) // Height set in the XIB file + } + .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 12)) + .alignmentGuide(.listRowSeparatorLeading) { _ in + 72 + } + } } From 729aae7bffc760b71bb29019a14a08ae46a94b24 Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Thu, 20 Nov 2025 18:39:16 +0100 Subject: [PATCH 2/5] chore: Update localizable strings file Signed-off-by: Ivan Sein --- NextcloudTalk/en.lproj/Localizable.strings | 6 ++++++ .../en.lproj/Localizable.stringsdict | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/NextcloudTalk/en.lproj/Localizable.strings b/NextcloudTalk/en.lproj/Localizable.strings index d433ee7dc..9e10f27f7 100644 --- a/NextcloudTalk/en.lproj/Localizable.strings +++ b/NextcloudTalk/en.lproj/Localizable.strings @@ -67,6 +67,9 @@ /* No comment provided by engineer. */ "%ld participants" = "%ld participants"; +/* No comment provided by engineer. */ +"%ld participants in the call" = "%ld participants in the call"; + /* Replies in a thread */ "%ld replies" = "%ld replies"; @@ -1616,6 +1619,9 @@ /* No comment provided by engineer. */ "Other Accounts" = "Other Accounts"; +/* No comment provided by engineer. */ +"Other participants" = "Other participants"; + /* No comment provided by engineer. */ "Others" = "Others"; diff --git a/NextcloudTalk/en.lproj/Localizable.stringsdict b/NextcloudTalk/en.lproj/Localizable.stringsdict index aa2fbcfb5..7443ff79d 100644 --- a/NextcloudTalk/en.lproj/Localizable.stringsdict +++ b/NextcloudTalk/en.lproj/Localizable.stringsdict @@ -36,6 +36,25 @@ + %ld participants in the call + + NSStringLocalizedFormatKey + %#@participants@ + participants + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + %ld participant in the call + many + %ld participants in the call + other + %ld participants in the call + + + %d minutes ago NSStringLocalizedFormatKey From 236284abb17a2021be8d0c837ea18bc41bc12a19 Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Thu, 20 Nov 2025 18:52:57 +0100 Subject: [PATCH 3/5] feat: Show participants in room info view when tapping on participants label in call view Signed-off-by: Ivan Sein --- NextcloudTalk/Calls/CallViewController.swift | 40 ++++++++++++------- .../Rooms/RoomInfo/RoomInfoSwiftUIView.swift | 39 ++++++++++-------- .../Rooms/RoomsTableViewController.m | 2 +- 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/NextcloudTalk/Calls/CallViewController.swift b/NextcloudTalk/Calls/CallViewController.swift index b701589eb..c4d4c4605 100644 --- a/NextcloudTalk/Calls/CallViewController.swift +++ b/NextcloudTalk/Calls/CallViewController.swift @@ -195,6 +195,8 @@ class CallViewController: UIViewController, self.audioMuteButton.addGestureRecognizer(pushToTalkRecognizer) self.participantsLabelContainer.isHidden = true + let participantsLabelTapGesture = UITapGestureRecognizer(target: self, action: #selector(showParticipantsInRoomInfo)) + self.participantsLabelContainer.addGestureRecognizer(participantsLabelTapGesture) self.screensharingView.isHidden = true self.screensharingView.clipsToBounds = true @@ -1991,6 +1993,28 @@ class CallViewController: UIViewController, } } + func showParticipantsInRoomInfo() { + self.showRoomInfo(scrollToParticipantsSection: true) + } + + func showRoomInfo(scrollToParticipantsSection: Bool = false) { + let roomInfoVC = RoomInfoUIViewFactory.create(room: self.room, showDestructiveActions: false, scrollToParticipantsSectionOnAppear: scrollToParticipantsSection) + roomInfoVC.modalPresentationStyle = .pageSheet + + let navController = UINavigationController(rootViewController: roomInfoVC) + let cancelButton = UIBarButtonItem(systemItem: .cancel, primaryAction: UIAction { _ in + roomInfoVC.dismiss(animated: true) + }) + + if #unavailable(iOS 26.0) { + cancelButton.tintColor = NCAppBranding.themeTextColor() + } + + navController.navigationBar.topItem?.leftBarButtonItem = cancelButton + + self.present(navController, animated: true) + } + func showChat() { if chatNavigationController == nil { guard let room = NCDatabaseManager.sharedInstance().room(withToken: room.token, forAccountId: room.accountId), @@ -2451,20 +2475,6 @@ class CallViewController: UIViewController, // MARK: - NCChatTitleViewDelegate func chatTitleViewTapped(_ chatTitleView: NCChatTitleView?) { - let roomInfoVC = RoomInfoUIViewFactory.create(room: self.room, showDestructiveActions: false) - roomInfoVC.modalPresentationStyle = .pageSheet - - let navController = UINavigationController(rootViewController: roomInfoVC) - let cancelButton = UIBarButtonItem(systemItem: .cancel, primaryAction: UIAction { _ in - roomInfoVC.dismiss(animated: true) - }) - - if #unavailable(iOS 26.0) { - cancelButton.tintColor = NCAppBranding.themeTextColor() - } - - navController.navigationBar.topItem?.leftBarButtonItem = cancelButton - - self.present(navController, animated: true) + showRoomInfo() } } diff --git a/NextcloudTalk/Rooms/RoomInfo/RoomInfoSwiftUIView.swift b/NextcloudTalk/Rooms/RoomInfo/RoomInfoSwiftUIView.swift index 98b8218e5..3be5682ab 100644 --- a/NextcloudTalk/Rooms/RoomInfo/RoomInfoSwiftUIView.swift +++ b/NextcloudTalk/Rooms/RoomInfo/RoomInfoSwiftUIView.swift @@ -21,6 +21,7 @@ class HostingControllerWrapper { struct RoomInfoSwiftUIView: View { let hostingWrapper: HostingControllerWrapper + let scrollToParticipantsSectionOnAppear: Bool @State var room: NCRoom @State var showDestructiveActions: Bool = true @@ -28,26 +29,32 @@ struct RoomInfoSwiftUIView: View { @State var profileInfo: ProfileInfo? var body: some View { - List { - RoomInfoHeaderSection(hostingWrapper: hostingWrapper, room: $room, profileInfo: $profileInfo) + ScrollViewReader { proxy in + List { + RoomInfoHeaderSection(hostingWrapper: hostingWrapper, room: $room, profileInfo: $profileInfo) - RoomInfoFileSection(hostingWrapper: hostingWrapper, room: $room, quickLookUrl: $quickLookUrl) - RoomInfoSharedItemsSection(hostingWrapper: hostingWrapper, room: $room) + RoomInfoFileSection(hostingWrapper: hostingWrapper, room: $room, quickLookUrl: $quickLookUrl) + RoomInfoSharedItemsSection(hostingWrapper: hostingWrapper, room: $room) - RoomInfoNotificationSection(room: $room) - RoomInfoConversationSettingsSection(hostingWrapper: hostingWrapper, room: $room) + RoomInfoNotificationSection(room: $room) + RoomInfoConversationSettingsSection(hostingWrapper: hostingWrapper, room: $room) - RoomInfoGuestSection(room: $room) - RoomInfoWebinarSection(room: $room) - RoomInfoSIPInfoSection(room: $room) + RoomInfoGuestSection(room: $room) + RoomInfoWebinarSection(room: $room) + RoomInfoSIPInfoSection(room: $room) - RoomInfoParticipantsSection(hostingWrapper: hostingWrapper, room: $room) + RoomInfoParticipantsSection(hostingWrapper: hostingWrapper, room: $room).id("participantsSection") - RoomInfoNonDestructiveSection(room: $room) + RoomInfoNonDestructiveSection(room: $room) - if showDestructiveActions { - RoomInfoDestructiveSection(room: $room) - } + if showDestructiveActions { + RoomInfoDestructiveSection(room: $room) + } + }.onAppear(perform: { + if scrollToParticipantsSectionOnAppear { + proxy.scrollTo("participantsSection", anchor: .center) + } + }) } .quickLookPreview($quickLookUrl) .environment(\.defaultMinListHeaderHeight, 1) @@ -77,9 +84,9 @@ struct RoomInfoSwiftUIView: View { @objc class RoomInfoUIViewFactory: NSObject { - @objc static func create(room: NCRoom, showDestructiveActions: Bool) -> UIViewController { + @objc static func create(room: NCRoom, showDestructiveActions: Bool, scrollToParticipantsSectionOnAppear: Bool = false) -> UIViewController { let wrapper = HostingControllerWrapper() - let roomInfoView = RoomInfoSwiftUIView(hostingWrapper: wrapper, room: room, showDestructiveActions: showDestructiveActions) + let roomInfoView = RoomInfoSwiftUIView(hostingWrapper: wrapper, scrollToParticipantsSectionOnAppear: scrollToParticipantsSectionOnAppear, room: room, showDestructiveActions: showDestructiveActions) let hostingController = UIHostingController(rootView: roomInfoView) hostingController.title = NSLocalizedString("Conversation settings", comment: "") NCAppBranding.styleViewController(hostingController) diff --git a/NextcloudTalk/Rooms/RoomsTableViewController.m b/NextcloudTalk/Rooms/RoomsTableViewController.m index d4b632060..88a84cdd9 100644 --- a/NextcloudTalk/Rooms/RoomsTableViewController.m +++ b/NextcloudTalk/Rooms/RoomsTableViewController.m @@ -1324,7 +1324,7 @@ - (void)removeRoomFromFavorites:(NCRoom *)room - (void)presentRoomInfoForRoom:(NCRoom *)room { - UIViewController *roomInfoVC = [RoomInfoUIViewFactory createWithRoom:room showDestructiveActions:YES]; + UIViewController *roomInfoVC = [RoomInfoUIViewFactory createWithRoom:room showDestructiveActions:YES scrollToParticipantsSectionOnAppear:NO]; NCNavigationController *navigationController = [[NCNavigationController alloc] initWithRootViewController:roomInfoVC]; UIAction *cancelAction = [UIAction actionWithHandler:^(__kindof UIAction * _Nonnull action) { From a9f36da7d0bca366d04f90d1b37c791065831aba Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Wed, 26 Nov 2025 11:30:23 +0100 Subject: [PATCH 4/5] feat: Show unique participants count in participants counter in call view Signed-off-by: Ivan Sein --- NextcloudTalk/Calls/CallViewController.swift | 25 +++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/NextcloudTalk/Calls/CallViewController.swift b/NextcloudTalk/Calls/CallViewController.swift index c4d4c4605..84ced6a24 100644 --- a/NextcloudTalk/Calls/CallViewController.swift +++ b/NextcloudTalk/Calls/CallViewController.swift @@ -1108,13 +1108,26 @@ class CallViewController: UIViewController, guard self.room.type != .oneToOne, let personImage = UIImage(systemName: "person.2") else { return } - DispatchQueue.main.async { - let participantAttachment = NSTextAttachment(image: personImage.withTintColor(self.participantsLabel.textColor)) - let participantText = NSMutableAttributedString(attachment: participantAttachment) - participantText.append(" \(self.peersInCall.count + 1)".withFont(self.participantsLabel.font)) + WebRTCCommon.shared.dispatch { + var participantsInCall: [TalkActor] = [] + + self.peersInCall.forEach { peerConnection in + let actor = self.callController?.getActorFromSessionId(peerConnection.peerId) ?? TalkActor() + participantsInCall.append(actor) + } - self.participantsLabel.attributedText = participantText - self.participantsLabelContainer.isHidden = false + let ownActor = TalkActor(actorId: self.room.account?.userId, actorType: kParticipantTypeUser) + participantsInCall.append(ownActor) + + DispatchQueue.main.async { + let participantAttachment = NSTextAttachment(image: personImage.withTintColor(self.participantsLabel.textColor)) + let participantText = NSMutableAttributedString(attachment: participantAttachment) + let uniqueParticipantsCount = Set(participantsInCall.map({ $0.id })).count + participantText.append(" \(uniqueParticipantsCount)".withFont(self.participantsLabel.font)) + + self.participantsLabel.attributedText = participantText + self.participantsLabelContainer.isHidden = false + } } } From 12ba9a1fe55c477b194a0ca063e5c76c57757cc0 Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Wed, 26 Nov 2025 11:31:06 +0100 Subject: [PATCH 5/5] feat: Always show "Add participants" option on top and hide "Other participants" section title when all participants are in the call Signed-off-by: Ivan Sein --- .../RoomInfoParticipantsSection.swift | 54 ++++++++++--------- NextcloudTalk/en.lproj/Localizable.strings | 5 +- .../en.lproj/Localizable.stringsdict | 8 +-- 3 files changed, 36 insertions(+), 31 deletions(-) diff --git a/NextcloudTalk/Rooms/RoomInfo/RoomInfoParticipantsSection.swift b/NextcloudTalk/Rooms/RoomInfo/RoomInfoParticipantsSection.swift index 8b3f0c3bc..26bd4f64d 100644 --- a/NextcloudTalk/Rooms/RoomInfo/RoomInfoParticipantsSection.swift +++ b/NextcloudTalk/Rooms/RoomInfo/RoomInfoParticipantsSection.swift @@ -36,17 +36,20 @@ struct RoomInfoParticipantsSection: View { } private var participantCountText: String { - if participants == nil { - return "" - } else if room.hasCall { - return String.localizedStringWithFormat(NSLocalizedString("Other participants", comment: "")) - } else { - return String.localizedStringWithFormat(NSLocalizedString("%ld participants", comment: ""), participants?.count ?? 0) - } + guard let participants else { return "" } + return String.localizedStringWithFormat(NSLocalizedString("%ld participants", comment: ""), participants.count) } private var participantsInCallCountText: String { - return participants == nil ? "" : String.localizedStringWithFormat(NSLocalizedString("%ld participants in the call", comment: ""), participantsInCall.count, participants?.count ?? 0) + return String.localizedStringWithFormat(NSLocalizedString("%ld participants in this call", comment: ""), participantsInCall.count) + } + + private var otherParticipansText: String { + guard let participants else { return "" } + if room.hasCall, !participantsInCall.isEmpty, participantsInCall.count == participants.count { + return "" + } + return String.localizedStringWithFormat(NSLocalizedString("Other participants", comment: "")) } private var participantsInCall: [NCRoomParticipant] { @@ -54,23 +57,8 @@ struct RoomInfoParticipantsSection: View { } var body: (some View)? { - if room.hasCall { - Section(participantsInCallCountText) { - if let participants = Binding($participants) { - ForEach(participants, id: \.self) { $participant in - if participant.inCall.contains(.inCall) { - participantView(participant: $participant) - } - } - } else { - ProgressView() - .listRowInsets(nil) - } - } - } - if room.canAddParticipants { - Section(participantCountText) { + Section(NSLocalizedString("Conversation participants", comment: "")) { Button(action: addParticipants) { if room.type == .oneToOne { ImageSublabelView(image: Image(systemName: "person.badge.plus")) { @@ -88,7 +76,22 @@ struct RoomInfoParticipantsSection: View { } } - Section(room.canAddParticipants ? "" : participantCountText) { + if room.hasCall { + Section(participantsInCallCountText) { + if let participants = Binding($participants) { + ForEach(participants, id: \.self) { $participant in + if participant.inCall.contains(.inCall) { + participantView(participant: $participant) + } + } + } else { + ProgressView() + .listRowInsets(nil) + } + } + } + + Section(room.hasCall ? otherParticipansText : participantCountText) { if let participants = Binding($participants) { ForEach(participants, id: \.self) { $participant in if !room.hasCall || participant.inCall.isEmpty { @@ -103,7 +106,6 @@ struct RoomInfoParticipantsSection: View { .task { getParticipants() } - .listRowInsets(room.canAddParticipants ? EdgeInsets(top: -12, leading: 0, bottom: 0, trailing: 0) : nil) .alert(String(format: NSLocalizedString("Ban %@", comment: "e.g. Ban John Doe"), participantToBan?.displayName ?? "Unknown"), isPresented: $banConfirmationShown) { // Can't move alert inside a menu element, it needs to be outside of the menu diff --git a/NextcloudTalk/en.lproj/Localizable.strings b/NextcloudTalk/en.lproj/Localizable.strings index 9e10f27f7..1039a68bf 100644 --- a/NextcloudTalk/en.lproj/Localizable.strings +++ b/NextcloudTalk/en.lproj/Localizable.strings @@ -68,7 +68,7 @@ "%ld participants" = "%ld participants"; /* No comment provided by engineer. */ -"%ld participants in the call" = "%ld participants in the call"; +"%ld participants in this call" = "%ld participants in this call"; /* Replies in a thread */ "%ld replies" = "%ld replies"; @@ -610,6 +610,9 @@ /* No comment provided by engineer. */ "Conversation not found or not joined" = "Conversation not found or not joined"; +/* No comment provided by engineer. */ +"Conversation participants" = "Conversation participants"; + /* No comment provided by engineer. */ "Conversation settings" = "Conversation settings"; diff --git a/NextcloudTalk/en.lproj/Localizable.stringsdict b/NextcloudTalk/en.lproj/Localizable.stringsdict index 7443ff79d..886d8ba79 100644 --- a/NextcloudTalk/en.lproj/Localizable.stringsdict +++ b/NextcloudTalk/en.lproj/Localizable.stringsdict @@ -36,7 +36,7 @@ - %ld participants in the call + %ld participants in this call NSStringLocalizedFormatKey %#@participants@ @@ -47,11 +47,11 @@ NSStringFormatValueTypeKey ld one - %ld participant in the call + %ld participant in this call many - %ld participants in the call + %ld participants in this call other - %ld participants in the call + %ld participants in this call