Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion NextcloudTalk/Chat/NCChatController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
3 changes: 2 additions & 1 deletion NextcloudTalk/Contacts/NCContactsManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions NextcloudTalk/Database/NCConversationTag.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/

#import <Foundation/Foundation.h>
#import <Realm/Realm.h>

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
45 changes: 45 additions & 0 deletions NextcloudTalk/Database/NCConversationTag.m
Original file line number Diff line number Diff line change
@@ -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
37 changes: 35 additions & 2 deletions NextcloudTalk/Database/NCDatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion NextcloudTalk/Database/NCThread.m
Original file line number Diff line number Diff line change
Expand Up @@ -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]];
}
}
}];
Expand Down
131 changes: 131 additions & 0 deletions NextcloudTalk/Network/NCAPIController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions NextcloudTalk/NextcloudTalk-Bridging-Header-Extensions.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions NextcloudTalk/NextcloudTalk-Bridging-Header.h
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
#import "NCContact.h"
#import "ABContact.h"
#import "NCThread.h"
#import "NCConversationTag.h"

#import "JDStatusBarNotificationPresenter.h"
#import "UIResponder+SLKAdditions.h"
Expand Down
1 change: 1 addition & 0 deletions NextcloudTalk/NotificationCenterNotifications.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions NextcloudTalk/NotificationCenterNotifications.m
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions NextcloudTalk/Rooms/NCRoom.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<RLMString> *tagIds;

+ (instancetype _Nullable)roomWithDictionary:(NSDictionary * _Nullable)roomDict andAccountId:(NSString * _Nullable)accountId;

Expand Down
10 changes: 10 additions & 0 deletions NextcloudTalk/Rooms/NCRoom.m
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down
9 changes: 9 additions & 0 deletions NextcloudTalk/Rooms/NCRoom.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}

}
Loading
Loading