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/NCConversationTag.h b/NextcloudTalk/Database/NCConversationTag.h new file mode 100644 index 000000000..b08df5845 --- /dev/null +++ b/NextcloudTalk/Database/NCConversationTag.h @@ -0,0 +1,29 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +extern NSString * const NCConversationTagTypeCustom; +extern NSString * const NCConversationTagTypeFavorites; +extern NSString * const NCConversationTagTypeOther; + +@interface NCConversationTag : RLMObject + +@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; +@property (nonatomic, assign) BOOL collapsed; +@property (nonatomic, copy) NSString *type; + ++ (instancetype _Nullable)conversationTagWithDictionary:(NSDictionary * _Nullable)tagDict andAccountId:(NSString * _Nullable)accountId; + +@end + +NS_ASSUME_NONNULL_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..c0eaa2b27 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,36 @@ 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 { + // Add a copy, so the passed objects stay unmanaged for the caller + realm.add(NCConversationTag(value: tag)) + } + } + } } // MARK: - Type-safe capability checks 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/Network/NCAPIController.swift b/NextcloudTalk/Network/NCAPIController.swift index f217c7674..c691f28fd 100644 --- a/NextcloudTalk/Network/NCAPIController.swift +++ b/NextcloudTalk/Network/NCAPIController.swift @@ -1310,6 +1310,137 @@ 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 + } + + 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 accountId = 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 + 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/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/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..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 @@ -424,6 +440,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 +504,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) } } 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) } } 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/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/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/RoomTagsAssignmentView.swift b/NextcloudTalk/Rooms/RoomTagsAssignmentView.swift new file mode 100644 index 000000000..211c8a5ef --- /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 + let isAssigned = assignedTagIds.contains(tag.tagId) + + Button(action: { + toggle(tag) + }, label: { + HStack { + Text(tag.name) + .foregroundColor(.primary) + Spacer() + Image(systemName: isAssigned ? "checkmark.circle.fill" : "circle") + .foregroundColor(isAssigned ? Color(NCAppBranding.elementColor()) : Color(.tertiaryLabel)) + } + }) + } + + // 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: "Generic 'Create' button label (e.g. new conversation, 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 new file mode 100644 index 000000000..e3fa7fda3 --- /dev/null +++ b/NextcloudTalk/Rooms/RoomTagsFilterView.swift @@ -0,0 +1,235 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +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 mentioned: Bool + let groupMentioned: Bool + let showsUnreadDot: Bool + let icon: UIImage? + + 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.mentioned = mentioned + self.groupMentioned = groupMentioned + self.showsUnreadDot = showsUnreadDot + self.icon = icon + } +} + +class RoomTagsFilterView: UIView { + + static let chipVerticalPadding: CGFloat = PillShapeMetrics.verticalPadding + static let rowVerticalPadding: CGFloat = 16 + + // 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)? + public var onChipLongPressed: (() -> Void)? + + private let scrollView = UIScrollView() + private let stackView = UIStackView() + private let feedbackGenerator = UISelectionFeedbackGenerator() + private var chips: [TagFilterChip] = [] + private var selectedChipId: String = TagFilterChip.allChipId + + 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: Self.rowVerticalPadding), + stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -Self.rowVerticalPadding), + stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor, constant: -(Self.rowVerticalPadding * 2)) + ]) + } + + public func update(chips: [TagFilterChip], selectedChipId: String) { + self.chips = chips + self.selectedChipId = selectedChipId + + stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + + for chip in chips { + 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() + } + + @objc private func chipTapped(_ sender: RoomTagChipControl) { + guard let chip = sender.chip else { return } + + feedbackGenerator.selectionChanged() + onChipSelected?(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 + // Same background as InfoLabelTableViewCell (e.g. pending invitations row) + self.backgroundColor = selected ? NCAppBranding.themeColor() : .secondarySystemBackground + + // 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 + + contentStackView.axis = .horizontal + contentStackView.spacing = 8 + contentStackView.alignment = .center + contentStackView.isUserInteractionEnabled = false + contentStackView.translatesAutoresizingMaskIntoConstraints = false + + if let icon = chip.icon { + let iconView = UIImageView(image: icon) + iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .headline, scale: .small) + iconView.tintColor = selected ? NCAppBranding.themeTextColor() : .label + contentStackView.addArrangedSubview(iconView) + contentStackView.setCustomSpacing(6, after: iconView) + } + + 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() + } else { + badgeView.badgeColor = NCAppBranding.themeColor() + badgeView.badgeTextColor = NCAppBranding.themeTextColor() + } + + if chip.mentioned { + badgeView.badgeHighlightStyle = .important + } else if chip.groupMentioned { + badgeView.badgeHighlightStyle = .border + } else { + badgeView.badgeHighlightStyle = selected ? .important : .none + } + + 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) + + // 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 ? PillShapeMetrics.horizontalPadding - 6 : PillShapeMetrics.horizontalPadding + + NSLayoutConstraint.activate([ + 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) + ]) + + 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) + } else if chip.showsUnreadDot { + self.accessibilityValue = NSLocalizedString("Unread mentions", comment: "") + } + } + + 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/RoomTagsManagementView.swift b/NextcloudTalk/Rooms/RoomTagsManagementView.swift new file mode 100644 index 000000000..cd137a90c --- /dev/null +++ b/NextcloudTalk/Rooms/RoomTagsManagementView.swift @@ -0,0 +1,241 @@ +// +// 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 + + @Environment(\.editMode) private var editMode + + @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) + + // 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.") + } + } + } + .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 31222918e..ec537bb4a 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 { @@ -20,8 +21,6 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI private enum RoomsSection: Int, CaseIterable { case pendingFederationInvitation = 0 - case threads - case archivedConversations case roomList } @@ -52,6 +51,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 +154,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() { @@ -318,6 +321,10 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI setProfileButton() setupNavigationBar() } + + if self.traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory { + updateTagsFilterHeader() + } } // MARK: - Notifications @@ -376,6 +383,16 @@ 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 } + + // The updated tags are already stored in the database, so refreshing the list picks them up + refreshRoomList() + } + @objc private func notificationWillBePresented(_ notification: Notification) { NCRoomsManager.shared.updateRoomsAndChats(updatingUserStatus: false, onlyLastModified: false, withCompletionBlock: nil) setUnreadMessageForInactiveAccountsIndicator() @@ -392,6 +409,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 +436,7 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI @objc private func activeAccountDidChange(_ notification: Notification) { DispatchQueue.main.async { self.activeFilter = .all + self.activeTagFilterId = nil self.refreshRoomList() // Setup the navigation bar here, otherwise it would only be updated @@ -476,6 +495,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 +775,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 +812,151 @@ 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 { + return + } + + // Revalidate from the server. The result is delivered through the NCConversationTagsUpdated notification + NCAPIController.sharedInstance().getConversationTags(forAccount: account) + } + + private func updateTagsFilterHeader() { + let chips = buildTagFilterChips() + + // 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.onChipSelected = { [weak self] chipId in + self?.handleChipSelection(chipId) + } + filterView.onChipLongPressed = { [weak self] in + guard NCDatabaseManager.sharedInstance().serverHasTalkCapability(.conversationTags) else { return } + + self?.presentTagsManagement() + } + tagsFilterView = filterView + } + + guard let tagsFilterView else { return } + + tagsFilterView.update(chips: chips, selectedChipId: selectedChipId()) + + 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 + } + } + + 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)] + + 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, + mentioned: unreadRooms.contains { $0.shouldBeHighlightedAsDirectMention }, + groupMentioned: unreadRooms.contains { $0.shouldBeHighlightedAsGroupMention })) + } + + 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 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'") + + // 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 } + + return conversationTags.first { $0.tagId == activeTagFilterId } + } + // MARK: - Rooms filter private func availableFilters() -> [RoomsFilter] { @@ -965,6 +1140,16 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI allRooms = accountRooms rooms = accountRooms + // 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 filterRooms() @@ -985,7 +1170,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 +1194,7 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI NCRoomsManager.shared.updateRooms(updatingUserStatus: isAppActive, onlyLastModified: false) updateUserStatus() getUserThreads() + refreshConversationTags() startRefreshRoomsTimer() setupNavigationBar() default: @@ -1483,24 +1676,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 } @@ -1531,11 +1712,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 } @@ -1609,59 +1787,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 @@ -1690,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: room.shouldBeHighlightedAsDirectMention, groupMentioned: room.shouldBeHighlightedAsGroupMention) if room.unreadMessages > 0 { // When there are unread messages, we need to show the subtitle at the moment @@ -1732,9 +1850,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 } @@ -1768,24 +1884,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) { @@ -1808,9 +1906,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 } @@ -1832,6 +1928,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/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 +} diff --git a/NextcloudTalk/en.lproj/Localizable.strings b/NextcloudTalk/en.lproj/Localizable.strings index 7d9cc6cce..fbe92330f 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"; @@ -409,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"; @@ -433,6 +442,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"; @@ -818,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"; + +/* Generic 'Create' button label (e.g. new conversation, new tag) */ "Create" = "Create"; /* No comment provided by engineer. */ @@ -871,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"; @@ -1159,6 +1177,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"; @@ -1444,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"; @@ -1613,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"; @@ -1991,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"; @@ -2279,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"; @@ -2288,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"; @@ -2357,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."; @@ -2618,6 +2666,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."; @@ -2627,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";