From b97589a39cfe76e859e1581a428e10478e5ebd78 Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Thu, 16 Jul 2026 15:05:47 +0200 Subject: [PATCH 01/13] feat(tags): Add conversation tags model, capability and API Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Ivan Sein --- NextcloudTalk/Database/NCConversationTag.h | 25 ++++ NextcloudTalk/Database/NCConversationTag.m | 45 +++++++ .../Database/NCDatabaseManager.swift | 36 +++++- NextcloudTalk/Network/NCAPIController.swift | 113 ++++++++++++++++++ ...NextcloudTalk-Bridging-Header-Extensions.h | 1 + NextcloudTalk/NextcloudTalk-Bridging-Header.h | 1 + NextcloudTalk/Rooms/NCRoom.h | 1 + NextcloudTalk/Rooms/NCRoom.m | 10 ++ NextcloudTalk/Rooms/NCRoom.swift | 9 ++ 9 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 NextcloudTalk/Database/NCConversationTag.h create mode 100644 NextcloudTalk/Database/NCConversationTag.m diff --git a/NextcloudTalk/Database/NCConversationTag.h b/NextcloudTalk/Database/NCConversationTag.h new file mode 100644 index 000000000..d91b33269 --- /dev/null +++ b/NextcloudTalk/Database/NCConversationTag.h @@ -0,0 +1,25 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#import +#import + +extern NSString * const NCConversationTagTypeCustom; +extern NSString * const NCConversationTagTypeFavorites; +extern NSString * const NCConversationTagTypeOther; + +@interface NCConversationTag : RLMObject + +@property (nonatomic, copy, nonnull) NSString *internalId; // accountId@tagId +@property (nonatomic, copy, nonnull) NSString *accountId; +@property (nonatomic, copy) NSString *tagId; +@property (nonatomic, copy) NSString *name; +@property (nonatomic, assign) NSInteger sortOrder; +@property (nonatomic, assign) BOOL collapsed; +@property (nonatomic, copy) NSString *type; + ++ (instancetype _Nullable)conversationTagWithDictionary:(NSDictionary * _Nullable)tagDict andAccountId:(NSString * _Nullable)accountId; + +@end diff --git a/NextcloudTalk/Database/NCConversationTag.m b/NextcloudTalk/Database/NCConversationTag.m new file mode 100644 index 000000000..47c9e42a1 --- /dev/null +++ b/NextcloudTalk/Database/NCConversationTag.m @@ -0,0 +1,45 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#import "NCConversationTag.h" + +NSString * const NCConversationTagTypeCustom = @"custom"; +NSString * const NCConversationTagTypeFavorites = @"favorites"; +NSString * const NCConversationTagTypeOther = @"other"; + +@implementation NCConversationTag + ++ (instancetype)conversationTagWithDictionary:(NSDictionary *)tagDict andAccountId:(NSString *)accountId +{ + if (![tagDict isKindOfClass:[NSDictionary class]] || !accountId) { + return nil; + } + + id tagId = [tagDict objectForKey:@"id"]; + if (![tagId isKindOfClass:[NSString class]] || [tagId length] == 0) { + return nil; + } + + NCConversationTag *tag = [[self alloc] init]; + tag.accountId = accountId; + tag.tagId = tagId; + tag.internalId = [NSString stringWithFormat:@"%@@%@", accountId, tag.tagId]; + tag.sortOrder = [[tagDict objectForKey:@"sortOrder"] integerValue]; + tag.collapsed = [[tagDict objectForKey:@"collapsed"] boolValue]; + + id name = [tagDict objectForKey:@"name"]; + tag.name = [name isKindOfClass:[NSString class]] ? name : @""; + + id type = [tagDict objectForKey:@"type"]; + tag.type = [type isKindOfClass:[NSString class]] ? type : NCConversationTagTypeCustom; + + return tag; +} + ++ (NSString *)primaryKey { + return @"internalId"; +} + +@end diff --git a/NextcloudTalk/Database/NCDatabaseManager.swift b/NextcloudTalk/Database/NCDatabaseManager.swift index 1b6002d25..1c73c22eb 100644 --- a/NextcloudTalk/Database/NCDatabaseManager.swift +++ b/NextcloudTalk/Database/NCDatabaseManager.swift @@ -7,7 +7,7 @@ import Foundation public let kTalkDatabaseFolder = "Library/Application Support/Talk" public let kTalkDatabaseFileName = "talk.realm" -public let kTalkDatabaseSchemaVersion: UInt64 = 90 +public let kTalkDatabaseSchemaVersion: UInt64 = 91 // Objective-C bridge for the Talk database constants that are still referenced from Objective-C code. // These reference the Swift values and can be removed once those call sites are migrated to Swift. @@ -91,6 +91,7 @@ public enum TalkCapability: String { case scheduleMessages = "scheduled-messages" case reactPermission = "react-permission" case botV1 = "bots-v1" + case conversationTags = "conversation-tags" // Talk 12.0 is the minimum required version public static let minimumRequired = TalkCapability.conversationV4 @@ -151,7 +152,8 @@ public extension Notification.Name { configuration.schemaVersion = kTalkDatabaseSchemaVersion configuration.objectClasses = [ TalkAccount.self, NCRoom.self, ServerCapabilities.self, FederatedCapabilities.self, - NCChatMessage.self, NCChatBlock.self, NCContact.self, ABContact.self, NCThread.self + NCChatMessage.self, NCChatBlock.self, NCContact.self, ABContact.self, NCThread.self, + NCConversationTag.self ] configuration.migrationBlock = { _, _ in // At the very minimum we need to update the version with an empty block to indicate that the schema has been upgraded (automatically) by Realm @@ -266,6 +268,7 @@ public extension Notification.Name { realm.deleteObjects(NCThread.objects(with: query)) realm.deleteObjects(NCContact.objects(with: query)) realm.deleteObjects(FederatedCapabilities.objects(with: query)) + realm.deleteObjects(NCConversationTag.objects(with: query)) if isLastAccount { realm.deleteObjects(ABContact.allObjects()) } @@ -835,6 +838,35 @@ public extension Notification.Name { return unmanagedRooms } + + // MARK: - Conversation tags + + func conversationTags(forAccountId accountId: String) -> [NCConversationTag] { + let query = NSPredicate(format: "accountId = %@", accountId) + let managedTags = NCConversationTag.objects(with: query).sortedResults(usingKeyPath: "sortOrder", ascending: true) + + // Create an unmanaged copy of the tags + var unmanagedTags: [NCConversationTag] = [] + + for case let managedTag as NCConversationTag in managedTags { + unmanagedTags.append(NCConversationTag(value: managedTag)) + } + + return unmanagedTags + } + + func updateConversationTags(_ tags: [NCConversationTag], forAccountId accountId: String) { + let realm = RLMRealm.default() + + try? realm.transaction { + let query = NSPredicate(format: "accountId = %@", accountId) + realm.deleteObjects(NCConversationTag.objects(with: query)) + + for tag in tags { + realm.add(tag) + } + } + } } // MARK: - Type-safe capability checks diff --git a/NextcloudTalk/Network/NCAPIController.swift b/NextcloudTalk/Network/NCAPIController.swift index f217c7674..c15c2b959 100644 --- a/NextcloudTalk/Network/NCAPIController.swift +++ b/NextcloudTalk/Network/NCAPIController.swift @@ -1310,6 +1310,119 @@ class NCAPIController: NSObject, NKCommonDelegate { } } + // MARK: - Conversation tags + + public enum ConversationTagError: Error { + case invalidName + case tagLimitReached + case immutableTag + + init?(ocsError: OcsError) { + switch ocsError.errorKey { + case "name": self = .invalidName + case "limit": self = .tagLimitReached + case "type": self = .immutableTag + default: return nil + } + } + } + + private func conversationTagError(from ocsError: OcsError) -> Error { + if let tagError = ConversationTagError(ocsError: ocsError) { + return tagError + } + + return ocsError + } + + @MainActor + public func getConversationTags(forAccount account: TalkAccount) async throws -> [NCConversationTag] { + guard let apiSessionManager = self.getAPISessionManager(forAccountId: account.accountId) + else { throw ApiControllerError.preconditionError } + + let urlString = self.getRequestURL(forConversationEndpoint: "tags", forAccount: account) + let ocsResponse = try await apiSessionManager.getOcs(urlString, account: account) + + return ocsResponse.dataArrayDict?.compactMap { NCConversationTag(dictionary: $0, andAccountId: account.accountId) } ?? [] + } + + @MainActor + public func createConversationTag(_ name: String, forAccount account: TalkAccount) async throws -> NCConversationTag { + guard let apiSessionManager = self.getAPISessionManager(forAccountId: account.accountId) + else { throw ApiControllerError.preconditionError } + + let urlString = self.getRequestURL(forConversationEndpoint: "tags", forAccount: account) + + do { + let ocsResponse = try await apiSessionManager.postOcs(urlString, account: account, parameters: ["name": name]) + + guard let tag = NCConversationTag(dictionary: ocsResponse.dataDict, andAccountId: account.accountId) + else { throw ApiControllerError.unexpectedOcsResponse } + + return tag + } catch let error as OcsError { + throw self.conversationTagError(from: error) + } + } + + @MainActor + public func renameConversationTag(withId tagId: String, to name: String, forAccount account: TalkAccount) async throws -> NCConversationTag { + guard let apiSessionManager = self.getAPISessionManager(forAccountId: account.accountId), + let encodedTagId = tagId.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) + else { throw ApiControllerError.preconditionError } + + let urlString = self.getRequestURL(forConversationEndpoint: "tags/\(encodedTagId)", forAccount: account) + + do { + let ocsResponse = try await apiSessionManager.putOcs(urlString, account: account, parameters: ["name": name]) + + guard let tag = NCConversationTag(dictionary: ocsResponse.dataDict, andAccountId: account.accountId) + else { throw ApiControllerError.unexpectedOcsResponse } + + return tag + } catch let error as OcsError { + throw self.conversationTagError(from: error) + } + } + + @MainActor + public func deleteConversationTag(withId tagId: String, forAccount account: TalkAccount) async throws { + guard let apiSessionManager = self.getAPISessionManager(forAccountId: account.accountId), + let encodedTagId = tagId.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) + else { throw ApiControllerError.preconditionError } + + let urlString = self.getRequestURL(forConversationEndpoint: "tags/\(encodedTagId)", forAccount: account) + + do { + try await apiSessionManager.deleteOcs(urlString, account: account) + } catch let error as OcsError { + throw self.conversationTagError(from: error) + } + } + + @MainActor + public func reorderConversationTags(withOrderedIds orderedIds: [String], forAccount account: TalkAccount) async throws -> [NCConversationTag] { + guard let apiSessionManager = self.getAPISessionManager(forAccountId: account.accountId) + else { throw ApiControllerError.preconditionError } + + let urlString = self.getRequestURL(forConversationEndpoint: "tags/reorder", forAccount: account) + let ocsResponse = try await apiSessionManager.putOcs(urlString, account: account, parameters: ["orderedIds": orderedIds]) + + return ocsResponse.dataArrayDict?.compactMap { NCConversationTag(dictionary: $0, andAccountId: account.accountId) } ?? [] + } + + @MainActor + public func setConversationTags(_ tagIds: [String], forRoom token: String, forAccount account: TalkAccount) async throws -> NCRoom? { + guard let apiSessionManager = self.getAPISessionManager(forAccountId: account.accountId), + let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) + else { throw ApiControllerError.preconditionError } + + let urlString = self.getRequestURL(forConversationEndpoint: "room/\(encodedToken)/tags", forAccount: account) + let ocsResponse = try await apiSessionManager.postOcs(urlString, account: account, parameters: ["tagIds": tagIds]) + + return NCRoom(dictionary: ocsResponse.dataDict, andAccountId: account.accountId) + } + // MARK: - Push notification test @nonobjc diff --git a/NextcloudTalk/NextcloudTalk-Bridging-Header-Extensions.h b/NextcloudTalk/NextcloudTalk-Bridging-Header-Extensions.h index 326dc998a..a8ea82dc4 100644 --- a/NextcloudTalk/NextcloudTalk-Bridging-Header-Extensions.h +++ b/NextcloudTalk/NextcloudTalk-Bridging-Header-Extensions.h @@ -12,6 +12,7 @@ #import "FederatedCapabilities.h" #import "NCRoom.h" #import "NCThread.h" +#import "NCConversationTag.h" #import "NCChatMessage.h" #import "NCChatBlock.h" #import "NCContact.h" diff --git a/NextcloudTalk/NextcloudTalk-Bridging-Header.h b/NextcloudTalk/NextcloudTalk-Bridging-Header.h index 305bee76b..40e63580f 100644 --- a/NextcloudTalk/NextcloudTalk-Bridging-Header.h +++ b/NextcloudTalk/NextcloudTalk-Bridging-Header.h @@ -44,6 +44,7 @@ #import "NCContact.h" #import "ABContact.h" #import "NCThread.h" +#import "NCConversationTag.h" #import "JDStatusBarNotificationPresenter.h" #import "UIResponder+SLKAdditions.h" diff --git a/NextcloudTalk/Rooms/NCRoom.h b/NextcloudTalk/Rooms/NCRoom.h index 44a6ced29..eb4c1a14e 100644 --- a/NextcloudTalk/Rooms/NCRoom.h +++ b/NextcloudTalk/Rooms/NCRoom.h @@ -77,6 +77,7 @@ extern NSString * const NCRoomObjectTypeExtendedConversation; @property (nonatomic, assign) NSInteger hiddenPinnedId; @property (nonatomic, assign) BOOL hasScheduledMessages; @property (nonatomic, assign) NCRoomAttribute attributes; +@property RLMArray *tagIds; + (instancetype _Nullable)roomWithDictionary:(NSDictionary * _Nullable)roomDict andAccountId:(NSString * _Nullable)accountId; diff --git a/NextcloudTalk/Rooms/NCRoom.m b/NextcloudTalk/Rooms/NCRoom.m index 0b597e172..db733f4c0 100644 --- a/NextcloudTalk/Rooms/NCRoom.m +++ b/NextcloudTalk/Rooms/NCRoom.m @@ -74,6 +74,16 @@ + (instancetype)roomWithDictionary:(NSDictionary *)roomDict andAccountId:(NSStri room.hasScheduledMessages = [[roomDict objectForKey:@"hasScheduledMessages"] boolValue]; room.attributes = [[roomDict objectForKey:@"attributes"] integerValue]; + // Only sent by servers with conversation-tags capability + id tagIds = [roomDict objectForKey:@"tagIds"]; + if ([tagIds isKindOfClass:[NSArray class]]) { + for (id tagId in tagIds) { + if ([tagId isKindOfClass:[NSString class]]) { + [room.tagIds addObject:tagId]; + } + } + } + // Local-only field -> update only if there's actually a value if ([roomDict objectForKey:@"pendingMessage"] != nil) { room.pendingMessage = [roomDict objectForKey:@"pendingMessage"]; diff --git a/NextcloudTalk/Rooms/NCRoom.swift b/NextcloudTalk/Rooms/NCRoom.swift index 0e1cd6f89..302c0179f 100644 --- a/NextcloudTalk/Rooms/NCRoom.swift +++ b/NextcloudTalk/Rooms/NCRoom.swift @@ -424,6 +424,12 @@ extension Array where Element == NCRoom { return self.attributes.contains(.voiceRoom) } + // MARK: - Conversation tags + + public var tagIdList: [String] { + return self.tagIds.value(forKey: "self") as? [String] ?? [] + } + // MARK: - Updating public static func update(_ managedRoom: NCRoom, with room: NCRoom) { @@ -482,6 +488,9 @@ extension Array where Element == NCRoom { managedRoom.hiddenPinnedId = room.hiddenPinnedId managedRoom.hasScheduledMessages = room.hasScheduledMessages managedRoom.attributes = room.attributes + + managedRoom.tagIds.removeAllObjects() + managedRoom.tagIds.addObjects(room.tagIds) } } From ca4c7c86ae69225782f8423a4dcf4d26f8aa46c7 Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Thu, 16 Jul 2026 19:05:58 +0200 Subject: [PATCH 02/13] feat(tags): Add tag filter chips to conversation list Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Ivan Sein --- NextcloudTalk/Database/NCConversationTag.h | 8 +- .../Database/NCDatabaseManager.swift | 3 +- NextcloudTalk/Network/NCAPIController.swift | 30 ++- .../NotificationCenterNotifications.h | 1 + .../NotificationCenterNotifications.m | 1 + NextcloudTalk/Rooms/RoomTagsFilterView.swift | 175 ++++++++++++++++++ .../Rooms/RoomsTableViewController.swift | 123 +++++++++++- NextcloudTalk/en.lproj/Localizable.strings | 12 ++ 8 files changed, 342 insertions(+), 11 deletions(-) create mode 100644 NextcloudTalk/Rooms/RoomTagsFilterView.swift diff --git a/NextcloudTalk/Database/NCConversationTag.h b/NextcloudTalk/Database/NCConversationTag.h index d91b33269..b08df5845 100644 --- a/NextcloudTalk/Database/NCConversationTag.h +++ b/NextcloudTalk/Database/NCConversationTag.h @@ -6,14 +6,16 @@ #import #import +NS_ASSUME_NONNULL_BEGIN + extern NSString * const NCConversationTagTypeCustom; extern NSString * const NCConversationTagTypeFavorites; extern NSString * const NCConversationTagTypeOther; @interface NCConversationTag : RLMObject -@property (nonatomic, copy, nonnull) NSString *internalId; // accountId@tagId -@property (nonatomic, copy, nonnull) NSString *accountId; +@property (nonatomic, copy) NSString *internalId; // accountId@tagId +@property (nonatomic, copy) NSString *accountId; @property (nonatomic, copy) NSString *tagId; @property (nonatomic, copy) NSString *name; @property (nonatomic, assign) NSInteger sortOrder; @@ -23,3 +25,5 @@ extern NSString * const NCConversationTagTypeOther; + (instancetype _Nullable)conversationTagWithDictionary:(NSDictionary * _Nullable)tagDict andAccountId:(NSString * _Nullable)accountId; @end + +NS_ASSUME_NONNULL_END diff --git a/NextcloudTalk/Database/NCDatabaseManager.swift b/NextcloudTalk/Database/NCDatabaseManager.swift index 1c73c22eb..c0eaa2b27 100644 --- a/NextcloudTalk/Database/NCDatabaseManager.swift +++ b/NextcloudTalk/Database/NCDatabaseManager.swift @@ -863,7 +863,8 @@ public extension Notification.Name { realm.deleteObjects(NCConversationTag.objects(with: query)) for tag in tags { - realm.add(tag) + // Add a copy, so the passed objects stay unmanaged for the caller + realm.add(NCConversationTag(value: tag)) } } } diff --git a/NextcloudTalk/Network/NCAPIController.swift b/NextcloudTalk/Network/NCAPIController.swift index c15c2b959..c691f28fd 100644 --- a/NextcloudTalk/Network/NCAPIController.swift +++ b/NextcloudTalk/Network/NCAPIController.swift @@ -1335,15 +1335,33 @@ class NCAPIController: NSObject, NKCommonDelegate { return ocsError } - @MainActor - public func getConversationTags(forAccount account: TalkAccount) async throws -> [NCConversationTag] { - guard let apiSessionManager = self.getAPISessionManager(forAccountId: account.accountId) - else { throw ApiControllerError.preconditionError } + public func getConversationTags(forAccount account: TalkAccount, completionBlock: ((_ tags: [NCConversationTag]?, _ error: Error?) -> Void)? = nil) { + guard let apiSessionManager = self.getAPISessionManager(forAccountId: account.accountId) else { + completionBlock?(nil, ApiControllerError.preconditionError) + return + } let urlString = self.getRequestURL(forConversationEndpoint: "tags", forAccount: account) - let ocsResponse = try await apiSessionManager.getOcs(urlString, account: account) + let accountId = account.accountId - return ocsResponse.dataArrayDict?.compactMap { NCConversationTag(dictionary: $0, andAccountId: account.accountId) } ?? [] + apiSessionManager.getOcs(urlString, account: account) { ocsResponse, ocsError in + if let ocsError { + completionBlock?(nil, ocsError) + return + } + + let tags = ocsResponse?.dataArrayDict?.compactMap { NCConversationTag(dictionary: $0, andAccountId: accountId) } ?? [] + + NCDatabaseManager.sharedInstance().updateConversationTags(tags, forAccountId: accountId) + + let userInfo: [AnyHashable: Any] = [ + "tags": tags, + "accountId": accountId + ] + NotificationCenter.default.post(name: .NCConversationTagsUpdated, object: self, userInfo: userInfo) + + completionBlock?(tags, nil) + } } @MainActor diff --git a/NextcloudTalk/NotificationCenterNotifications.h b/NextcloudTalk/NotificationCenterNotifications.h index f88902305..6e2ea6973 100644 --- a/NextcloudTalk/NotificationCenterNotifications.h +++ b/NextcloudTalk/NotificationCenterNotifications.h @@ -21,6 +21,7 @@ extern NSString * const NCRoomCreatedNotification; extern NSString * const NCSelectedUserForChatNotification; extern NSString * const NCUserThreadsUpdatedNotification; extern NSString * const NCUserHasThreadsFlagUpdatedNotification; +extern NSString * const NCConversationTagsUpdatedNotification; extern NSString * const AudioSessionDidChangeRouteNotification; extern NSString * const AudioSessionWasActivatedByProviderNotification; diff --git a/NextcloudTalk/NotificationCenterNotifications.m b/NextcloudTalk/NotificationCenterNotifications.m index 90a760c30..c6ced0c75 100644 --- a/NextcloudTalk/NotificationCenterNotifications.m +++ b/NextcloudTalk/NotificationCenterNotifications.m @@ -19,6 +19,7 @@ NSString * const NCSelectedUserForChatNotification = @"NCSelectedUserForChatNotification"; NSString * const NCUserThreadsUpdatedNotification = @"NCUserThreadsUpdatedNotification"; NSString * const NCUserHasThreadsFlagUpdatedNotification = @"NCUserHasThreadsFlagUpdatedNotification"; +NSString * const NCConversationTagsUpdatedNotification = @"NCConversationTagsUpdatedNotification"; NSString * const AudioSessionDidChangeRouteNotification = @"AudioSessionDidChangeRouteNotification"; NSString * const AudioSessionWasActivatedByProviderNotification = @"AudioSessionWasActivatedByProviderNotification"; diff --git a/NextcloudTalk/Rooms/RoomTagsFilterView.swift b/NextcloudTalk/Rooms/RoomTagsFilterView.swift new file mode 100644 index 000000000..bc3399868 --- /dev/null +++ b/NextcloudTalk/Rooms/RoomTagsFilterView.swift @@ -0,0 +1,175 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit + +struct TagFilterChip { + static let allChipId = "all" + + let id: String + let title: String + let unreadCount: Int + let hasUnreadMention: Bool +} + +class RoomTagsFilterView: UIView { + + static let viewHeight: CGFloat = 48 + + public var onTagSelected: ((String?) -> Void)? + + private let scrollView = UIScrollView() + private let stackView = UIStackView() + private var chips: [TagFilterChip] = [] + private var selectedTagId: String? + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + private func commonInit() { + scrollView.showsHorizontalScrollIndicator = false + scrollView.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(scrollView) + + stackView.axis = .horizontal + stackView.spacing = 8 + stackView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(stackView) + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: self.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + + stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16), + stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16), + stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 8), + stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -8), + stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor, constant: -16) + ]) + } + + public func update(chips: [TagFilterChip], selectedTagId: String?) { + self.chips = chips + self.selectedTagId = selectedTagId + + stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + + for chip in chips { + let chipControl = RoomTagChipControl(chip: chip, selected: isSelected(chip)) + chipControl.addTarget(self, action: #selector(chipTapped(_:)), for: .touchUpInside) + stackView.addArrangedSubview(chipControl) + } + } + + private func isSelected(_ chip: TagFilterChip) -> Bool { + guard let selectedTagId else { return chip.id == TagFilterChip.allChipId } + + return chip.id == selectedTagId + } + + @objc private func chipTapped(_ sender: RoomTagChipControl) { + guard let chip = sender.chip else { return } + + // Tapping "All" or the already selected chip clears the tag filter + if chip.id == TagFilterChip.allChipId || isSelected(chip) { + onTagSelected?(nil) + } else { + onTagSelected?(chip.id) + } + } +} + +private class RoomTagChipControl: UIControl { + + public private(set) var chip: TagFilterChip? + + private let titleLabel = UILabel() + private let contentStackView = UIStackView() + + init(chip: TagFilterChip, selected: Bool) { + super.init(frame: .zero) + + self.chip = chip + self.layer.masksToBounds = true + self.backgroundColor = selected ? NCAppBranding.themeColor() : .secondarySystemFill + + titleLabel.font = .preferredFont(for: .subheadline, weight: .medium) + titleLabel.text = chip.title + titleLabel.textColor = selected ? NCAppBranding.themeTextColor() : .label + + contentStackView.axis = .horizontal + contentStackView.spacing = 8 + contentStackView.alignment = .center + contentStackView.isUserInteractionEnabled = false + contentStackView.translatesAutoresizingMaskIntoConstraints = false + contentStackView.addArrangedSubview(titleLabel) + + if chip.unreadCount > 0 { + // Same unread badge as in the conversation cells + let badgeView = BadgeView() + + if selected { + // Invert the badge colors on the theme-colored background + badgeView.badgeColor = NCAppBranding.themeTextColor() + badgeView.badgeTextColor = NCAppBranding.themeColor() + badgeView.badgeHighlightStyle = .important + } else { + badgeView.badgeColor = NCAppBranding.themeColor() + badgeView.badgeTextColor = NCAppBranding.themeTextColor() + badgeView.badgeHighlightStyle = chip.hasUnreadMention ? .important : .none + } + + badgeView.setBadgeNumber(chip.unreadCount) + contentStackView.addArrangedSubview(badgeView) + } + + self.addSubview(contentStackView) + + // With a badge, use a smaller trailing padding to compensate for + // the badge's internal padding and its rounded shape + let trailingPadding: CGFloat = chip.unreadCount > 0 ? 6 : 12 + + NSLayoutConstraint.activate([ + contentStackView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12), + contentStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -trailingPadding), + contentStackView.topAnchor.constraint(equalTo: self.topAnchor, constant: 2), + contentStackView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -2) + ]) + + self.isAccessibilityElement = true + self.accessibilityLabel = chip.title + self.accessibilityTraits = selected ? [.button, .selected] : [.button] + + if chip.unreadCount > 0 { + let format = NSLocalizedString("%ld conversations with unread messages", comment: "Accessibility label for unread counter on a tag filter") + self.accessibilityValue = String(format: format, chip.unreadCount) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + self.layer.cornerRadius = self.bounds.height / 2 + } + + override var isHighlighted: Bool { + didSet { + self.alpha = isHighlighted ? 0.6 : 1.0 + } + } +} diff --git a/NextcloudTalk/Rooms/RoomsTableViewController.swift b/NextcloudTalk/Rooms/RoomsTableViewController.swift index 31222918e..018492c4b 100644 --- a/NextcloudTalk/Rooms/RoomsTableViewController.swift +++ b/NextcloudTalk/Rooms/RoomsTableViewController.swift @@ -52,6 +52,9 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI private var lastRoomWithMentionIndexPath: IndexPath? private var unreadMentionsBottomButton: UIButton! private var activeFilter: RoomsFilter = .all + private var conversationTags: [NCConversationTag] = [] + private var activeTagFilterId: String? + private var tagsFilterView: RoomTagsFilterView? private var contextMenuActionBlock: (() -> Void)? @@ -152,6 +155,7 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI notificationCenter.addObserver(self, selector: #selector(inviationDidAccept(_:)), name: .FederationInvitationDidAcceptNotification, object: nil) notificationCenter.addObserver(self, selector: #selector(userThreadsUpdated(_:)), name: .NCUserThreadsUpdated, object: nil) notificationCenter.addObserver(self, selector: #selector(userHasThreadsUpdated(_:)), name: .NCUserHasThreadsFlagUpdated, object: nil) + notificationCenter.addObserver(self, selector: #selector(conversationTagsUpdated(_:)), name: .NCConversationTagsUpdated, object: nil) } private func configureFilterButtonInToolbar() { @@ -376,6 +380,22 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI } } + @objc private func conversationTagsUpdated(_ notification: Notification) { + let accountId = notification.userInfo?["accountId"] as? String + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + + guard activeAccount.accountId == accountId else { return } + + conversationTags = notification.userInfo?["tags"] as? [NCConversationTag] ?? [] + + // Clear the tag filter in case the filtered tag was deleted + if let activeTagFilterId, !conversationTags.contains(where: { $0.tagId == activeTagFilterId }) { + self.activeTagFilterId = nil + } + + refreshRoomList() + } + @objc private func notificationWillBePresented(_ notification: Notification) { NCRoomsManager.shared.updateRoomsAndChats(updatingUserStatus: false, onlyLastModified: false, withCompletionBlock: nil) setUnreadMessageForInactiveAccountsIndicator() @@ -392,6 +412,7 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI @objc private func appWillEnterForeground(_ notification: Notification) { if NCConnectionController.shared.appState == .ready { NCRoomsManager.shared.updateRoomsAndChats(updatingUserStatus: true, onlyLastModified: false, withCompletionBlock: nil) + refreshConversationTags() startRefreshRoomsTimer() DispatchQueue.main.async { @@ -418,6 +439,8 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI @objc private func activeAccountDidChange(_ notification: Notification) { DispatchQueue.main.async { self.activeFilter = .all + self.activeTagFilterId = nil + self.conversationTags = [] self.refreshRoomList() // Setup the navigation bar here, otherwise it would only be updated @@ -476,6 +499,7 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI @objc private func refreshControlTarget() { NCRoomsManager.shared.updateRoomsAndChats(updatingUserStatus: true, onlyLastModified: false, withCompletionBlock: nil) + refreshConversationTags() updateUserStatus() // Actuate `Peek` feedback (weak boom) @@ -755,7 +779,17 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI predicate = NSPredicate(format: "isVisible == YES AND isArchived == %@", NSNumber(value: showingArchivedRooms)) } - return (allRooms as NSArray).filtered(using: predicate) as? [NCRoom] ?? [] + var filteredRooms = (allRooms as NSArray).filtered(using: predicate) as? [NCRoom] ?? [] + + if !showingArchivedRooms, let activeTag = activeConversationTagFilter() { + if activeTag.type == NCConversationTagTypeFavorites { + filteredRooms = filteredRooms.filter(\.isFavorite) + } else { + filteredRooms = filteredRooms.filter { $0.tagIdList.contains(activeTag.tagId) } + } + } + + return filteredRooms } private func filterRooms(_ rooms: [NCRoom], with searchString: String) -> [NCRoom] { @@ -782,6 +816,80 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI } } + // MARK: - Conversation tags filter + + private func refreshConversationTags() { + let account = NCDatabaseManager.sharedInstance().activeAccount() + + guard NCDatabaseManager.sharedInstance().serverHasTalkCapability(.conversationTags, forAccountId: account.accountId) else { + conversationTags = [] + updateTagsFilterHeader() + return + } + + // Show cached tags immediately, then revalidate from the server + conversationTags = NCDatabaseManager.sharedInstance().conversationTags(forAccountId: account.accountId) + updateTagsFilterHeader() + + // Revalidate from the server. The result is delivered through the NCConversationTagsUpdated notification + NCAPIController.sharedInstance().getConversationTags(forAccount: account) + } + + private func updateTagsFilterHeader() { + let hasCustomTags = conversationTags.contains { $0.type == NCConversationTagTypeCustom } + + guard !showingArchivedRooms, hasCustomTags else { + self.tableView.tableHeaderView = nil + return + } + + if tagsFilterView == nil { + let filterView = RoomTagsFilterView(frame: .zero) + filterView.onTagSelected = { [weak self] tagId in + guard let self else { return } + + self.activeTagFilterId = tagId + self.filterRooms() + self.updateTagsFilterHeader() + } + tagsFilterView = filterView + } + + guard let tagsFilterView else { return } + + tagsFilterView.update(chips: buildTagFilterChips(), selectedTagId: activeTagFilterId) + + if self.tableView.tableHeaderView != tagsFilterView { + tagsFilterView.frame = CGRect(x: 0, y: 0, width: self.tableView.bounds.width, height: RoomTagsFilterView.viewHeight) + tagsFilterView.autoresizingMask = [.flexibleWidth] + self.tableView.tableHeaderView = tagsFilterView + } + } + + private func buildTagFilterChips() -> [TagFilterChip] { + let allChipTitle = NSLocalizedString("All", comment: "'All' meaning 'All conversations', shown when no tag filter is applied in conversations list") + var chips = [TagFilterChip(id: TagFilterChip.allChipId, title: allChipTitle, unreadCount: 0, hasUnreadMention: false)] + + let visibleRooms = allRooms.filter { $0.isVisible && !$0.isArchived } + + for tag in conversationTags where tag.type != NCConversationTagTypeOther { + let isFavoritesTag = tag.type == NCConversationTagTypeFavorites + let taggedRooms = visibleRooms.filter { isFavoritesTag ? $0.isFavorite : $0.tagIdList.contains(tag.tagId) } + let unreadRooms = taggedRooms.filter { $0.unreadMessages > 0 } + let title = isFavoritesTag ? NSLocalizedString("Favorites", comment: "'Favorites' meaning 'Favorite conversations'") : tag.name + + chips.append(TagFilterChip(id: tag.tagId, title: title, unreadCount: unreadRooms.count, hasUnreadMention: unreadRooms.contains { $0.hasUnreadMention })) + } + + return chips + } + + private func activeConversationTagFilter() -> NCConversationTag? { + guard let activeTagFilterId else { return nil } + + return conversationTags.first { $0.tagId == activeTagFilterId } + } + // MARK: - Rooms filter private func availableFilters() -> [RoomsFilter] { @@ -965,6 +1073,9 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI allRooms = accountRooms rooms = accountRooms + // Update tags filter header (chips + unread counters) + updateTagsFilterHeader() + // Filter rooms filterRooms() @@ -985,7 +1096,14 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI roomsBackgroundView.loadingView.isHidden = true roomsBackgroundView.setImage(filterPlaceholderImage(activeFilter)) - roomsBackgroundView.placeholderTextView.text = filterPlaceholderText(activeFilter) + + // The attribute filter takes precedence, since removing it can be enough to get a non-empty list + if activeFilter == .all, activeConversationTagFilter() != nil { + roomsBackgroundView.placeholderTextView.text = NSLocalizedString("You have no conversations with this tag.", comment: "") + } else { + roomsBackgroundView.placeholderTextView.text = filterPlaceholderText(activeFilter) + } + roomsBackgroundView.placeholderView.isHidden = !rooms.isEmpty } @@ -1002,6 +1120,7 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI NCRoomsManager.shared.updateRooms(updatingUserStatus: isAppActive, onlyLastModified: false) updateUserStatus() getUserThreads() + refreshConversationTags() startRefreshRoomsTimer() setupNavigationBar() default: diff --git a/NextcloudTalk/en.lproj/Localizable.strings b/NextcloudTalk/en.lproj/Localizable.strings index 7d9cc6cce..0e6dcdc08 100644 --- a/NextcloudTalk/en.lproj/Localizable.strings +++ b/NextcloudTalk/en.lproj/Localizable.strings @@ -64,6 +64,9 @@ /* '5 active bots' in this conversation */ "%ld active bots" = "%ld active bots"; +/* Accessibility label for unread counter on a tag filter */ +"%ld conversations with unread messages" = "%ld conversations with unread messages"; + /* No comment provided by engineer. */ "%ld notifications" = "%ld notifications"; @@ -193,6 +196,9 @@ /* No comment provided by engineer. */ "AirPlay button" = "AirPlay button"; +/* 'All' meaning 'All conversations', shown when no tag filter is applied in conversations list */ +"All" = "All"; + /* No comment provided by engineer. */ "All messages" = "All messages"; @@ -1159,6 +1165,9 @@ /* No comment provided by engineer. */ "Failed to unban selected entry" = "Failed to unban selected entry"; +/* 'Favorites' meaning 'Favorite conversations' */ +"Favorites" = "Favorites"; + /* No comment provided by engineer. */ "Federated" = "Federated"; @@ -2618,6 +2627,9 @@ /* No comment provided by engineer. */ "You have been muted by a moderator" = "You have been muted by a moderator"; +/* No comment provided by engineer. */ +"You have no conversations with this tag." = "You have no conversations with this tag."; + /* No comment provided by engineer. */ "You have no meetings scheduled." = "You have no meetings scheduled."; From e95fb174b55fa1446bd9bae16e3d279609b8c6cd Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Thu, 16 Jul 2026 19:07:00 +0200 Subject: [PATCH 03/13] fix(db): Don't leak managed Realm objects to callers Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Ivan Sein --- NextcloudTalk/Chat/NCChatController.swift | 3 ++- NextcloudTalk/Contacts/NCContactsManager.m | 3 ++- NextcloudTalk/Database/NCThread.m | 3 ++- NextcloudTalk/Rooms/NCRoomsManager.swift | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/NextcloudTalk/Chat/NCChatController.swift b/NextcloudTalk/Chat/NCChatController.swift index e407551ea..303e75b0a 100644 --- a/NextcloudTalk/Chat/NCChatController.swift +++ b/NextcloudTalk/Chat/NCChatController.swift @@ -1436,7 +1436,8 @@ public class NCChatController: NSObject { public func getSingleMessage(withMessageId messageId: Int, completionBlock block: ((_ message: NCChatMessage?) -> Void)?) { let query = NSPredicate(format: "accountId = %@ AND token = %@ AND messageId == %ld", account.accountId, room.token, messageId) if let message = NCChatMessage.objects(with: query).firstObject() as? NCChatMessage { - block?(message) + // Return an unmanaged copy, since callers might retain it beyond the lifetime of the stored message + block?(NCChatMessage(value: message)) return } diff --git a/NextcloudTalk/Contacts/NCContactsManager.m b/NextcloudTalk/Contacts/NCContactsManager.m index 3b7e8ffea..f25995f5c 100644 --- a/NextcloudTalk/Contacts/NCContactsManager.m +++ b/NextcloudTalk/Contacts/NCContactsManager.m @@ -121,7 +121,8 @@ - (void)updateAddressBookCopyWithContacts:(NSArray *)contacts andTimestamp:(NSIn if (managedABContact) { [ABContact updateContact:managedABContact withContact:contact]; } else { - [realm addObject:contact]; + // Add a copy, so the passed objects stay unmanaged for the caller + [realm addObject:[[ABContact alloc] initWithValue:contact]]; } } // Delete old contacts diff --git a/NextcloudTalk/Database/NCThread.m b/NextcloudTalk/Database/NCThread.m index b4793421c..d4c3c6e50 100644 --- a/NextcloudTalk/Database/NCThread.m +++ b/NextcloudTalk/Database/NCThread.m @@ -143,7 +143,8 @@ + (void)storeOrUpdateThreads:(NSArray *)threads if (managedThread) { [self updateThread:managedThread withThread:thread]; } else { - [realm addObject:thread]; + // Add a copy, so the passed objects stay unmanaged for the caller + [realm addObject:[[NCThread alloc] initWithValue:thread]]; } } }]; diff --git a/NextcloudTalk/Rooms/NCRoomsManager.swift b/NextcloudTalk/Rooms/NCRoomsManager.swift index 63dfa7b2c..09243aabd 100644 --- a/NextcloudTalk/Rooms/NCRoomsManager.swift +++ b/NextcloudTalk/Rooms/NCRoomsManager.swift @@ -296,7 +296,8 @@ class NCRoomsManager: NSObject, CallViewControllerDelegate { if let managedLastMessage = NCChatMessage.objects(where: "internalId = %@", internalId).firstObject() as? NCChatMessage { NCChatMessage.update(managedLastMessage, with: lastMessage, isRoomLastMessage: true) } else { - let chatController = NCChatController(for: room) + // Pass an unmanaged copy, since "room" might have been added to realm (and became managed) at this point + let chatController = NCChatController(for: NCRoom(value: room)) chatController?.storeMessages([lastMessageDict], with: realm) } } From 8b43cff33e4e70c4c32f655d46afd0a2ec67e0ba Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Fri, 17 Jul 2026 11:42:33 +0200 Subject: [PATCH 04/13] feat(tags): Move threads and archived entries into filter chips Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Ivan Sein --- NextcloudTalk/Rooms/RoomTagsFilterView.swift | 63 ++++-- .../Rooms/RoomsTableViewController.swift | 180 +++++++----------- NextcloudTalk/en.lproj/Localizable.strings | 3 + 3 files changed, 123 insertions(+), 123 deletions(-) diff --git a/NextcloudTalk/Rooms/RoomTagsFilterView.swift b/NextcloudTalk/Rooms/RoomTagsFilterView.swift index bc3399868..60d0e3e22 100644 --- a/NextcloudTalk/Rooms/RoomTagsFilterView.swift +++ b/NextcloudTalk/Rooms/RoomTagsFilterView.swift @@ -7,23 +7,37 @@ import UIKit struct TagFilterChip { static let allChipId = "all" + static let threadsChipId = "threads" + static let archivedChipId = "archived" let id: String let title: String let unreadCount: Int let hasUnreadMention: Bool + let showsUnreadDot: Bool + let icon: UIImage? + + init(id: String, title: String, unreadCount: Int = 0, hasUnreadMention: Bool = false, showsUnreadDot: Bool = false, icon: UIImage? = nil) { + self.id = id + self.title = title + self.unreadCount = unreadCount + self.hasUnreadMention = hasUnreadMention + self.showsUnreadDot = showsUnreadDot + self.icon = icon + } } class RoomTagsFilterView: UIView { static let viewHeight: CGFloat = 48 - public var onTagSelected: ((String?) -> Void)? + public var onChipSelected: ((String) -> Void)? private let scrollView = UIScrollView() private let stackView = UIStackView() + private let feedbackGenerator = UISelectionFeedbackGenerator() private var chips: [TagFilterChip] = [] - private var selectedTagId: String? + private var selectedChipId: String = TagFilterChip.allChipId override init(frame: CGRect) { super.init(frame: frame) @@ -59,34 +73,30 @@ class RoomTagsFilterView: UIView { ]) } - public func update(chips: [TagFilterChip], selectedTagId: String?) { + public func update(chips: [TagFilterChip], selectedChipId: String) { self.chips = chips - self.selectedTagId = selectedTagId + self.selectedChipId = selectedChipId stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } for chip in chips { - let chipControl = RoomTagChipControl(chip: chip, selected: isSelected(chip)) + let chipControl = RoomTagChipControl(chip: chip, selected: chip.id == selectedChipId) + chipControl.addTarget(self, action: #selector(chipTouchedDown(_:)), for: .touchDown) chipControl.addTarget(self, action: #selector(chipTapped(_:)), for: .touchUpInside) stackView.addArrangedSubview(chipControl) } } - private func isSelected(_ chip: TagFilterChip) -> Bool { - guard let selectedTagId else { return chip.id == TagFilterChip.allChipId } - - return chip.id == selectedTagId + @objc private func chipTouchedDown(_ sender: RoomTagChipControl) { + // Wake up the Taptic Engine, so the feedback on touch up plays without delay + feedbackGenerator.prepare() } @objc private func chipTapped(_ sender: RoomTagChipControl) { guard let chip = sender.chip else { return } - // Tapping "All" or the already selected chip clears the tag filter - if chip.id == TagFilterChip.allChipId || isSelected(chip) { - onTagSelected?(nil) - } else { - onTagSelected?(chip.id) - } + feedbackGenerator.selectionChanged() + onChipSelected?(chip.id) } } @@ -113,6 +123,15 @@ private class RoomTagChipControl: UIControl { contentStackView.alignment = .center contentStackView.isUserInteractionEnabled = false contentStackView.translatesAutoresizingMaskIntoConstraints = false + + if let icon = chip.icon { + let iconView = UIImageView(image: icon) + iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .subheadline, scale: .small) + iconView.tintColor = selected ? NCAppBranding.themeTextColor() : .label + contentStackView.addArrangedSubview(iconView) + contentStackView.setCustomSpacing(6, after: iconView) + } + contentStackView.addArrangedSubview(titleLabel) if chip.unreadCount > 0 { @@ -132,6 +151,18 @@ private class RoomTagChipControl: UIControl { badgeView.setBadgeNumber(chip.unreadCount) contentStackView.addArrangedSubview(badgeView) + } else if chip.showsUnreadDot { + // Unread mention indicator without a number, to not grab too much attention + let dotView = UIView() + dotView.backgroundColor = selected ? NCAppBranding.themeTextColor() : NCAppBranding.elementColor() + dotView.layer.cornerRadius = 4 + + NSLayoutConstraint.activate([ + dotView.widthAnchor.constraint(equalToConstant: 8), + dotView.heightAnchor.constraint(equalToConstant: 8) + ]) + + contentStackView.addArrangedSubview(dotView) } self.addSubview(contentStackView) @@ -154,6 +185,8 @@ private class RoomTagChipControl: UIControl { if chip.unreadCount > 0 { let format = NSLocalizedString("%ld conversations with unread messages", comment: "Accessibility label for unread counter on a tag filter") self.accessibilityValue = String(format: format, chip.unreadCount) + } else if chip.showsUnreadDot { + self.accessibilityValue = NSLocalizedString("Unread mentions", comment: "") } } diff --git a/NextcloudTalk/Rooms/RoomsTableViewController.swift b/NextcloudTalk/Rooms/RoomsTableViewController.swift index 018492c4b..5c11703e9 100644 --- a/NextcloudTalk/Rooms/RoomsTableViewController.swift +++ b/NextcloudTalk/Rooms/RoomsTableViewController.swift @@ -20,8 +20,6 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI private enum RoomsSection: Int, CaseIterable { case pendingFederationInvitation = 0 - case threads - case archivedConversations case roomList } @@ -836,28 +834,25 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI } private func updateTagsFilterHeader() { - let hasCustomTags = conversationTags.contains { $0.type == NCConversationTagTypeCustom } + let chips = buildTagFilterChips() - guard !showingArchivedRooms, hasCustomTags else { + // Only show the filter view when there is anything beyond the "All" chip + guard chips.count > 1 else { self.tableView.tableHeaderView = nil return } if tagsFilterView == nil { let filterView = RoomTagsFilterView(frame: .zero) - filterView.onTagSelected = { [weak self] tagId in - guard let self else { return } - - self.activeTagFilterId = tagId - self.filterRooms() - self.updateTagsFilterHeader() + filterView.onChipSelected = { [weak self] chipId in + self?.handleChipSelection(chipId) } tagsFilterView = filterView } guard let tagsFilterView else { return } - tagsFilterView.update(chips: buildTagFilterChips(), selectedTagId: activeTagFilterId) + tagsFilterView.update(chips: chips, selectedChipId: selectedChipId()) if self.tableView.tableHeaderView != tagsFilterView { tagsFilterView.frame = CGRect(x: 0, y: 0, width: self.tableView.bounds.width, height: RoomTagsFilterView.viewHeight) @@ -866,9 +861,45 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI } } + private func selectedChipId() -> String { + if showingArchivedRooms { + return TagFilterChip.archivedChipId + } + + return activeTagFilterId ?? TagFilterChip.allChipId + } + + private func handleChipSelection(_ chipId: String) { + switch chipId { + case TagFilterChip.threadsChipId: + let threadsVC = ThreadsTableViewController(threads: self.threads) + let navigationController = NCNavigationController(rootViewController: threadsVC) + self.present(navigationController, animated: true) + return + case TagFilterChip.archivedChipId: + // Tapping the already selected archived chip returns to the conversation list + showingArchivedRooms = !showingArchivedRooms + activeTagFilterId = nil + case TagFilterChip.allChipId: + showingArchivedRooms = false + activeTagFilterId = nil + default: + // Tapping the already selected tag chip clears the tag filter + activeTagFilterId = (activeTagFilterId == chipId) ? nil : chipId + showingArchivedRooms = false + } + + UIView.transition(with: self.tableView, duration: 0.2, options: .transitionCrossDissolve, animations: { + self.filterRooms() + self.updateMentionsIndicator() + }, completion: nil) + + updateTagsFilterHeader() + } + private func buildTagFilterChips() -> [TagFilterChip] { let allChipTitle = NSLocalizedString("All", comment: "'All' meaning 'All conversations', shown when no tag filter is applied in conversations list") - var chips = [TagFilterChip(id: TagFilterChip.allChipId, title: allChipTitle, unreadCount: 0, hasUnreadMention: false)] + var chips = [TagFilterChip(id: TagFilterChip.allChipId, title: allChipTitle)] let visibleRooms = allRooms.filter { $0.isVisible && !$0.isArchived } @@ -881,9 +912,32 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI chips.append(TagFilterChip(id: tag.tagId, title: title, unreadCount: unreadRooms.count, hasUnreadMention: unreadRooms.contains { $0.hasUnreadMention })) } + let account = NCDatabaseManager.sharedInstance().activeAccount() + if account.hasThreads || (threads?.count ?? 0) > 0 { + let threadsTitle = NSLocalizedString("Threads", comment: "") + chips.append(TagFilterChip(id: TagFilterChip.threadsChipId, title: threadsTitle, icon: UIImage(systemName: "bubble.left.and.bubble.right"))) + } + + // Keep the archived chip visible while showing archived conversations, + // even when the last conversation was unarchived + if !archivedRooms().isEmpty || showingArchivedRooms { + chips.append(archivedChip()) + } + return chips } + private func archivedChip() -> TagFilterChip { + let title = NSLocalizedString("Archived", comment: "'Archived' meaning 'Archived conversations'") + + // No unread numbers for archived conversations, so they don't grab too much attention. + // Just show an indicator when there is an unread mention. + return TagFilterChip(id: TagFilterChip.archivedChipId, + title: title, + showsUnreadDot: areArchivedRoomsWithUnreadMentions(), + icon: UIImage(systemName: "archivebox")) + } + private func activeConversationTagFilter() -> NCConversationTag? { guard let activeTagFilterId else { return nil } @@ -1602,24 +1656,12 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI return account.pendingFederationInvitations > 0 ? 1 : 0 } - if section == RoomsSection.archivedConversations.rawValue { - return (!archivedRooms().isEmpty || showingArchivedRooms) ? 1 : 0 - } - - if section == RoomsSection.threads.rawValue { - let account = NCDatabaseManager.sharedInstance().activeAccount() - return (account.hasThreads || (threads?.count ?? 0) > 0) ? 1 : 0 - } - return rooms.count } override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - if tableView == self.tableView && - (indexPath.section == RoomsSection.pendingFederationInvitation.rawValue || - indexPath.section == RoomsSection.archivedConversations.rawValue || - indexPath.section == RoomsSection.threads.rawValue) { - // No swipe action for pending invitations or archived conversations + if tableView == self.tableView && indexPath.section == RoomsSection.pendingFederationInvitation.rawValue { + // No swipe action for pending invitations return nil } @@ -1650,11 +1692,8 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI } override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - if tableView == self.tableView && - (indexPath.section == RoomsSection.pendingFederationInvitation.rawValue || - indexPath.section == RoomsSection.archivedConversations.rawValue || - indexPath.section == RoomsSection.threads.rawValue) { - // No swipe action for pending invitations or archived conversations + if tableView == self.tableView && indexPath.section == RoomsSection.pendingFederationInvitation.rawValue { + // No swipe action for pending invitations return nil } @@ -1728,59 +1767,6 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI return cell } - if indexPath.section == RoomsSection.archivedConversations.rawValue { - let cell = tableView.dequeueReusableCell(withIdentifier: InfoLabelTableViewCell.identifier) as? InfoLabelTableViewCell ?? InfoLabelTableViewCell(style: .default, reuseIdentifier: InfoLabelTableViewCell.identifier) - - let actionString = showingArchivedRooms ? NSLocalizedString("Back to conversations", comment: "") : NSLocalizedString("Archived conversations", comment: "") - let iconName = showingArchivedRooms ? "arrow.left" : "archivebox" - let resultFont = UIFont.preferredFont(forTextStyle: .headline) - - let attachment = NSTextAttachment() - attachment.image = UIImage(systemName: iconName)?.withRenderingMode(.alwaysTemplate) - attachment.bounds = CGRect(x: 0, y: CGFloat(roundf(Float(resultFont.capHeight) - 20)) / 2, width: 24, height: 20) - - let resultString = NSMutableAttributedString(attributedString: NSAttributedString(attachment: attachment)) - resultString.append(NSAttributedString(string: " ")) - resultString.append(NSAttributedString(string: actionString)) - - let range = NSRange(location: 0, length: resultString.length) - resultString.addAttribute(.font, value: UIFont.preferredFont(forTextStyle: .headline), range: range) - - if !showingArchivedRooms && areArchivedRoomsWithUnreadMentions() { - let mentionAttachment = NSTextAttachment() - mentionAttachment.image = UIImage(systemName: "circle.fill")?.withTintColor(NCAppBranding.elementColor(), renderingMode: .alwaysTemplate) - mentionAttachment.bounds = CGRect(x: 0, y: CGFloat(roundf(Float(resultFont.capHeight) - 20)) / 2, width: 20, height: 20) - - resultString.append(NSAttributedString(string: " ")) - resultString.append(NSAttributedString(attachment: mentionAttachment)) - } - - cell.label.attributedText = resultString - - return cell - } - - if indexPath.section == RoomsSection.threads.rawValue { - let cell = tableView.dequeueReusableCell(withIdentifier: InfoLabelTableViewCell.identifier) as? InfoLabelTableViewCell ?? InfoLabelTableViewCell(style: .default, reuseIdentifier: InfoLabelTableViewCell.identifier) - - let resultFont = UIFont.preferredFont(forTextStyle: .headline) - let attachment = NSTextAttachment() - attachment.image = UIImage(systemName: "bubble.left.and.bubble.right")?.withRenderingMode(.alwaysTemplate) - attachment.bounds = CGRect(x: 0, y: CGFloat(roundf(Float(resultFont.capHeight) - 20)) / 2, width: 24, height: 20) - - let resultString = NSMutableAttributedString(attributedString: NSAttributedString(attachment: attachment)) - resultString.append(NSAttributedString(string: " ")) - resultString.append(NSAttributedString(string: NSLocalizedString("Threads", comment: ""))) - - let range = NSRange(location: 0, length: resultString.length) - resultString.addAttribute(.font, value: UIFont.preferredFont(forTextStyle: .headline), range: range) - - cell.label.attributedText = resultString - cell.separatorInset = UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: .greatestFiniteMagnitude) - - return cell - } - let cell = tableView.dequeueReusableCell(withIdentifier: RoomTableViewCell.identifier) as? RoomTableViewCell ?? RoomTableViewCell(style: .default, reuseIdentifier: RoomTableViewCell.identifier) cell.backgroundColor = .clear @@ -1851,9 +1837,7 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI override func tableView(_ tableView: UITableView, willDisplay rcell: UITableViewCell, forRowAt indexPath: IndexPath) { if tableView != self.tableView || - indexPath.section == RoomsSection.pendingFederationInvitation.rawValue || - indexPath.section == RoomsSection.archivedConversations.rawValue || - indexPath.section == RoomsSection.threads.rawValue { + indexPath.section == RoomsSection.pendingFederationInvitation.rawValue { return } @@ -1887,24 +1871,6 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI return } - if tableView == self.tableView && indexPath.section == RoomsSection.archivedConversations.rawValue { - showingArchivedRooms = !showingArchivedRooms - UIView.transition(with: self.tableView, duration: 0.2, options: .transitionCrossDissolve, animations: { - self.filterRooms() - self.updateMentionsIndicator() - }, completion: nil) - return - } - - if tableView == self.tableView && indexPath.section == RoomsSection.threads.rawValue { - UIView.transition(with: self.tableView, duration: 0.2, options: .transitionCrossDissolve, animations: { - let threadsVC = ThreadsTableViewController(threads: self.threads) - let navigationController = NCNavigationController(rootViewController: threadsVC) - self.present(navigationController, animated: true) - }, completion: nil) - return - } - if tableView == resultTableViewController.tableView { // Messages if let message = resultTableViewController.message(for: indexPath) { @@ -1927,9 +1893,7 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI // swiftlint:disable:next cyclomatic_complexity override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { if tableView != self.tableView || - indexPath.section == RoomsSection.pendingFederationInvitation.rawValue || - indexPath.section == RoomsSection.archivedConversations.rawValue || - indexPath.section == RoomsSection.threads.rawValue { + indexPath.section == RoomsSection.pendingFederationInvitation.rawValue { return nil } diff --git a/NextcloudTalk/en.lproj/Localizable.strings b/NextcloudTalk/en.lproj/Localizable.strings index 0e6dcdc08..b8a6ea6d8 100644 --- a/NextcloudTalk/en.lproj/Localizable.strings +++ b/NextcloudTalk/en.lproj/Localizable.strings @@ -439,6 +439,9 @@ /* No comment provided by engineer. */ "Archive conversation" = "Archive conversation"; +/* 'Archived' meaning 'Archived conversations' */ +"Archived" = "Archived"; + /* No comment provided by engineer. */ "Archived conversations" = "Archived conversations"; From 255e412e9a2bd667dc6c748004886c49addce446 Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Fri, 17 Jul 2026 12:02:09 +0200 Subject: [PATCH 05/13] feat(tags): Differentiate direct and group mentions in chip badges Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Ivan Sein --- NextcloudTalk/Rooms/RoomTagsFilterView.swift | 18 ++++++++--- .../Rooms/RoomsTableViewController.swift | 31 +++++++++++++------ 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/NextcloudTalk/Rooms/RoomTagsFilterView.swift b/NextcloudTalk/Rooms/RoomTagsFilterView.swift index 60d0e3e22..88341eaae 100644 --- a/NextcloudTalk/Rooms/RoomTagsFilterView.swift +++ b/NextcloudTalk/Rooms/RoomTagsFilterView.swift @@ -13,15 +13,17 @@ struct TagFilterChip { let id: String let title: String let unreadCount: Int - let hasUnreadMention: Bool + let mentioned: Bool + let groupMentioned: Bool let showsUnreadDot: Bool let icon: UIImage? - init(id: String, title: String, unreadCount: Int = 0, hasUnreadMention: Bool = false, showsUnreadDot: Bool = false, icon: UIImage? = nil) { + init(id: String, title: String, unreadCount: Int = 0, mentioned: Bool = false, groupMentioned: Bool = false, showsUnreadDot: Bool = false, icon: UIImage? = nil) { self.id = id self.title = title self.unreadCount = unreadCount - self.hasUnreadMention = hasUnreadMention + self.mentioned = mentioned + self.groupMentioned = groupMentioned self.showsUnreadDot = showsUnreadDot self.icon = icon } @@ -142,11 +144,17 @@ private class RoomTagChipControl: UIControl { // Invert the badge colors on the theme-colored background badgeView.badgeColor = NCAppBranding.themeTextColor() badgeView.badgeTextColor = NCAppBranding.themeColor() - badgeView.badgeHighlightStyle = .important } else { badgeView.badgeColor = NCAppBranding.themeColor() badgeView.badgeTextColor = NCAppBranding.themeTextColor() - badgeView.badgeHighlightStyle = chip.hasUnreadMention ? .important : .none + } + + if chip.mentioned { + badgeView.badgeHighlightStyle = .important + } else if chip.groupMentioned { + badgeView.badgeHighlightStyle = .border + } else { + badgeView.badgeHighlightStyle = selected ? .important : .none } badgeView.setBadgeNumber(chip.unreadCount) diff --git a/NextcloudTalk/Rooms/RoomsTableViewController.swift b/NextcloudTalk/Rooms/RoomsTableViewController.swift index 5c11703e9..ff80fb8be 100644 --- a/NextcloudTalk/Rooms/RoomsTableViewController.swift +++ b/NextcloudTalk/Rooms/RoomsTableViewController.swift @@ -909,7 +909,11 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI let unreadRooms = taggedRooms.filter { $0.unreadMessages > 0 } let title = isFavoritesTag ? NSLocalizedString("Favorites", comment: "'Favorites' meaning 'Favorite conversations'") : tag.name - chips.append(TagFilterChip(id: tag.tagId, title: title, unreadCount: unreadRooms.count, hasUnreadMention: unreadRooms.contains { $0.hasUnreadMention })) + chips.append(TagFilterChip(id: tag.tagId, + title: title, + unreadCount: unreadRooms.count, + mentioned: unreadRooms.contains { self.isRoomMentioned($0) }, + groupMentioned: unreadRooms.contains { self.isRoomGroupMentioned($0) })) } let account = NCDatabaseManager.sharedInstance().activeAccount() @@ -1573,6 +1577,22 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI return (allRooms as NSArray).filtered(using: NSPredicate(format: "isArchived == YES")) as? [NCRoom] ?? [] } + private func isRoomMentioned(_ room: NCRoom) -> Bool { + if NCDatabaseManager.sharedInstance().serverHasTalkCapability(.directMentionFlag) { + return room.unreadMentionDirect || room.type == .oneToOne || room.type == .formerOneToOne + } + + return room.unreadMention || room.type == .oneToOne || room.type == .formerOneToOne + } + + private func isRoomGroupMentioned(_ room: NCRoom) -> Bool { + if NCDatabaseManager.sharedInstance().serverHasTalkCapability(.directMentionFlag) { + return room.unreadMention && !room.unreadMentionDirect + } + + return false + } + private func areArchivedRoomsWithUnreadMentions() -> Bool { return !(allRooms as NSArray).filtered(using: NSPredicate(format: "hasUnreadMention == YES AND isArchived == YES")).isEmpty } @@ -1795,14 +1815,7 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI } // Set unread messages - if NCDatabaseManager.sharedInstance().serverHasTalkCapability(.directMentionFlag) { - let mentioned = room.unreadMentionDirect || room.type == .oneToOne || room.type == .formerOneToOne - let groupMentioned = room.unreadMention && !room.unreadMentionDirect - cell.setUnread(messages: room.unreadMessages, mentioned: mentioned, groupMentioned: groupMentioned) - } else { - let mentioned = room.unreadMention || room.type == .oneToOne || room.type == .formerOneToOne - cell.setUnread(messages: room.unreadMessages, mentioned: mentioned, groupMentioned: false) - } + cell.setUnread(messages: room.unreadMessages, mentioned: isRoomMentioned(room), groupMentioned: isRoomGroupMentioned(room)) if room.unreadMessages > 0 { // When there are unread messages, we need to show the subtitle at the moment From 723b09fe16ca74582efe267ef5249706e9a87776 Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Fri, 17 Jul 2026 13:25:34 +0200 Subject: [PATCH 06/13] feat(tags): Adjust font and padding of chips Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Ivan Sein --- .../RoomInfo/RoomInfoGuestPasswordSave.swift | 8 +++--- NextcloudTalk/Rooms/RoomTagsFilterView.swift | 28 +++++++++++++------ .../Rooms/RoomsTableViewController.swift | 8 +++++- .../User Interface/PillShapeMetrics.swift | 13 +++++++++ 4 files changed, 43 insertions(+), 14 deletions(-) create mode 100644 NextcloudTalk/User Interface/PillShapeMetrics.swift diff --git a/NextcloudTalk/Rooms/RoomInfo/RoomInfoGuestPasswordSave.swift b/NextcloudTalk/Rooms/RoomInfo/RoomInfoGuestPasswordSave.swift index 034c79ae3..6aa9d451e 100644 --- a/NextcloudTalk/Rooms/RoomInfo/RoomInfoGuestPasswordSave.swift +++ b/NextcloudTalk/Rooms/RoomInfo/RoomInfoGuestPasswordSave.swift @@ -130,8 +130,8 @@ struct RoomInfoGuestPasswordSave: View { resetValues() } .foregroundColor(.white) - .padding(.horizontal, 16) - .padding(.vertical, 8) + .padding(.horizontal, PillShapeMetrics.horizontalPadding) + .padding(.vertical, PillShapeMetrics.verticalPadding) .background(isSaveEnabled ? Color.green : Color.gray.opacity(0.3)) .clipShape(Capsule()) .buttonStyle(.plain) @@ -144,8 +144,8 @@ struct RoomInfoGuestPasswordSave: View { resetValues() } .foregroundColor(.white) - .padding(.horizontal, 16) - .padding(.vertical, 8) + .padding(.horizontal, PillShapeMetrics.horizontalPadding) + .padding(.vertical, PillShapeMetrics.verticalPadding) .background(Color.blue) .clipShape(Capsule()) .buttonStyle(.plain) diff --git a/NextcloudTalk/Rooms/RoomTagsFilterView.swift b/NextcloudTalk/Rooms/RoomTagsFilterView.swift index 88341eaae..7f14c77a9 100644 --- a/NextcloudTalk/Rooms/RoomTagsFilterView.swift +++ b/NextcloudTalk/Rooms/RoomTagsFilterView.swift @@ -31,7 +31,14 @@ struct TagFilterChip { class RoomTagsFilterView: UIView { - static let viewHeight: CGFloat = 48 + static let chipVerticalPadding: CGFloat = PillShapeMetrics.verticalPadding + static let rowVerticalPadding: CGFloat = 8 + + // Adapts to the current dynamic type size of the chip title font + static var viewHeight: CGFloat { + let titleHeight = ceil(UIFont.preferredFont(forTextStyle: .headline).lineHeight) + return titleHeight + chipVerticalPadding * 2 + rowVerticalPadding * 2 + } public var onChipSelected: ((String) -> Void)? @@ -69,9 +76,9 @@ class RoomTagsFilterView: UIView { stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16), stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16), - stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 8), - stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -8), - stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor, constant: -16) + stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: Self.rowVerticalPadding), + stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -Self.rowVerticalPadding), + stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor, constant: -(Self.rowVerticalPadding * 2)) ]) } @@ -114,9 +121,12 @@ private class RoomTagChipControl: UIControl { self.chip = chip self.layer.masksToBounds = true - self.backgroundColor = selected ? NCAppBranding.themeColor() : .secondarySystemFill + // Same background as InfoLabelTableViewCell (e.g. pending invitations row) + self.backgroundColor = selected ? NCAppBranding.themeColor() : .secondarySystemBackground - titleLabel.font = .preferredFont(for: .subheadline, weight: .medium) + // Same font as the title in the conversation cells + titleLabel.font = .preferredFont(forTextStyle: .headline) + titleLabel.adjustsFontForContentSizeCategory = true titleLabel.text = chip.title titleLabel.textColor = selected ? NCAppBranding.themeTextColor() : .label @@ -128,7 +138,7 @@ private class RoomTagChipControl: UIControl { if let icon = chip.icon { let iconView = UIImageView(image: icon) - iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .subheadline, scale: .small) + iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .headline, scale: .small) iconView.tintColor = selected ? NCAppBranding.themeTextColor() : .label contentStackView.addArrangedSubview(iconView) contentStackView.setCustomSpacing(6, after: iconView) @@ -177,10 +187,10 @@ private class RoomTagChipControl: UIControl { // With a badge, use a smaller trailing padding to compensate for // the badge's internal padding and its rounded shape - let trailingPadding: CGFloat = chip.unreadCount > 0 ? 6 : 12 + let trailingPadding: CGFloat = chip.unreadCount > 0 ? PillShapeMetrics.horizontalPadding - 6 : PillShapeMetrics.horizontalPadding NSLayoutConstraint.activate([ - contentStackView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12), + contentStackView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: PillShapeMetrics.horizontalPadding), contentStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -trailingPadding), contentStackView.topAnchor.constraint(equalTo: self.topAnchor, constant: 2), contentStackView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -2) diff --git a/NextcloudTalk/Rooms/RoomsTableViewController.swift b/NextcloudTalk/Rooms/RoomsTableViewController.swift index ff80fb8be..cea83e5e4 100644 --- a/NextcloudTalk/Rooms/RoomsTableViewController.swift +++ b/NextcloudTalk/Rooms/RoomsTableViewController.swift @@ -320,6 +320,10 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI setProfileButton() setupNavigationBar() } + + if self.traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory { + updateTagsFilterHeader() + } } // MARK: - Notifications @@ -854,9 +858,11 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI tagsFilterView.update(chips: chips, selectedChipId: selectedChipId()) - if self.tableView.tableHeaderView != tagsFilterView { + if self.tableView.tableHeaderView != tagsFilterView || tagsFilterView.frame.height != RoomTagsFilterView.viewHeight { tagsFilterView.frame = CGRect(x: 0, y: 0, width: self.tableView.bounds.width, height: RoomTagsFilterView.viewHeight) tagsFilterView.autoresizingMask = [.flexibleWidth] + + // Reassign the header view, so the table view picks up the new height (e.g. after dynamic type changes) self.tableView.tableHeaderView = tagsFilterView } } diff --git a/NextcloudTalk/User Interface/PillShapeMetrics.swift b/NextcloudTalk/User Interface/PillShapeMetrics.swift new file mode 100644 index 000000000..fa62aeab7 --- /dev/null +++ b/NextcloudTalk/User Interface/PillShapeMetrics.swift @@ -0,0 +1,13 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation + +// Common content padding for pill/capsule shaped buttons and chips. +// Matches the default content insets of a medium sized UIButton.Configuration with capsule corner style. +enum PillShapeMetrics { + static let horizontalPadding: CGFloat = 14 + static let verticalPadding: CGFloat = 7 +} From 3d603b8eb0c34806b56ca6c8da405e5550c7bb63 Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Fri, 17 Jul 2026 17:10:51 +0200 Subject: [PATCH 07/13] feat(tags): Add tag assignment and management views Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Ivan Sein --- .../Rooms/RoomInfo/RoomInfoSwiftUIView.swift | 5 + .../Rooms/RoomInfo/RoomInfoTagsSection.swift | 43 ++++ .../Rooms/RoomTagsAssignmentView.swift | 169 +++++++++++++ NextcloudTalk/Rooms/RoomTagsFilterView.swift | 9 + .../Rooms/RoomTagsManagementView.swift | 238 ++++++++++++++++++ .../Rooms/RoomsTableViewController.swift | 27 ++ NextcloudTalk/en.lproj/Localizable.strings | 39 +++ 7 files changed, 530 insertions(+) create mode 100644 NextcloudTalk/Rooms/RoomInfo/RoomInfoTagsSection.swift create mode 100644 NextcloudTalk/Rooms/RoomTagsAssignmentView.swift create mode 100644 NextcloudTalk/Rooms/RoomTagsManagementView.swift diff --git a/NextcloudTalk/Rooms/RoomInfo/RoomInfoSwiftUIView.swift b/NextcloudTalk/Rooms/RoomInfo/RoomInfoSwiftUIView.swift index d2007320f..c4665a001 100644 --- a/NextcloudTalk/Rooms/RoomInfo/RoomInfoSwiftUIView.swift +++ b/NextcloudTalk/Rooms/RoomInfo/RoomInfoSwiftUIView.swift @@ -17,6 +17,10 @@ class HostingControllerWrapper { func presentViewController(_ vc: UIViewController, animated: Bool) { controller?.present(vc, animated: animated) } + + func dismissViewController(animated: Bool) { + controller?.dismiss(animated: animated) + } } struct RoomInfoSwiftUIView: View { @@ -32,6 +36,7 @@ struct RoomInfoSwiftUIView: View { ScrollViewReader { proxy in List { RoomInfoHeaderSection(hostingWrapper: hostingWrapper, room: $room, profileInfo: $profileInfo) + RoomInfoTagsSection(hostingWrapper: hostingWrapper, room: $room) RoomInfoFileSection(hostingWrapper: hostingWrapper, room: $room, quickLookUrl: $quickLookUrl) RoomInfoSharedItemsSection(hostingWrapper: hostingWrapper, room: $room) diff --git a/NextcloudTalk/Rooms/RoomInfo/RoomInfoTagsSection.swift b/NextcloudTalk/Rooms/RoomInfo/RoomInfoTagsSection.swift new file mode 100644 index 000000000..acb35c615 --- /dev/null +++ b/NextcloudTalk/Rooms/RoomInfo/RoomInfoTagsSection.swift @@ -0,0 +1,43 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import SwiftUI + +struct RoomInfoTagsSection: View { + let hostingWrapper: HostingControllerWrapper + @Binding var room: NCRoom + + var body: (some View)? { + guard NCDatabaseManager.sharedInstance().serverHasTalkCapability(.conversationTags, forAccountId: room.accountId), + room.type != .changelog, room.type != .noteToSelf + else { + return Body.none + } + + return Section { + Button(action: { + hostingWrapper.presentViewController(RoomTagsAssignmentView.viewController(for: room, withAccount: room.account!), animated: true) + }, label: { + ImageSublabelView(image: Image(systemName: "tag")) { + HStack { + Text("Tags") + Spacer() + Text(assignedTagNames()) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + }) + .foregroundColor(.primary) + } + } + + private func assignedTagNames() -> String { + let tags = NCDatabaseManager.sharedInstance().conversationTags(forAccountId: room.accountId) + let assignedIds = room.tagIdList + + return tags.filter { assignedIds.contains($0.tagId) }.map { $0.name }.joined(separator: ", ") + } +} diff --git a/NextcloudTalk/Rooms/RoomTagsAssignmentView.swift b/NextcloudTalk/Rooms/RoomTagsAssignmentView.swift new file mode 100644 index 000000000..01e276ef2 --- /dev/null +++ b/NextcloudTalk/Rooms/RoomTagsAssignmentView.swift @@ -0,0 +1,169 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import SwiftUI + +struct RoomTagsAssignmentView: View { + + let room: NCRoom + let account: TalkAccount + let hostingWrapper: HostingControllerWrapper + + @State private var customTags: [NCConversationTag] = [] + @State private var assignedTagIds: Set = [] + @State private var newTagName = "" + @State private var errorMessage: String? + @State private var showError = false + + var body: some View { + List { + Section { + ForEach(customTags, id: \.tagId) { tag in + Button(action: { + toggle(tag) + }, label: { + HStack { + Text(tag.name) + .foregroundColor(.primary) + Spacer() + if assignedTagIds.contains(tag.tagId) { + Image(systemName: "checkmark") + .foregroundColor(Color(NCAppBranding.elementColor())) + } + } + }) + } + + // Create a new tag and directly assign it to this conversation + HStack { + TextField(NSLocalizedString("New tag", comment: "Placeholder for the name of a new tag"), text: $newTagName) + Button(NSLocalizedString("Create", comment: "Create a new tag")) { + createAndAssignTag() + } + .disabled(newTagName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + + Section { + Button(action: { + hostingWrapper.presentViewController(RoomTagsManagementView.viewController(forAccount: account), animated: true) + }, label: { + HStack { + Image(systemName: "gearshape") + Text("Manage tags") + } + .foregroundColor(.primary) + }) + } + } + .navigationBarTitle(Text(NSLocalizedString("Tags", comment: "'Tags' meaning 'Conversation tags'")), displayMode: .inline) + .navigationBarHidden(false) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { + // Environment dismiss is not wired up to the UIKit modal presentation + hostingWrapper.dismissViewController(animated: true) + }, label: { + Text("Close") + .foregroundColor(Color(getTintColor())) + }) + } + } + .onAppear { + loadTags() + } + .onReceive(NotificationCenter.default.publisher(for: .NCConversationTagsUpdated)) { output in + guard output.userInfo?["accountId"] as? String == account.accountId, + let tags = output.userInfo?["tags"] as? [NCConversationTag] + else { return } + + customTags = tags.filter { $0.type == NCConversationTagTypeCustom } + } + .alert(errorMessage ?? NSLocalizedString("Could not update tags", comment: ""), isPresented: $showError) { + Button(NSLocalizedString("OK", comment: ""), role: .cancel) {} + } + } + + private func getTintColor() -> UIColor { + if #available(iOS 26.0, *) { + return .label + } else { + return NCAppBranding.themeTextColor() + } + } + + private func loadTags() { + customTags = NCDatabaseManager.sharedInstance().conversationTags(forAccountId: account.accountId).filter { $0.type == NCConversationTagTypeCustom } + assignedTagIds = Set(room.tagIdList) + + // Revalidate from the server. The result is delivered through the NCConversationTagsUpdated notification + NCAPIController.sharedInstance().getConversationTags(forAccount: account) + } + + private func createAndAssignTag() { + let name = newTagName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + + Task { + do { + let tag = try await NCAPIController.sharedInstance().createConversationTag(name, forAccount: account) + + newTagName = "" + customTags.append(tag) + toggle(tag) + } catch { + if let tagError = error as? NCAPIController.ConversationTagError { + errorMessage = tagError.localizedMessage + } else { + errorMessage = nil + } + + showError = true + } + } + } + + private func toggle(_ tag: NCConversationTag) { + // Optimistically apply the new state and roll back in case the request fails + let previousTagIds = assignedTagIds + + if assignedTagIds.contains(tag.tagId) { + assignedTagIds.remove(tag.tagId) + } else { + assignedTagIds.insert(tag.tagId) + } + + let newTagIds = Array(assignedTagIds) + + Task { + do { + _ = try await NCAPIController.sharedInstance().setConversationTags(newTagIds, forRoom: room.token, forAccount: account) + + // Refresh the room, so the tag changes are stored and reflected in the conversation list + NCRoomsManager.shared.updateRoom(room.token, forAccount: account) + } catch { + assignedTagIds = previousTagIds + errorMessage = nil + showError = true + } + } + } +} + +extension RoomTagsAssignmentView { + + static func viewController(for room: NCRoom, withAccount account: TalkAccount) -> UIViewController { + let wrapper = HostingControllerWrapper() + let hostingController = UIHostingController(rootView: RoomTagsAssignmentView(room: room, account: account, hostingWrapper: wrapper)) + wrapper.controller = hostingController + + let navigationController = NCNavigationController(rootViewController: hostingController) + + // Style the content view controller, so the navigation bar gets the theme color (pre iOS 26) + NCAppBranding.styleViewController(hostingController) + + return navigationController + } +} diff --git a/NextcloudTalk/Rooms/RoomTagsFilterView.swift b/NextcloudTalk/Rooms/RoomTagsFilterView.swift index 7f14c77a9..dbba03d56 100644 --- a/NextcloudTalk/Rooms/RoomTagsFilterView.swift +++ b/NextcloudTalk/Rooms/RoomTagsFilterView.swift @@ -41,6 +41,7 @@ class RoomTagsFilterView: UIView { } public var onChipSelected: ((String) -> Void)? + public var onChipLongPressed: (() -> Void)? private let scrollView = UIScrollView() private let stackView = UIStackView() @@ -92,10 +93,18 @@ class RoomTagsFilterView: UIView { let chipControl = RoomTagChipControl(chip: chip, selected: chip.id == selectedChipId) chipControl.addTarget(self, action: #selector(chipTouchedDown(_:)), for: .touchDown) chipControl.addTarget(self, action: #selector(chipTapped(_:)), for: .touchUpInside) + chipControl.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(chipLongPressed(_:)))) stackView.addArrangedSubview(chipControl) } } + @objc private func chipLongPressed(_ recognizer: UILongPressGestureRecognizer) { + guard recognizer.state == .began else { return } + + feedbackGenerator.selectionChanged() + onChipLongPressed?() + } + @objc private func chipTouchedDown(_ sender: RoomTagChipControl) { // Wake up the Taptic Engine, so the feedback on touch up plays without delay feedbackGenerator.prepare() diff --git a/NextcloudTalk/Rooms/RoomTagsManagementView.swift b/NextcloudTalk/Rooms/RoomTagsManagementView.swift new file mode 100644 index 000000000..db1087a10 --- /dev/null +++ b/NextcloudTalk/Rooms/RoomTagsManagementView.swift @@ -0,0 +1,238 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import SwiftUI + +extension NCAPIController.ConversationTagError { + + var localizedMessage: String { + switch self { + case .invalidName: + return NSLocalizedString("This tag name is invalid or already in use", comment: "") + case .tagLimitReached: + return NSLocalizedString("You have reached the maximum number of tags", comment: "") + case .immutableTag: + return NSLocalizedString("This tag cannot be changed", comment: "") + } + } +} + +struct RoomTagsManagementView: View { + + let account: TalkAccount + let hostingWrapper: HostingControllerWrapper + + @State private var tags: [NCConversationTag] = [] + @State private var newTagName = "" + @State private var errorMessage: String? + @State private var showErrorAlert = false + + @State private var tagToRename: NCConversationTag? + @State private var renamedTagName = "" + @State private var showRenameAlert = false + + @State private var tagToDelete: NCConversationTag? + @State private var showDeleteConfirmation = false + + var body: some View { + List { + Section { + ForEach(tags, id: \.tagId) { tag in + if tag.type == NCConversationTagTypeCustom { + Text(tag.name) + .swipeActions { + Button(role: .destructive, action: { + tagToDelete = tag + showDeleteConfirmation = true + }, label: { + Image(systemName: "trash") + }) + + Button(action: { + tagToRename = tag + renamedTagName = tag.name + showRenameAlert = true + }, label: { + Image(systemName: "pencil") + }) + } + } else { + // Built-in favorites tag can be reordered, but not renamed or deleted + HStack { + Text(NSLocalizedString("Favorites", comment: "'Favorites' meaning 'Favorite conversations'")) + Spacer() + Image(systemName: "star.fill") + .foregroundColor(.secondary) + } + } + } + .onMove(perform: move) + } footer: { + if !tags.isEmpty { + Text("Swipe a tag to rename or delete it. Use edit to reorder the tags.") + } + } + + Section { + HStack { + TextField(NSLocalizedString("New tag", comment: "Placeholder for the name of a new tag"), text: $newTagName) + Button(NSLocalizedString("Create", comment: "Create a new tag")) { + createTag() + } + .disabled(newTagName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + .navigationBarTitle(Text(NSLocalizedString("Manage tags", comment: "")), displayMode: .inline) + .navigationBarHidden(false) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { + // Environment dismiss is not wired up to the UIKit modal presentation + hostingWrapper.dismissViewController(animated: true) + }, label: { + Text("Close") + .foregroundColor(Color(getTintColor())) + }) + } + ToolbarItem(placement: .navigationBarTrailing) { + EditButton() + .tint(Color(getTintColor())) + } + } + .onAppear { + loadTags() + } + .alert(NSLocalizedString("Rename tag", comment: ""), isPresented: $showRenameAlert) { + TextField(NSLocalizedString("Tag name", comment: ""), text: $renamedTagName) + Button(NSLocalizedString("Save", comment: "")) { + renameTag() + } + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) {} + } + .confirmationDialog(NSLocalizedString("Delete tag?", comment: ""), isPresented: $showDeleteConfirmation, titleVisibility: .visible) { + Button(NSLocalizedString("Delete", comment: ""), role: .destructive) { + deleteTag() + } + } message: { + Text("This tag will be removed from all conversations it is assigned to.") + } + .alert(errorMessage ?? NSLocalizedString("An error occurred, please try again", comment: ""), isPresented: $showErrorAlert) { + Button(NSLocalizedString("OK", comment: ""), role: .cancel) {} + } + } + + private func getTintColor() -> UIColor { + if #available(iOS 26.0, *) { + return .label + } else { + return NCAppBranding.themeTextColor() + } + } + + private func loadTags() { + tags = NCDatabaseManager.sharedInstance().conversationTags(forAccountId: account.accountId).filter { $0.type != NCConversationTagTypeOther } + + refreshTagsFromServer() + } + + private func refreshTagsFromServer() { + // Also updates the stored tags and notifies the conversation list + NCAPIController.sharedInstance().getConversationTags(forAccount: account) { fetchedTags, _ in + guard let fetchedTags else { return } + + tags = fetchedTags.filter { $0.type != NCConversationTagTypeOther } + } + } + + private func showError(_ error: Error) { + if let tagError = error as? NCAPIController.ConversationTagError { + errorMessage = tagError.localizedMessage + } else { + errorMessage = nil + } + + showErrorAlert = true + } + + private func createTag() { + let name = newTagName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + + Task { + do { + _ = try await NCAPIController.sharedInstance().createConversationTag(name, forAccount: account) + newTagName = "" + refreshTagsFromServer() + } catch { + showError(error) + } + } + } + + private func renameTag() { + guard let tagToRename else { return } + + let name = renamedTagName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, name != tagToRename.name else { return } + + Task { + do { + _ = try await NCAPIController.sharedInstance().renameConversationTag(withId: tagToRename.tagId, to: name, forAccount: account) + refreshTagsFromServer() + } catch { + showError(error) + } + } + } + + private func deleteTag() { + guard let tagToDelete else { return } + + Task { + do { + try await NCAPIController.sharedInstance().deleteConversationTag(withId: tagToDelete.tagId, forAccount: account) + refreshTagsFromServer() + + // The tag was removed from all conversations, so refresh the rooms to update their tags + NCRoomsManager.shared.updateRooms(updatingUserStatus: false, onlyLastModified: false) + } catch { + showError(error) + } + } + } + + private func move(from source: IndexSet, to destination: Int) { + tags.move(fromOffsets: source, toOffset: destination) + + let orderedIds = tags.map { $0.tagId } + + Task { + do { + _ = try await NCAPIController.sharedInstance().reorderConversationTags(withOrderedIds: orderedIds, forAccount: account) + refreshTagsFromServer() + } catch { + showError(error) + refreshTagsFromServer() + } + } + } +} + +extension RoomTagsManagementView { + + static func viewController(forAccount account: TalkAccount) -> UIViewController { + let wrapper = HostingControllerWrapper() + let hostingController = UIHostingController(rootView: RoomTagsManagementView(account: account, hostingWrapper: wrapper)) + wrapper.controller = hostingController + + let navigationController = NCNavigationController(rootViewController: hostingController) + + // Style the content view controller, so the navigation bar gets the theme color (pre iOS 26) + NCAppBranding.styleViewController(hostingController) + + return navigationController + } +} diff --git a/NextcloudTalk/Rooms/RoomsTableViewController.swift b/NextcloudTalk/Rooms/RoomsTableViewController.swift index cea83e5e4..c4c1507d2 100644 --- a/NextcloudTalk/Rooms/RoomsTableViewController.swift +++ b/NextcloudTalk/Rooms/RoomsTableViewController.swift @@ -7,6 +7,7 @@ import UIKit import AudioToolbox import Realm import NextcloudKit +import SwiftUI @objc(RoomsTableViewController) class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UISearchBarDelegate, UISearchControllerDelegate, UISearchResultsUpdating, UserStatusViewDelegate { @@ -851,6 +852,11 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI filterView.onChipSelected = { [weak self] chipId in self?.handleChipSelection(chipId) } + filterView.onChipLongPressed = { [weak self] in + guard NCDatabaseManager.sharedInstance().serverHasTalkCapability(.conversationTags) else { return } + + self?.presentTagsManagement() + } tagsFilterView = filterView } @@ -937,6 +943,16 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI return chips } + private func presentTagsAssignment(for room: NCRoom) { + let account = NCDatabaseManager.sharedInstance().activeAccount() + self.present(RoomTagsAssignmentView.viewController(for: room, withAccount: account), animated: true) + } + + private func presentTagsManagement() { + let account = NCDatabaseManager.sharedInstance().activeAccount() + self.present(RoomTagsManagementView.viewController(forAccount: account), animated: true) + } + private func archivedChip() -> TagFilterChip { let title = NSLocalizedString("Archived", comment: "'Archived' meaning 'Archived conversations'") @@ -1934,6 +1950,17 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI actions.append(favAction) + // Assign tags + if NCDatabaseManager.sharedInstance().serverHasTalkCapability(.conversationTags) { + let tagsAction = UIAction(title: NSLocalizedString("Tags", comment: "'Tags' meaning 'Conversation tags'"), image: UIImage(systemName: "tag")) { [weak self] _ in + self?.contextMenuActionBlock = { + self?.presentTagsAssignment(for: room) + } + } + + actions.append(tagsAction) + } + // Mark room as read/unread if NCDatabaseManager.sharedInstance().serverHasTalkCapability(.chatReadMarker) && (!room.isFederated || NCDatabaseManager.sharedInstance().serverHasTalkCapability(.chatReadLast)) { diff --git a/NextcloudTalk/en.lproj/Localizable.strings b/NextcloudTalk/en.lproj/Localizable.strings index b8a6ea6d8..6286a0016 100644 --- a/NextcloudTalk/en.lproj/Localizable.strings +++ b/NextcloudTalk/en.lproj/Localizable.strings @@ -415,6 +415,9 @@ /* No comment provided by engineer. */ "An error occurred while unpinning the message" = "An error occurred while unpinning the message"; +/* No comment provided by engineer. */ +"An error occurred, please try again" = "An error occurred, please try again"; + /* No comment provided by engineer. */ "an hour" = "an hour"; @@ -827,6 +830,9 @@ "Could not start group conversation" = "Could not start group conversation"; /* No comment provided by engineer. */ +"Could not update tags" = "Could not update tags"; + +/* Create a new tag */ "Create" = "Create"; /* No comment provided by engineer. */ @@ -880,6 +886,9 @@ /* Delete a conversation right now without waiting for auto-deletion */ "Delete now" = "Delete now"; +/* No comment provided by engineer. */ +"Delete tag?" = "Delete tag?"; + /* No comment provided by engineer. */ "Deleted user" = "Deleted user"; @@ -1456,6 +1465,9 @@ /* No comment provided by engineer. */ "Lower hand" = "Lower hand"; +/* No comment provided by engineer. */ +"Manage tags" = "Manage tags"; + /* TRANSLATORS this is used when no meeting start time is set and the meeting will be started manually */ "Manual" = "Manual"; @@ -1625,6 +1637,9 @@ /* No comment provided by engineer. */ "New poll" = "New poll"; +/* Placeholder for the name of a new tag */ +"New tag" = "New tag"; + /* Remind me next week about that message */ "Next week" = "Next week"; @@ -2003,6 +2018,9 @@ /* No comment provided by engineer. */ "Remove team and members" = "Remove team and members"; +/* No comment provided by engineer. */ +"Rename tag" = "Rename tag"; + /* Replacement in case of out of office */ "Replacement" = "Replacement"; @@ -2291,6 +2309,9 @@ /* No comment provided by engineer. */ "Submit vote" = "Submit vote"; +/* No comment provided by engineer. */ +"Swipe a tag to rename or delete it. Use edit to reorder the tags." = "Swipe a tag to rename or delete it. Use edit to reorder the tags."; + /* No comment provided by engineer. */ "Switch account" = "Switch account"; @@ -2300,6 +2321,12 @@ /* No comment provided by engineer. */ "Synchronize to trusted servers and the global and public address book" = "Synchronize to trusted servers and the global and public address book"; +/* No comment provided by engineer. */ +"Tag name" = "Tag name"; + +/* 'Tags' meaning 'Conversation tags' */ +"Tags" = "Tags"; + /* Talk to a user */ "Talk to" = "Talk to"; @@ -2369,6 +2396,15 @@ /* No comment provided by engineer. */ "This summary is AI generated and may contain mistakes." = "This summary is AI generated and may contain mistakes."; +/* No comment provided by engineer. */ +"This tag cannot be changed" = "This tag cannot be changed"; + +/* No comment provided by engineer. */ +"This tag name is invalid or already in use" = "This tag name is invalid or already in use"; + +/* No comment provided by engineer. */ +"This tag will be removed from all conversations it is assigned to." = "This tag will be removed from all conversations it is assigned to."; + /* No comment provided by engineer. */ "This usually indicates that this device was previously used for an account, which was not properly removed from the server." = "This usually indicates that this device was previously used for an account, which was not properly removed from the server."; @@ -2642,6 +2678,9 @@ /* No comment provided by engineer. */ "You have no unread messages." = "You have no unread messages."; +/* No comment provided by engineer. */ +"You have reached the maximum number of tags" = "You have reached the maximum number of tags"; + /* No comment provided by engineer. */ "You joined the call" = "You joined the call"; From a7dab0d42a64dafc75c97a341e2da383539d19d3 Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Mon, 20 Jul 2026 12:44:36 +0200 Subject: [PATCH 08/13] fix(tags): Avoid tags flickering on app launch Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Ivan Sein --- .../Rooms/RoomsTableViewController.swift | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/NextcloudTalk/Rooms/RoomsTableViewController.swift b/NextcloudTalk/Rooms/RoomsTableViewController.swift index c4c1507d2..f7a87af46 100644 --- a/NextcloudTalk/Rooms/RoomsTableViewController.swift +++ b/NextcloudTalk/Rooms/RoomsTableViewController.swift @@ -389,13 +389,7 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI guard activeAccount.accountId == accountId else { return } - conversationTags = notification.userInfo?["tags"] as? [NCConversationTag] ?? [] - - // Clear the tag filter in case the filtered tag was deleted - if let activeTagFilterId, !conversationTags.contains(where: { $0.tagId == activeTagFilterId }) { - self.activeTagFilterId = nil - } - + // The updated tags are already stored in the database, so refreshing the list picks them up refreshRoomList() } @@ -443,7 +437,6 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI DispatchQueue.main.async { self.activeFilter = .all self.activeTagFilterId = nil - self.conversationTags = [] self.refreshRoomList() // Setup the navigation bar here, otherwise it would only be updated @@ -825,15 +818,9 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI let account = NCDatabaseManager.sharedInstance().activeAccount() guard NCDatabaseManager.sharedInstance().serverHasTalkCapability(.conversationTags, forAccountId: account.accountId) else { - conversationTags = [] - updateTagsFilterHeader() return } - // Show cached tags immediately, then revalidate from the server - conversationTags = NCDatabaseManager.sharedInstance().conversationTags(forAccountId: account.accountId) - updateTagsFilterHeader() - // Revalidate from the server. The result is delivered through the NCConversationTagsUpdated notification NCAPIController.sharedInstance().getConversationTags(forAccount: account) } @@ -1153,7 +1140,14 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI allRooms = accountRooms rooms = accountRooms - // Update tags filter header (chips + unread counters) + // Conversation tags + conversationTags = NCDatabaseManager.sharedInstance().conversationTags(forAccountId: account.accountId) + + // Clear the tag filter in case the filtered tag no longer exists + if let activeTagFilterId, !conversationTags.contains(where: { $0.tagId == activeTagFilterId }) { + self.activeTagFilterId = nil + } + updateTagsFilterHeader() // Filter rooms From cfd7c562c4b7682dc93b55253e81a22ad294fd02 Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Mon, 20 Jul 2026 12:53:43 +0200 Subject: [PATCH 09/13] fix(l10n): Use a generic comment for the shared "Create" string Signed-off-by: Ivan Sein --- NextcloudTalk/Rooms/RoomTagsAssignmentView.swift | 2 +- NextcloudTalk/Rooms/RoomTagsManagementView.swift | 2 +- NextcloudTalk/en.lproj/Localizable.strings | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/NextcloudTalk/Rooms/RoomTagsAssignmentView.swift b/NextcloudTalk/Rooms/RoomTagsAssignmentView.swift index 01e276ef2..92fd7cdd9 100644 --- a/NextcloudTalk/Rooms/RoomTagsAssignmentView.swift +++ b/NextcloudTalk/Rooms/RoomTagsAssignmentView.swift @@ -39,7 +39,7 @@ struct RoomTagsAssignmentView: View { // Create a new tag and directly assign it to this conversation HStack { TextField(NSLocalizedString("New tag", comment: "Placeholder for the name of a new tag"), text: $newTagName) - Button(NSLocalizedString("Create", comment: "Create a new tag")) { + Button(NSLocalizedString("Create", comment: "Generic 'Create' button label (e.g. new conversation, new tag)")) { createAndAssignTag() } .disabled(newTagName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) diff --git a/NextcloudTalk/Rooms/RoomTagsManagementView.swift b/NextcloudTalk/Rooms/RoomTagsManagementView.swift index db1087a10..081e0353e 100644 --- a/NextcloudTalk/Rooms/RoomTagsManagementView.swift +++ b/NextcloudTalk/Rooms/RoomTagsManagementView.swift @@ -78,7 +78,7 @@ struct RoomTagsManagementView: View { Section { HStack { TextField(NSLocalizedString("New tag", comment: "Placeholder for the name of a new tag"), text: $newTagName) - Button(NSLocalizedString("Create", comment: "Create a new tag")) { + Button(NSLocalizedString("Create", comment: "Generic 'Create' button label (e.g. new conversation, new tag)")) { createTag() } .disabled(newTagName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) diff --git a/NextcloudTalk/en.lproj/Localizable.strings b/NextcloudTalk/en.lproj/Localizable.strings index 6286a0016..fbe92330f 100644 --- a/NextcloudTalk/en.lproj/Localizable.strings +++ b/NextcloudTalk/en.lproj/Localizable.strings @@ -832,7 +832,7 @@ /* No comment provided by engineer. */ "Could not update tags" = "Could not update tags"; -/* Create a new tag */ +/* Generic 'Create' button label (e.g. new conversation, new tag) */ "Create" = "Create"; /* No comment provided by engineer. */ From a67fa427732921efef25f7413602a5c423803e5a Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Mon, 20 Jul 2026 15:47:49 +0200 Subject: [PATCH 10/13] chore: Move mention highlighting logic to NCRoom Signed-off-by: Ivan Sein --- NextcloudTalk/Rooms/NCRoom.swift | 16 ++++++++++++++ .../Rooms/RoomSearchTableViewController.swift | 9 +------- .../Rooms/RoomsTableViewController.swift | 22 +++---------------- 3 files changed, 20 insertions(+), 27 deletions(-) diff --git a/NextcloudTalk/Rooms/NCRoom.swift b/NextcloudTalk/Rooms/NCRoom.swift index 302c0179f..a441577c7 100644 --- a/NextcloudTalk/Rooms/NCRoom.swift +++ b/NextcloudTalk/Rooms/NCRoom.swift @@ -269,6 +269,22 @@ extension Array where Element == NCRoom { return self.unreadMention || self.unreadMentionDirect } + public var shouldBeHighlightedAsDirectMention: Bool { + if NCDatabaseManager.sharedInstance().serverHasTalkCapability(.directMentionFlag, forAccountId: self.accountId) { + return self.unreadMentionDirect || self.type == .oneToOne || self.type == .formerOneToOne + } + + return self.unreadMention || self.type == .oneToOne || self.type == .formerOneToOne + } + + public var shouldBeHighlightedAsGroupMention: Bool { + guard NCDatabaseManager.sharedInstance().serverHasTalkCapability(.directMentionFlag, forAccountId: self.accountId) else { + return false + } + + return self.unreadMention && !self.unreadMentionDirect + } + public var callRecordingIsInActiveState: Bool { if NCDatabaseManager.sharedInstance().serverHasTalkCapability(.recordingV1) { // Starting states and running states are considered active diff --git a/NextcloudTalk/Rooms/RoomSearchTableViewController.swift b/NextcloudTalk/Rooms/RoomSearchTableViewController.swift index 5182d9feb..067aeeaf0 100644 --- a/NextcloudTalk/Rooms/RoomSearchTableViewController.swift +++ b/NextcloudTalk/Rooms/RoomSearchTableViewController.swift @@ -265,14 +265,7 @@ class RoomSearchTableViewController: UITableViewController { } // Set unread messages - if NCDatabaseManager.sharedInstance().serverHasTalkCapability(.directMentionFlag) { - let mentioned = room.unreadMentionDirect || room.type == .oneToOne || room.type == .formerOneToOne - let groupMentioned = room.unreadMention && !room.unreadMentionDirect - cell.setUnread(messages: room.unreadMessages, mentioned: mentioned, groupMentioned: groupMentioned) - } else { - let mentioned = room.unreadMention || room.type == .oneToOne || room.type == .formerOneToOne - cell.setUnread(messages: room.unreadMessages, mentioned: mentioned, groupMentioned: false) - } + cell.setUnread(messages: room.unreadMessages, mentioned: room.shouldBeHighlightedAsDirectMention, groupMentioned: room.shouldBeHighlightedAsGroupMention) if room.unreadMessages > 0 { // When there are unread messages, we need to show the subtitle at the moment diff --git a/NextcloudTalk/Rooms/RoomsTableViewController.swift b/NextcloudTalk/Rooms/RoomsTableViewController.swift index f7a87af46..ec537bb4a 100644 --- a/NextcloudTalk/Rooms/RoomsTableViewController.swift +++ b/NextcloudTalk/Rooms/RoomsTableViewController.swift @@ -911,8 +911,8 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI chips.append(TagFilterChip(id: tag.tagId, title: title, unreadCount: unreadRooms.count, - mentioned: unreadRooms.contains { self.isRoomMentioned($0) }, - groupMentioned: unreadRooms.contains { self.isRoomGroupMentioned($0) })) + mentioned: unreadRooms.contains { $0.shouldBeHighlightedAsDirectMention }, + groupMentioned: unreadRooms.contains { $0.shouldBeHighlightedAsGroupMention })) } let account = NCDatabaseManager.sharedInstance().activeAccount() @@ -1593,22 +1593,6 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI return (allRooms as NSArray).filtered(using: NSPredicate(format: "isArchived == YES")) as? [NCRoom] ?? [] } - private func isRoomMentioned(_ room: NCRoom) -> Bool { - if NCDatabaseManager.sharedInstance().serverHasTalkCapability(.directMentionFlag) { - return room.unreadMentionDirect || room.type == .oneToOne || room.type == .formerOneToOne - } - - return room.unreadMention || room.type == .oneToOne || room.type == .formerOneToOne - } - - private func isRoomGroupMentioned(_ room: NCRoom) -> Bool { - if NCDatabaseManager.sharedInstance().serverHasTalkCapability(.directMentionFlag) { - return room.unreadMention && !room.unreadMentionDirect - } - - return false - } - private func areArchivedRoomsWithUnreadMentions() -> Bool { return !(allRooms as NSArray).filtered(using: NSPredicate(format: "hasUnreadMention == YES AND isArchived == YES")).isEmpty } @@ -1831,7 +1815,7 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI } // Set unread messages - cell.setUnread(messages: room.unreadMessages, mentioned: isRoomMentioned(room), groupMentioned: isRoomGroupMentioned(room)) + cell.setUnread(messages: room.unreadMessages, mentioned: room.shouldBeHighlightedAsDirectMention, groupMentioned: room.shouldBeHighlightedAsGroupMention) if room.unreadMessages > 0 { // When there are unread messages, we need to show the subtitle at the moment From 836831c678f0e5d4f01f2958f22a1876401692c5 Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Mon, 20 Jul 2026 17:52:32 +0200 Subject: [PATCH 11/13] chore: Use checkboxes for multi selection Signed-off-by: Ivan Sein --- NextcloudTalk/Rooms/RoomTagsAssignmentView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/NextcloudTalk/Rooms/RoomTagsAssignmentView.swift b/NextcloudTalk/Rooms/RoomTagsAssignmentView.swift index 92fd7cdd9..211c8a5ef 100644 --- a/NextcloudTalk/Rooms/RoomTagsAssignmentView.swift +++ b/NextcloudTalk/Rooms/RoomTagsAssignmentView.swift @@ -21,6 +21,8 @@ struct RoomTagsAssignmentView: View { List { Section { ForEach(customTags, id: \.tagId) { tag in + let isAssigned = assignedTagIds.contains(tag.tagId) + Button(action: { toggle(tag) }, label: { @@ -28,10 +30,8 @@ struct RoomTagsAssignmentView: View { Text(tag.name) .foregroundColor(.primary) Spacer() - if assignedTagIds.contains(tag.tagId) { - Image(systemName: "checkmark") - .foregroundColor(Color(NCAppBranding.elementColor())) - } + Image(systemName: isAssigned ? "checkmark.circle.fill" : "circle") + .foregroundColor(isAssigned ? Color(NCAppBranding.elementColor()) : Color(.tertiaryLabel)) } }) } From 0bbe8a0411965842d706b12d02f3e4bbab5f318a Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Mon, 20 Jul 2026 17:53:13 +0200 Subject: [PATCH 12/13] chore: Only show "New tag" option on edit mode Signed-off-by: Ivan Sein --- .../Rooms/RoomTagsManagementView.swift | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/NextcloudTalk/Rooms/RoomTagsManagementView.swift b/NextcloudTalk/Rooms/RoomTagsManagementView.swift index 081e0353e..cd137a90c 100644 --- a/NextcloudTalk/Rooms/RoomTagsManagementView.swift +++ b/NextcloudTalk/Rooms/RoomTagsManagementView.swift @@ -24,6 +24,8 @@ struct RoomTagsManagementView: View { let account: TalkAccount let hostingWrapper: HostingControllerWrapper + @Environment(\.editMode) private var editMode + @State private var tags: [NCConversationTag] = [] @State private var newTagName = "" @State private var errorMessage: String? @@ -69,21 +71,22 @@ struct RoomTagsManagementView: View { } } .onMove(perform: move) + + // Shown as the last row of the tags section while editing + if editMode?.wrappedValue.isEditing == true { + HStack { + TextField(NSLocalizedString("New tag", comment: "Placeholder for the name of a new tag"), text: $newTagName) + Button(NSLocalizedString("Create", comment: "Generic 'Create' button label (e.g. new conversation, new tag)")) { + createTag() + } + .disabled(newTagName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } } footer: { if !tags.isEmpty { Text("Swipe a tag to rename or delete it. Use edit to reorder the tags.") } } - - Section { - HStack { - TextField(NSLocalizedString("New tag", comment: "Placeholder for the name of a new tag"), text: $newTagName) - Button(NSLocalizedString("Create", comment: "Generic 'Create' button label (e.g. new conversation, new tag)")) { - createTag() - } - .disabled(newTagName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } } .navigationBarTitle(Text(NSLocalizedString("Manage tags", comment: "")), displayMode: .inline) .navigationBarHidden(false) From d3e41ae47c84fc5b4af84f5c61c24e033bb57c20 Mon Sep 17 00:00:00 2001 From: Ivan Sein Date: Mon, 20 Jul 2026 18:01:35 +0200 Subject: [PATCH 13/13] chore: Give extra padding to tags filter view Signed-off-by: Ivan Sein --- NextcloudTalk/Rooms/RoomTagsFilterView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NextcloudTalk/Rooms/RoomTagsFilterView.swift b/NextcloudTalk/Rooms/RoomTagsFilterView.swift index dbba03d56..e3fa7fda3 100644 --- a/NextcloudTalk/Rooms/RoomTagsFilterView.swift +++ b/NextcloudTalk/Rooms/RoomTagsFilterView.swift @@ -32,7 +32,7 @@ struct TagFilterChip { class RoomTagsFilterView: UIView { static let chipVerticalPadding: CGFloat = PillShapeMetrics.verticalPadding - static let rowVerticalPadding: CGFloat = 8 + static let rowVerticalPadding: CGFloat = 16 // Adapts to the current dynamic type size of the chip title font static var viewHeight: CGFloat {