diff --git a/NextcloudTalk/NextcloudTalk-Bridging-Header.h b/NextcloudTalk/NextcloudTalk-Bridging-Header.h index 010490101..75548794e 100644 --- a/NextcloudTalk/NextcloudTalk-Bridging-Header.h +++ b/NextcloudTalk/NextcloudTalk-Bridging-Header.h @@ -26,7 +26,7 @@ #import "NotificationCenterNotifications.h" #import "MapViewController.h" #import "PlaceholderView.h" -#import "RoomsTableViewController.h" +#import "UIBarButtonItem+LegacyBadge.h" #import "ShareTableViewCell.h" #import "TOCroppedImageAttributes.h" #import "TOCropViewController.h" diff --git a/NextcloudTalk/Rooms/RoomSearchTableViewController.h b/NextcloudTalk/Rooms/RoomSearchTableViewController.h deleted file mode 100644 index 48fe4a93d..000000000 --- a/NextcloudTalk/Rooms/RoomSearchTableViewController.h +++ /dev/null @@ -1,30 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class NCRoom; -@class NCUser; -@class NKSearchEntry; - -@interface RoomSearchTableViewController : UITableViewController - -@property (nonatomic, strong) NSArray *rooms; -@property (nonatomic, strong) NSArray *users; -@property (nonatomic, strong) NSArray *listableRooms; -@property (nonatomic, strong) NSArray *messages; -@property (nonatomic, assign) BOOL searchingMessages; - -- (NCRoom *)roomForIndexPath:(NSIndexPath *)indexPath; -- (NCUser *)userForIndexPath:(NSIndexPath *)indexPath; -- (NKSearchEntry *)messageForIndexPath:(NSIndexPath *)indexPath; -- (void)showSearchingFooterView; -- (void)clearSearchedResults; - -@end - -NS_ASSUME_NONNULL_END diff --git a/NextcloudTalk/Rooms/RoomSearchTableViewController.m b/NextcloudTalk/Rooms/RoomSearchTableViewController.m deleted file mode 100644 index 2ab391cb8..000000000 --- a/NextcloudTalk/Rooms/RoomSearchTableViewController.m +++ /dev/null @@ -1,355 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -#import "RoomSearchTableViewController.h" - -@import NextcloudKit; - -#import "NCAppBranding.h" -#import "NCDatabaseManager.h" -#import "NCRoom.h" -#import "NCSettingsController.h" -#import "PlaceholderView.h" - -#import "NextcloudTalk-Swift.h" - -typedef enum RoomSearchSection { - RoomSearchSectionFiltered = 0, - RoomSearchSectionUsers, - RoomSearchSectionListable, - RoomSearchSectionMessages -} RoomSearchSection; - -@interface RoomSearchTableViewController () -{ - PlaceholderView *_roomSearchBackgroundView; -} -@end - -@implementation RoomSearchTableViewController - -- (void)viewDidLoad -{ - [super viewDidLoad]; - [self.tableView registerNib:[UINib nibWithNibName:RoomTableViewCell.nibName bundle:nil] forCellReuseIdentifier:RoomTableViewCell.identifier]; - self.tableView.rowHeight = UITableViewAutomaticDimension; - self.tableView.estimatedRowHeight = UITableViewAutomaticDimension; - self.tableView.tableFooterView = [[UIView alloc] initWithFrame:CGRectZero]; - // Align header's title to ContactsTableViewCell's label - self.tableView.separatorInset = UIEdgeInsetsMake(0, 52, 0, 0); - self.tableView.separatorInsetReference = UITableViewSeparatorInsetFromAutomaticInsets; - // Contacts placeholder view - _roomSearchBackgroundView = [[PlaceholderView alloc] initForTableViewStyle:UITableViewStyleInsetGrouped]; - [_roomSearchBackgroundView setImage:[UIImage imageNamed:@"conversations-placeholder"]]; - [_roomSearchBackgroundView.placeholderTextView setText:NSLocalizedString(@"No results found", nil)]; - [_roomSearchBackgroundView.placeholderView setHidden:YES]; - [_roomSearchBackgroundView.loadingView startAnimating]; - self.tableView.backgroundView = _roomSearchBackgroundView; -} - -- (void)didReceiveMemoryWarning -{ - [super didReceiveMemoryWarning]; -} - -- (void)setRooms:(NSArray *)rooms -{ - _rooms = rooms; - [self reloadAndCheckSearchingIndicator]; -} - -- (void)setUsers:(NSArray *)users -{ - _users = users; - [self reloadAndCheckSearchingIndicator]; -} - -- (void)setListableRooms:(NSArray *)listableRooms -{ - _listableRooms = listableRooms; - [self reloadAndCheckSearchingIndicator]; -} - -- (void)setMessages:(NSArray *)messages -{ - _messages = messages; - [self reloadAndCheckSearchingIndicator]; -} - -- (void)setSearchingMessages:(BOOL)searchingMessages -{ - _searchingMessages = searchingMessages; - [self reloadAndCheckSearchingIndicator]; -} - - -#pragma mark - User Interface - -- (void)reloadAndCheckSearchingIndicator -{ - [self.tableView reloadData]; - - if (_searchingMessages) { - if ([self searchSections].count > 0) { - [_roomSearchBackgroundView.loadingView stopAnimating]; - [_roomSearchBackgroundView.loadingView setHidden:YES]; - [self showSearchingFooterView]; - } else { - [_roomSearchBackgroundView.loadingView startAnimating]; - [_roomSearchBackgroundView.loadingView setHidden:NO]; - [self hideSearchingFooterView]; - } - [_roomSearchBackgroundView.placeholderView setHidden:YES]; - } else { - [_roomSearchBackgroundView.loadingView stopAnimating]; - [_roomSearchBackgroundView.loadingView setHidden:YES]; - [_roomSearchBackgroundView.placeholderView setHidden:[self searchSections].count > 0]; - } -} - -- (void)showSearchingFooterView -{ - UIActivityIndicatorView *loadingMoreView = [[UIActivityIndicatorView alloc] initWithFrame:CGRectMake(0, 0, 44, 44)]; - loadingMoreView.color = [UIColor darkGrayColor]; - [loadingMoreView startAnimating]; - self.tableView.tableFooterView = loadingMoreView; -} - -- (void)hideSearchingFooterView -{ - self.tableView.tableFooterView = nil; -} - -- (void)clearSearchedResults -{ - _rooms = @[]; - _users = @[]; - _listableRooms = @[]; - _messages = @[]; - - [self reloadAndCheckSearchingIndicator]; -} - - -#pragma mark - Utils - -- (NSArray *)searchSections -{ - NSMutableArray *sections = [NSMutableArray new]; - if (_rooms.count > 0) { - [sections addObject:@(RoomSearchSectionFiltered)]; - } - if (_users.count > 0) { - [sections addObject:@(RoomSearchSectionUsers)]; - } - if (_listableRooms.count > 0) { - [sections addObject:@(RoomSearchSectionListable)]; - } - if (_messages.count > 0) { - [sections addObject:@(RoomSearchSectionMessages)]; - } - return [NSArray arrayWithArray:sections]; -} - -- (NCRoom *)roomForIndexPath:(NSIndexPath *)indexPath -{ - NSInteger searchSection = [[[self searchSections] objectAtIndex:indexPath.section] integerValue]; - if (searchSection == RoomSearchSectionFiltered && indexPath.row < _rooms.count) { - return [_rooms objectAtIndex:indexPath.row]; - } else if (searchSection == RoomSearchSectionListable && indexPath.row < _listableRooms.count) { - return [_listableRooms objectAtIndex:indexPath.row]; - } - - return nil; -} - -- (NKSearchEntry *)messageForIndexPath:(NSIndexPath *)indexPath -{ - NSInteger searchSection = [[[self searchSections] objectAtIndex:indexPath.section] integerValue]; - if (searchSection == RoomSearchSectionMessages && indexPath.row < _messages.count) { - return [_messages objectAtIndex:indexPath.row];; - } - - return nil; -} - -- (UITableViewCell *)tableView:(UITableView *)tableView cellForMessageAtIndexPath:(NSIndexPath *)indexPath -{ - NKSearchEntry *messageEntry = [_messages objectAtIndex:indexPath.row]; - RoomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:RoomTableViewCell.identifier]; - if (!cell) { - cell = [[RoomTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:RoomTableViewCell.identifier]; - } - - cell.titleLabel.text = messageEntry.title; - cell.subtitleLabel.text = messageEntry.subline; - - // Thumbnail image - NSURL *thumbnailURL = [[NSURL alloc] initWithString:messageEntry.thumbnailURL]; - NSString *actorId = [messageEntry.attributes objectForKey:@"actorId"]; - NSString *actorType = [messageEntry.attributes objectForKey:@"actorType"]; - if (thumbnailURL && thumbnailURL.absoluteString.length > 0) { - [cell.avatarView.avatarImageView sd_setImageWithURL:thumbnailURL placeholderImage:nil options:SDWebImageRetryFailed | SDWebImageRefreshCached]; - cell.avatarView.avatarImageView.contentMode = UIViewContentModeScaleToFill; - } else { - TalkAccount *activeAccount = [[NCDatabaseManager sharedInstance] activeAccount]; - [cell.avatarView setActorAvatarForId:actorId withType:actorType withDisplayName:@"" withRoomToken:nil using:activeAccount]; - } - - // Clear possible content not removed by cell reuse - cell.dateLabel.text = @""; - [cell setUnreadWithMessages:0 mentioned:NO groupMentioned:NO]; - - // Add message date (if it is included in attributes) - NSInteger timestamp = [[messageEntry.attributes objectForKey:@"timestamp"] integerValue]; - if (timestamp > 0) { - NSDate *date = [[NSDate alloc] initWithTimeIntervalSince1970:timestamp]; - cell.dateLabel.text = [NCUtils readableTimeOrDateFromDate:date]; - } - - return cell; -} - -- (NCUser *)userForIndexPath:(NSIndexPath *)indexPath -{ - NSInteger searchSection = [[[self searchSections] objectAtIndex:indexPath.section] integerValue]; - if (searchSection == RoomSearchSectionUsers && indexPath.row < _users.count) { - return [_users objectAtIndex:indexPath.row]; - } - - return nil; -} - -- (UITableViewCell *)tableView:(UITableView *)tableView cellForUserAtIndexPath:(NSIndexPath *)indexPath -{ - NCUser *user = [_users objectAtIndex:indexPath.row]; - RoomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:RoomTableViewCell.identifier]; - if (!cell) { - cell = [[RoomTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:RoomTableViewCell.identifier]; - } - - // Clear possible content not removed by cell reuse - cell.dateLabel.text = @""; - [cell setUnreadWithMessages:0 mentioned:NO groupMentioned:NO]; - - cell.titleLabel.text = user.name; - cell.titleOnly = YES; - TalkAccount *activeAccount = [[NCDatabaseManager sharedInstance] activeAccount]; - [cell.avatarView setActorAvatarForId:user.userId withType:user.source withDisplayName:user.name withRoomToken:nil using:activeAccount]; - - return cell; -} - -#pragma mark - Table view data source - -- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView -{ - return [self searchSections].count; -} - -- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section -{ - NSInteger searchSection = [[[self searchSections] objectAtIndex:section] integerValue]; - switch (searchSection) { - case RoomSearchSectionFiltered: - return _rooms.count; - case RoomSearchSectionUsers: - return _users.count; - case RoomSearchSectionListable: - return _listableRooms.count; - case RoomSearchSectionMessages: - return _messages.count; - default: - return 0; - } -} - -- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section -{ - NSInteger searchSection = [[[self searchSections] objectAtIndex:section] integerValue]; - switch (searchSection) { - case RoomSearchSectionFiltered: - return NSLocalizedString(@"Conversations", @""); - case RoomSearchSectionUsers: - return NSLocalizedString(@"Users", @""); - case RoomSearchSectionListable: - return NSLocalizedString(@"Open conversations", @"TRANSLATORS 'Open conversations' as a type of conversation. 'Open conversations' are conversations that can be found by other users"); - case RoomSearchSectionMessages: - return NSLocalizedString(@"Messages", @""); - default: - return nil; - } -} - -- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath -{ - NSInteger searchSection = [[[self searchSections] objectAtIndex:indexPath.section] integerValue]; - // Messages - if (searchSection == RoomSearchSectionMessages) { - return [self tableView:tableView cellForMessageAtIndexPath:indexPath]; - } - // Contacts - if (searchSection == RoomSearchSectionUsers) { - return [self tableView:tableView cellForUserAtIndexPath:indexPath]; - } - - NCRoom *room = [self roomForIndexPath:indexPath]; - - RoomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:RoomTableViewCell.identifier]; - if (!cell) { - cell = [[RoomTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:RoomTableViewCell.identifier]; - } - - // Set room name - cell.titleLabel.text = room.displayName; - - // Set last activity - if (room.lastMessageId || room.lastMessageProxiedJSONString) { - cell.titleOnly = NO; - cell.subtitleLabel.attributedText = room.lastMessageString; - } else { - cell.titleOnly = YES; - cell.subtitleLabel.text = @""; - } - NSDate *date = [[NSDate alloc] initWithTimeIntervalSince1970:room.lastActivity]; - cell.dateLabel.text = [NCUtils readableTimeOrDateFromDate:date]; - - // Open conversations - if (searchSection == RoomSearchSectionListable) { - cell.titleOnly = NO; - cell.subtitleLabel.text = room.roomDescription; - cell.dateLabel.text = @""; - } - - // Set unread messages - if ([[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilityDirectMentionFlag]) { - BOOL mentioned = room.unreadMentionDirect || room.type == kNCRoomTypeOneToOne || room.type == kNCRoomTypeFormerOneToOne; - BOOL groupMentioned = room.unreadMention && !room.unreadMentionDirect; - [cell setUnreadWithMessages:room.unreadMessages mentioned:mentioned groupMentioned:groupMentioned]; - } else { - BOOL mentioned = room.unreadMention || room.type == kNCRoomTypeOneToOne || room.type == kNCRoomTypeFormerOneToOne; - [cell setUnreadWithMessages:room.unreadMessages mentioned:mentioned groupMentioned:NO]; - } - - if (room.unreadMessages > 0) { - // When there are unread messages, we need to show the subtitle at the moment - cell.titleOnly = NO; - } - - [cell.avatarView setAvatarFor:room]; - - // Set favorite or call image - if (room.hasCall) { - [cell.avatarView.favoriteImageView setTintColor:[UIColor systemRedColor]]; - [cell.avatarView.favoriteImageView setImage:[UIImage systemImageNamed:@"video.fill"]]; - } else if (room.isFavorite) { - [cell.avatarView.favoriteImageView setTintColor:[UIColor systemYellowColor]]; - [cell.avatarView.favoriteImageView setImage:[UIImage systemImageNamed:@"star.fill"]]; - } - - return cell; -} - -@end diff --git a/NextcloudTalk/Rooms/RoomSearchTableViewController.swift b/NextcloudTalk/Rooms/RoomSearchTableViewController.swift new file mode 100644 index 000000000..ca388b9ae --- /dev/null +++ b/NextcloudTalk/Rooms/RoomSearchTableViewController.swift @@ -0,0 +1,295 @@ +// +// SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit +import NextcloudKit +import SDWebImage + +class RoomSearchTableViewController: UITableViewController { + + private enum RoomSearchSection: Int { + case filtered = 0 + case users + case listable + case messages + } + + var rooms: [NCRoom] = [] { didSet { reloadAndCheckSearchingIndicator() } } + var users: [NCUser] = [] { didSet { reloadAndCheckSearchingIndicator() } } + var listableRooms: [NCRoom] = [] { didSet { reloadAndCheckSearchingIndicator() } } + var messages: [NKSearchEntry] = [] { didSet { reloadAndCheckSearchingIndicator() } } + var searchingMessages: Bool = false { didSet { reloadAndCheckSearchingIndicator() } } + + private var roomSearchBackgroundView: PlaceholderView = PlaceholderView(for: .insetGrouped) + private var suppressReload = false + + override func viewDidLoad() { + super.viewDidLoad() + + self.tableView.register(UINib(nibName: RoomTableViewCell.nibName, bundle: nil), forCellReuseIdentifier: RoomTableViewCell.identifier) + self.tableView.rowHeight = UITableView.automaticDimension + self.tableView.estimatedRowHeight = UITableView.automaticDimension + self.tableView.tableFooterView = UIView(frame: .zero) + + // Align header's title to ContactsTableViewCell's label + self.tableView.separatorInset = UIEdgeInsets(top: 0, left: 52, bottom: 0, right: 0) + self.tableView.separatorInsetReference = .fromAutomaticInsets + + // Contacts placeholder view + roomSearchBackgroundView.setImage(UIImage(named: "conversations-placeholder")) + roomSearchBackgroundView.placeholderTextView.text = NSLocalizedString("No results found", comment: "") + roomSearchBackgroundView.placeholderView.isHidden = true + roomSearchBackgroundView.loadingView.startAnimating() + self.tableView.backgroundView = roomSearchBackgroundView + } + + // MARK: - User Interface + + private func reloadAndCheckSearchingIndicator() { + guard !suppressReload else { return } + + self.tableView.reloadData() + + if searchingMessages { + if !searchSections().isEmpty { + roomSearchBackgroundView.loadingView.stopAnimating() + roomSearchBackgroundView.loadingView.isHidden = true + showSearchingFooterView() + } else { + roomSearchBackgroundView.loadingView.startAnimating() + roomSearchBackgroundView.loadingView.isHidden = false + hideSearchingFooterView() + } + roomSearchBackgroundView.placeholderView.isHidden = true + } else { + roomSearchBackgroundView.loadingView.stopAnimating() + roomSearchBackgroundView.loadingView.isHidden = true + roomSearchBackgroundView.placeholderView.isHidden = !searchSections().isEmpty + } + } + + func showSearchingFooterView() { + let loadingMoreView = UIActivityIndicatorView(frame: CGRect(x: 0, y: 0, width: 44, height: 44)) + loadingMoreView.color = .darkGray + loadingMoreView.startAnimating() + self.tableView.tableFooterView = loadingMoreView + } + + func hideSearchingFooterView() { + self.tableView.tableFooterView = nil + } + + func clearSearchedResults() { + suppressReload = true + rooms = [] + users = [] + listableRooms = [] + messages = [] + suppressReload = false + + reloadAndCheckSearchingIndicator() + } + + // MARK: - Utils + + private func searchSections() -> [RoomSearchSection] { + var sections: [RoomSearchSection] = [] + if !rooms.isEmpty { + sections.append(.filtered) + } + if !users.isEmpty { + sections.append(.users) + } + if !listableRooms.isEmpty { + sections.append(.listable) + } + if !messages.isEmpty { + sections.append(.messages) + } + return sections + } + + func room(for indexPath: IndexPath) -> NCRoom? { + let searchSection = searchSections()[indexPath.section] + if searchSection == .filtered && indexPath.row < rooms.count { + return rooms[indexPath.row] + } else if searchSection == .listable && indexPath.row < listableRooms.count { + return listableRooms[indexPath.row] + } + + return nil + } + + func message(for indexPath: IndexPath) -> NKSearchEntry? { + let searchSection = searchSections()[indexPath.section] + if searchSection == .messages && indexPath.row < messages.count { + return messages[indexPath.row] + } + + return nil + } + + func user(for indexPath: IndexPath) -> NCUser? { + let searchSection = searchSections()[indexPath.section] + if searchSection == .users && indexPath.row < users.count { + return users[indexPath.row] + } + + return nil + } + + private func tableView(_ tableView: UITableView, cellForMessageAt indexPath: IndexPath) -> UITableViewCell { + let messageEntry = messages[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: RoomTableViewCell.identifier) as? RoomTableViewCell ?? RoomTableViewCell(style: .default, reuseIdentifier: RoomTableViewCell.identifier) + + cell.titleLabel.text = messageEntry.title + cell.subtitleLabel.text = messageEntry.subline + + // Thumbnail image + let thumbnailURL = URL(string: messageEntry.thumbnailURL) + let actorId = messageEntry.attributes?["actorId"] as? String + let actorType = messageEntry.attributes?["actorType"] as? String + if let thumbnailURL, !thumbnailURL.absoluteString.isEmpty { + cell.avatarView.avatarImageView.sd_setImage(with: thumbnailURL, placeholderImage: nil, options: [.retryFailed, .refreshCached]) + cell.avatarView.avatarImageView.contentMode = .scaleToFill + } else { + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + cell.avatarView.setActorAvatar(forId: actorId, withType: actorType, withDisplayName: "", withRoomToken: nil, using: activeAccount) + } + + // Clear possible content not removed by cell reuse + cell.dateLabel.text = "" + cell.setUnread(messages: 0, mentioned: false, groupMentioned: false) + + // Add message date (if it is included in attributes) + var timestamp = 0 + if let timestampValue = messageEntry.attributes?["timestamp"] { + if let number = timestampValue as? NSNumber { + timestamp = number.intValue + } else if let string = timestampValue as? String { + timestamp = Int(string) ?? 0 + } + } + if timestamp > 0 { + let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) + cell.dateLabel.text = NCUtils.readableTimeOrDate(fromDate: date) + } + + return cell + } + + private func tableView(_ tableView: UITableView, cellForUserAt indexPath: IndexPath) -> UITableViewCell { + let user = users[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: RoomTableViewCell.identifier) as? RoomTableViewCell ?? RoomTableViewCell(style: .default, reuseIdentifier: RoomTableViewCell.identifier) + + // Clear possible content not removed by cell reuse + cell.dateLabel.text = "" + cell.setUnread(messages: 0, mentioned: false, groupMentioned: false) + + cell.titleLabel.text = user.name + cell.titleOnly = true + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + cell.avatarView.setActorAvatar(forId: user.userId, withType: user.source as String?, withDisplayName: user.name, withRoomToken: nil, using: activeAccount) + + return cell + } + + // MARK: - Table view data source + + override func numberOfSections(in tableView: UITableView) -> Int { + return searchSections().count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch searchSections()[section] { + case .filtered: + return rooms.count + case .users: + return users.count + case .listable: + return listableRooms.count + case .messages: + return messages.count + } + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch searchSections()[section] { + case .filtered: + return NSLocalizedString("Conversations", comment: "") + case .users: + return NSLocalizedString("Users", comment: "") + case .listable: + return NSLocalizedString("Open conversations", comment: "TRANSLATORS 'Open conversations' as a type of conversation. 'Open conversations' are conversations that can be found by other users") + case .messages: + return NSLocalizedString("Messages", comment: "") + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let searchSection = searchSections()[indexPath.section] + // Messages + if searchSection == .messages { + return self.tableView(tableView, cellForMessageAt: indexPath) + } + // Contacts + if searchSection == .users { + return self.tableView(tableView, cellForUserAt: indexPath) + } + + let cell = tableView.dequeueReusableCell(withIdentifier: RoomTableViewCell.identifier) as? RoomTableViewCell ?? RoomTableViewCell(style: .default, reuseIdentifier: RoomTableViewCell.identifier) + + guard let room = room(for: indexPath) else { return cell } + + // Set room name + cell.titleLabel.text = room.displayName + + // Set last activity + if room.lastMessageId != nil || room.lastMessageProxiedJSONString != nil { + cell.titleOnly = false + cell.subtitleLabel.attributedText = room.lastMessageString + } else { + cell.titleOnly = true + cell.subtitleLabel.text = "" + } + let date = Date(timeIntervalSince1970: TimeInterval(room.lastActivity)) + cell.dateLabel.text = NCUtils.readableTimeOrDate(fromDate: date) + + // Open conversations + if searchSection == .listable { + cell.titleOnly = false + cell.subtitleLabel.text = room.roomDescription + cell.dateLabel.text = "" + } + + // Set unread messages + if NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityDirectMentionFlag) { + 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) + } + + if room.unreadMessages > 0 { + // When there are unread messages, we need to show the subtitle at the moment + cell.titleOnly = false + } + + cell.avatarView.setAvatar(for: room) + + // Set favorite or call image + if room.hasCall { + cell.avatarView.favoriteImageView.tintColor = .systemRed + cell.avatarView.favoriteImageView.image = UIImage(systemName: "video.fill") + } else if room.isFavorite { + cell.avatarView.favoriteImageView.tintColor = .systemYellow + cell.avatarView.favoriteImageView.image = UIImage(systemName: "star.fill") + } + + return cell + } +} diff --git a/NextcloudTalk/Rooms/RoomsTableViewController.h b/NextcloudTalk/Rooms/RoomsTableViewController.h deleted file mode 100644 index f89330234..000000000 --- a/NextcloudTalk/Rooms/RoomsTableViewController.h +++ /dev/null @@ -1,17 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -#import -#import - -@interface RoomsTableViewController : UITableViewController - -@property (nonatomic) NSString *selectedRoomToken; - -- (void)setSelectedRoomToken:(NSString *)selectedRoomToken; -- (void)highlightSelectedRoom; -- (void)removeRoomSelection; - -@end diff --git a/NextcloudTalk/Rooms/RoomsTableViewController.m b/NextcloudTalk/Rooms/RoomsTableViewController.m deleted file mode 100644 index 4d8e9ce6d..000000000 --- a/NextcloudTalk/Rooms/RoomsTableViewController.m +++ /dev/null @@ -1,2324 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -#import "RoomsTableViewController.h" - -@import NextcloudKit; -#import - -#import "NextcloudTalk-Swift.h" - -#import "JDStatusBarNotification.h" - -#import "CCCertificate.h" -#import "NCAppBranding.h" -#import "NCDatabaseManager.h" -#import "NCNotificationController.h" -#import "NCSettingsController.h" -#import "NCUserInterfaceController.h" -#import "NotificationCenterNotifications.h" -#import "PlaceholderView.h" -#import "RoomSearchTableViewController.h" -#import "UIBarButtonItem+LegacyBadge.h" - -typedef void (^FetchRoomsCompletionBlock)(BOOL success); - -typedef enum RoomsFilter { - kRoomsFilterAll = 0, - kRoomsFilterUnread, - kRoomsFilterMentioned, - kRoomsFilterEvent -} RoomsFilter; - -typedef enum RoomsSections { - kRoomsSectionPendingFederationInvitation = 0, - kRoomsSectionThreads, - kRoomsSectionArchivedConversations, - kRoomsSectionRoomList, - kRoomsSectionsCount -} RoomsSections; - -@interface RoomsTableViewController () -{ - RLMNotificationToken *_rlmNotificationToken; - NSMutableArray *_rooms; - NSMutableArray *_allRooms; - NSArray *_threads; - BOOL _showingArchivedRooms; - UIRefreshControl *_refreshControl; - BOOL _allowEmptyGroupRooms; - UISearchController *_searchController; - NSString *_searchString; - RoomSearchTableViewController *_resultTableViewController; - NCUnifiedSearchController *_unifiedSearchController; - PlaceholderView *_roomsBackgroundView; - UIBarButtonItem *_newConversationButton; - UIBarButtonItem *_filterButton; - UIBarButtonItem *_settingsButton; - UIButton *_profileButton; - NCUserStatus *_activeUserStatus; - NSTimer *_refreshRoomsTimer; - NSIndexPath *_nextRoomWithMentionIndexPath; - NSIndexPath *_lastRoomWithMentionIndexPath; - UIButton *_unreadMentionsBottomButton; - NCNavigationController *_contextChatNavigationController; - RoomsFilter _activeFilter; -} - -@property (nonatomic, copy, nullable) void (^contextMenuActionBlock)(void); - -@end - -@implementation RoomsTableViewController - -- (void)viewDidLoad -{ - [super viewDidLoad]; - - __weak typeof(self) weakSelf = self; - _rlmNotificationToken = [[NCRoom allObjects] addNotificationBlock:^(RLMResults * _Nullable results, RLMCollectionChange * _Nullable change, NSError * _Nullable error) { - [weakSelf refreshRoomList]; - }]; - - [self.tableView registerNib:[UINib nibWithNibName:RoomTableViewCell.nibName bundle:nil] forCellReuseIdentifier:RoomTableViewCell.identifier]; - [self.tableView registerClass:InfoLabelTableViewCell.class forCellReuseIdentifier:InfoLabelTableViewCell.identifier]; - - self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone; - - self.tableView.rowHeight = UITableViewAutomaticDimension; - self.tableView.estimatedRowHeight = UITableViewAutomaticDimension; - self.tableView.tableFooterView = [[UIView alloc] initWithFrame:CGRectZero]; - - _resultTableViewController = [[RoomSearchTableViewController alloc] initWithStyle:UITableViewStyleInsetGrouped]; - _searchController = [[UISearchController alloc] initWithSearchResultsController:_resultTableViewController]; - _searchController.searchResultsUpdater = self; - [_searchController.searchBar sizeToFit]; - - [self setupNavigationBar]; - - // We want ourselves to be the delegate for the result table so didSelectRowAtIndexPath is called for both tables. - _resultTableViewController.tableView.delegate = self; - _searchController.delegate = self; - _searchController.searchBar.delegate = self; - - self.definesPresentationContext = YES; - - // Rooms placeholder view - _roomsBackgroundView = [[PlaceholderView alloc] init]; - [_roomsBackgroundView.placeholderView setHidden:YES]; - [_roomsBackgroundView.loadingView startAnimating]; - self.tableView.backgroundView = _roomsBackgroundView; - - // Unread mentions bottom indicator - _unreadMentionsBottomButton = [[UIButton alloc] initWithFrame:CGRectMake(0, 0, 126, 28)]; - _unreadMentionsBottomButton.backgroundColor = [NCAppBranding themeColor]; - [_unreadMentionsBottomButton setTitleColor:[NCAppBranding themeTextColor] forState:UIControlStateNormal]; - _unreadMentionsBottomButton.titleLabel.font = [UIFont systemFontOfSize:14]; - _unreadMentionsBottomButton.layer.cornerRadius = 14; - _unreadMentionsBottomButton.clipsToBounds = YES; - _unreadMentionsBottomButton.hidden = NO; - _unreadMentionsBottomButton.translatesAutoresizingMaskIntoConstraints = NO; - _unreadMentionsBottomButton.contentEdgeInsets = UIEdgeInsetsMake(0.0f, 12.0f, 0.0f, 12.0f); - _unreadMentionsBottomButton.titleLabel.minimumScaleFactor = 0.9f; - _unreadMentionsBottomButton.titleLabel.numberOfLines = 1; - _unreadMentionsBottomButton.titleLabel.adjustsFontSizeToFitWidth = YES; - - NSString *unreadMentionsString = NSLocalizedString(@"Unread mentions", nil); - NSString *buttonText = [NSString stringWithFormat:@"↓ %@", unreadMentionsString]; - NSDictionary *attributes = @{NSFontAttributeName: [UIFont systemFontOfSize:14]}; - CGRect textSize = [buttonText boundingRectWithSize:CGSizeMake(300, 28) options:NSStringDrawingUsesLineFragmentOrigin attributes:attributes context:NULL]; - CGFloat buttonWidth = textSize.size.width + 20; - - [_unreadMentionsBottomButton addTarget:self action:@selector(unreadMentionsBottomButtonPressed:) forControlEvents:UIControlEventTouchUpInside]; - [_unreadMentionsBottomButton setTitle:buttonText forState:UIControlStateNormal]; - - [self.view addSubview:_unreadMentionsBottomButton]; - - // Set selection color for selected cells - [self.tableView setTintColor:[UIColor clearColor]]; - - // The title is used when long-pressing the back button in a conversation - self.navigationItem.backButtonTitle = NSLocalizedString(@"Conversations", nil); - - NSDictionary *views = @{@"unreadMentionsButton": _unreadMentionsBottomButton}; - NSDictionary *metrics = @{@"buttonWidth": @(buttonWidth)}; - UILayoutGuide *margins = self.view.layoutMarginsGuide; - - [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|-(>=0)-[unreadMentionsButton(28)]-30-|" options:0 metrics:nil views:views]]; - [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|-(>=0)-[unreadMentionsButton(buttonWidth)]-(>=0)-|" options:0 metrics:metrics views:views]]; - [NSLayoutConstraint activateConstraints:@[[_unreadMentionsBottomButton.centerXAnchor constraintEqualToAnchor:margins.centerXAnchor]]]; - [self.view addConstraint:[_unreadMentionsBottomButton.bottomAnchor constraintEqualToAnchor:self.tableView.safeAreaLayoutGuide.bottomAnchor constant:-20]]; - - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(appStateHasChanged:) name:NSNotification.NCAppStateHasChangedNotification object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(connectionStateHasChanged:) name:NSNotification.NCConnectionStateHasChangedNotification object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(roomsDidUpdate:) name:NCRoomsManagerDidUpdateRoomsNotification object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(notificationWillBePresented:) name:NCNotificationControllerWillPresentNotification object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(serverCapabilitiesUpdated:) name:NCServerCapabilitiesUpdatedNotification object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(userProfileImageUpdated:) name:NCUserProfileImageUpdatedNotification object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(appWillEnterForeground:) name:UIApplicationWillEnterForegroundNotification object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(appWillResignActive:) name:UIApplicationWillResignActiveNotification object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(roomCreated:) name:NCRoomCreatedNotification object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(activeAccountDidChange:) name:NCSettingsControllerDidChangeActiveAccountNotification object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(pendingInvitationsDidUpdate:) name:NCDatabaseManagerPendingFederationInvitationsDidChange object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(inviationDidAccept:) name:NSNotification.FederationInvitationDidAcceptNotification object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(userThreadsUpdated:) name:NCUserThreadsUpdatedNotification object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(userHasThreadsUpdated:) name:NCUserHasThreadsFlagUpdatedNotification object:nil]; -} - -- (void)configureFilterButtonInToolbar -{ -#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 - if (@available(iOS 26, *)) { - if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone) { - TalkAccount *account = [[NCDatabaseManager sharedInstance] activeAccount]; - - NSMutableArray *menuChildren = [[NSMutableArray alloc] init]; - [menuChildren addObject:[self getFiltersSectionReversed:YES]]; - if ([[NCSettingsController sharedInstance] isRoomsSortingSupportedForAccountId:account.accountId]) { - [menuChildren addObject:[self getGroupModeSectionReversed:YES]]; - [menuChildren addObject:[self getSortOrderSectionReversed:YES]]; - } - - UIMenu *menu = [UIMenu menuWithTitle:@"" children:menuChildren]; - - UIBarButtonItem *filterBarButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"line.3.horizontal.decrease"] menu:menu]; - - if (_activeFilter != kRoomsFilterAll) { - filterBarButton.style = UIBarButtonItemStyleProminent; - filterBarButton.tintColor = [NCAppBranding elementColor]; - } - - [self setToolbarItems:@[ - self.navigationItem.searchBarPlacementBarButtonItem, - [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil], - filterBarButton - ] animated:YES]; - - [self.navigationController setToolbarHidden:NO]; - } - } -#endif -} - -- (void)setupNavigationBar -{ - [self setNavigationLogoButton]; - [self configureRightBarButtonItems]; - [self createRefreshControl]; - - self.navigationItem.searchController = _searchController; - - if (@available(iOS 26.0, *)) { - self.tableView.backgroundColor = [UIColor clearColor]; - - // Set a solid background in collapsed mode, as otherwise we have a weird color transition - // when navigating back in light mode - if (self.splitViewController.isCollapsed) { - self.view.backgroundColor = [UIColor systemBackgroundColor]; - } else { - self.view.backgroundColor = [UIColor clearColor]; - } - } else { - [NCAppBranding styleViewController:self]; - } -} - -- (void)setNavigationLogoButton -{ - UIImageView *logoImageView = [[UIImageView alloc] initWithImage:[NCAppBranding navigationLogoImage]]; - if (!customNavigationLogo) { - logoImageView.tintColor = [UIColor labelColor]; - } - self.navigationItem.titleView = logoImageView; - self.navigationItem.titleView.accessibilityLabel = talkAppName; -} - -- (void)configureRightBarButtonItems -{ - NSMutableArray *rightItems = [[NSMutableArray alloc] init]; - - // New conversation button - if ([[NCSettingsController sharedInstance] canCreateGroupAndPublicRooms] || - [[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilityListableRooms]) { - - _newConversationButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"plus.circle.fill"] - style:UIBarButtonItemStylePlain - target:self - action:@selector(presentNewRoomViewController)]; - _newConversationButton.accessibilityLabel = NSLocalizedString(@"Create or join a conversation", nil); - [rightItems addObject:_newConversationButton]; - } - - // Filter and sort button (only when not already in the iOS 26 toolbar menu) - if (![self hasFilterAndSortMenuInToolbar]) { - _filterButton = [[UIBarButtonItem alloc] initWithImage:nil menu:[self getFilterAndSortMenu]]; - _filterButton.image = [UIImage systemImageNamed:(_activeFilter != kRoomsFilterAll) ? @"line.3.horizontal.decrease.circle.fill" : @"line.3.horizontal.decrease.circle"]; - _filterButton.accessibilityLabel = NSLocalizedString(@"Filter and sort conversations", nil); - [rightItems addObject:_filterButton]; - } else { - [self configureFilterButtonInToolbar]; - } - - // iOS 26 style -#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 - if (@available(iOS 26.0, *)) { - _newConversationButton.tintColor = [NCAppBranding elementColor]; - - if ([UIDevice currentDevice].userInterfaceIdiom != UIUserInterfaceIdiomPhone) { - // On non-iPhones we want to hide the shared background (glass effect) - for (UIBarButtonItem *item in rightItems) { - item.hidesSharedBackground = YES; - } - } else { - // On iPhones we want to have a prominent glass button with non-filled icon - _newConversationButton.image = [UIImage systemImageNamed:@"plus"]; - _newConversationButton.style = UIBarButtonItemStyleProminent; - } - } -#endif - - self.navigationItem.rightBarButtonItems = rightItems; -} - -- (BOOL)hasFilterAndSortMenuInToolbar -{ -#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 - if (@available(iOS 26, *)) { - return ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone); - } -#endif - return NO; -} - -- (void)presentNewRoomViewController -{ - TalkAccount *activeAccount = [[NCDatabaseManager sharedInstance] activeAccount]; - NewRoomTableViewController *newRoowVC = [[NewRoomTableViewController alloc] initWithAccount:activeAccount]; - NCNavigationController *navigationController = [[NCNavigationController alloc] initWithRootViewController:newRoowVC]; - [self presentViewController:navigationController animated:YES completion:nil]; -} - -- (void)dealloc -{ - [_rlmNotificationToken invalidate]; - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -- (void)viewDidAppear:(BOOL)animated -{ - [super viewDidAppear:animated]; - - [self adaptInterfaceForAppState:[NCConnectionController shared].appState]; - [self adaptInterfaceForConnectionState:[NCConnectionController shared].connectionState]; - - if ([[NCSettingsController sharedInstance] isContactSyncEnabled] && [[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilityPhonebookSearch]) { - [[NCContactsManager sharedInstance] searchInServerForAddressBookContacts:NO]; - } -} - -- (void)viewWillAppear:(BOOL)animated -{ - [super viewWillAppear:animated]; - - [self refreshRoomList]; - - self.clearsSelectionOnViewWillAppear = self.splitViewController.isCollapsed; - - if (self.splitViewController.isCollapsed) { - [self setSelectedRoomToken:nil]; - } -} - -- (void)viewWillDisappear:(BOOL)animated -{ - [super viewWillDisappear:animated]; - - [self stopRefreshRoomsTimer]; -} - -- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection -{ - if ([self.traitCollection hasDifferentColorAppearanceComparedToTraitCollection:previousTraitCollection]) { - [self setProfileButton]; - [self setupNavigationBar]; - } -} - -- (void)didReceiveMemoryWarning -{ - [super didReceiveMemoryWarning]; - // Dispose of any resources that can be recreated. -} - -#pragma mark - Notifications - -- (void)appStateHasChanged:(NSNotification *)notification -{ - AppState appState = [[notification.userInfo objectForKey:@"appState"] intValue]; - [self adaptInterfaceForAppState:appState]; -} - -- (void)connectionStateHasChanged:(NSNotification *)notification -{ - ConnectionState connectionState = [[notification.userInfo objectForKey:@"connectionState"] intValue]; - [self adaptInterfaceForConnectionState:connectionState]; -} - -- (void)roomsDidUpdate:(NSNotification *)notification -{ - OcsError *error = [notification.userInfo objectForKey:@"error"]; - if (error) { - NSLog(@"Error while trying to get rooms: %@", error); - if ([error underlyingError].code == NSURLErrorServerCertificateUntrusted) { - NSLog(@"Untrusted certificate"); - dispatch_async(dispatch_get_main_queue(), ^{ - [[CCCertificate sharedManager] presentViewControllerCertificateWithTitle:[error underlyingError].localizedDescription viewController:self delegate:self]; - }); - - } - } - - [_refreshControl endRefreshing]; -} - -- (void)pendingInvitationsDidUpdate:(NSNotification *)notification -{ - [self refreshRoomList]; -} - -- (void)inviationDidAccept:(NSNotification *)notification -{ - // We accepted an invitation, so we refresh the rooms from the API to show it directly - [self refreshRooms]; -} - -- (void)userThreadsUpdated:(NSNotification *)notification -{ - NSString *accountId = [notification.userInfo objectForKey:@"accountId"]; - NSArray *threads = [notification.userInfo objectForKey:@"threads"]; - - TalkAccount *activeAccount = [[NCDatabaseManager sharedInstance] activeAccount]; - if ([activeAccount.accountId isEqualToString:accountId]) { - _threads = threads; - [self refreshRoomList]; - } -} - -- (void)userHasThreadsUpdated:(NSNotification *)notification -{ - NSString *accountId = [notification.userInfo objectForKey:@"accountId"]; - TalkAccount *activeAccount = [[NCDatabaseManager sharedInstance] activeAccount]; - if ([activeAccount.accountId isEqualToString:accountId]) { - [self refreshRoomList]; - } -} - -- (void)notificationWillBePresented:(NSNotification *)notification -{ - [[NCRoomsManager shared] updateRoomsAndChatsUpdatingUserStatus:NO onlyLastModified:NO withCompletionBlock:nil]; - [self setUnreadMessageForInactiveAccountsIndicator]; -} - -- (void)serverCapabilitiesUpdated:(NSNotification *)notification -{ - [self setupNavigationBar]; -} - -- (void)userProfileImageUpdated:(NSNotification *)notification -{ - [self setProfileButton]; -} - -- (void)appWillEnterForeground:(NSNotification *)notification -{ - if ([NCConnectionController shared].appState == AppStateReady) { - [[NCRoomsManager shared] updateRoomsAndChatsUpdatingUserStatus:YES onlyLastModified:NO withCompletionBlock:nil]; - [self startRefreshRoomsTimer]; - - dispatch_async(dispatch_get_main_queue(), ^{ - // Dispatch to main, otherwise the traitCollection is not updated yet and profile buttons shows wrong style - [self setProfileButton]; - [self setUnreadMessageForInactiveAccountsIndicator]; - }); - } -} - -- (void)appWillResignActive:(NSNotification *)notification -{ - [self stopRefreshRoomsTimer]; -} - -- (void)roomCreated:(NSNotification *)notification -{ - dispatch_async(dispatch_get_main_queue(), ^{ - [self refreshRooms]; - NSString *roomToken = [notification.userInfo objectForKey:@"token"]; - [self setSelectedRoomToken:roomToken]; - }); -} - -- (void)activeAccountDidChange:(NSNotification *)notification -{ - dispatch_async(dispatch_get_main_queue(), ^{ - self->_activeFilter = kRoomsFilterAll; - [self refreshRoomList]; - - // Setup the navigation bar here, otherwise it would only be updated - // when the capabilities were updated, which fails when the server is not reachable. - [self setupNavigationBar]; - }); -} - -#pragma mark - Refresh Timer - -- (void)startRefreshRoomsTimer -{ - [self stopRefreshRoomsTimer]; - _refreshRoomsTimer = [NSTimer scheduledTimerWithTimeInterval:30.0 target:self selector:@selector(refreshRooms) userInfo:nil repeats:YES]; -} - -- (void)stopRefreshRoomsTimer -{ - [_refreshRoomsTimer invalidate]; - _refreshRoomsTimer = nil; -} - -- (void)refreshRooms -{ - [[NCRoomsManager shared] updateRoomsAndChatsUpdatingUserStatus:YES onlyLastModified:NO withCompletionBlock:nil]; - - if ([NCConnectionController shared].connectionState == ConnectionStateConnected) { - [[NCRoomsManager shared] resendOfflineMessagesWithCompletionBlock:nil]; - } - - [self updateUserStatus]; - - dispatch_async(dispatch_get_main_queue(), ^{ - // Dispatch to main, otherwise the traitCollection is not updated yet and profile buttons shows wrong style - [self setUnreadMessageForInactiveAccountsIndicator]; - }); -} - -#pragma mark - Refresh Control - -- (void)createRefreshControl -{ - _refreshControl = [UIRefreshControl new]; - - if (@available(iOS 26.0, *)) { - _refreshControl.tintColor = [UIColor labelColor]; - } else { - _refreshControl.tintColor = [NCAppBranding themeTextColor]; - } - - [_refreshControl addTarget:self action:@selector(refreshControlTarget) forControlEvents:UIControlEventValueChanged]; - self.tableView.refreshControl = _refreshControl; -} - -- (void)deleteRefreshControl -{ - [_refreshControl endRefreshing]; - self.refreshControl = nil; -} - -- (void)refreshControlTarget -{ - [[NCRoomsManager shared] updateRoomsAndChatsUpdatingUserStatus:YES onlyLastModified:NO withCompletionBlock:nil]; - - [self updateUserStatus]; - - // Actuate `Peek` feedback (weak boom) - AudioServicesPlaySystemSound(1519); -} - -#pragma mark - User Status SwiftUI View Delegate - -- (void)userStatusViewDidDisappear -{ - [self updateUserStatus]; -} - -#pragma mark - Title menu - -- (UIMenu *)getActiveAccountMenuOptions -{ - TalkAccount *activeAccount = [[NCDatabaseManager sharedInstance] activeAccount]; - ServerCapabilities *serverCapabilities = [[NCDatabaseManager sharedInstance] serverCapabilitiesForAccountId:activeAccount.accountId]; - - UIDeferredMenuElement *userStatusDeferred = [UIDeferredMenuElement elementWithUncachedProvider:^(void (^ _Nonnull completion)(NSArray * _Nonnull)) { - if (!activeAccount || !serverCapabilities.userStatus) { - completion(@[]); - return; - } - - [[NCAPIController sharedInstance] getUserStatusForAccount:activeAccount completionBlock:^(NCUserStatus * _Nullable userStatus) { - if (userStatus == nil) { - completion(@[]); - return; - } - - UIImage *userStatusImage = [userStatus getSFUserStatusIcon]; - UIViewController *vc = [UserStatusSwiftUIViewFactory createWithUserStatus:userStatus delegate:self]; - - UIAction *onlineOption = [UIAction actionWithTitle:[userStatus readableUserStatusOrMessage] image:userStatusImage identifier:nil handler:^(UIAction *action) { - [self presentViewController:vc animated:YES completion:nil]; - }]; - - self->_activeUserStatus = userStatus; - [self updateProfileButtonImage]; - - completion(@[onlineOption]); - }]; - }]; - - return [UIMenu menuWithTitle:@"" - image:nil - identifier:nil - options:UIMenuOptionsDisplayInline - children:@[userStatusDeferred]]; -} - -- (UIDeferredMenuElement *)getInactiveAccountMenuOptions -{ - // We use a deferred action here to always have an up-to-date list of inactive accounts and their notifications - UIDeferredMenuElement *inactiveAccountMenuDeferred = [UIDeferredMenuElement elementWithUncachedProvider:^(void (^ _Nonnull completion)(NSArray * _Nonnull)) { - NSMutableArray *inactiveAccounts = [[NSMutableArray alloc] init]; - - for (TalkAccount *account in [[NCDatabaseManager sharedInstance] inactiveAccounts]) { - NSString *accountName = account.userDisplayName; - UIImage *accountImage = [[NCAPIController sharedInstance] userProfileImageForAccount:account withStyle:self.traitCollection.userInterfaceStyle]; - - if (accountImage) { - accountImage = [NCUtils roundedImageFromImage:accountImage]; - - // Draw a red circle to the image in case we have unread notifications for that account - if (account.unreadNotification) { - UIGraphicsBeginImageContextWithOptions(CGSizeMake(82, 82), NO, 3); - CGContextRef context = UIGraphicsGetCurrentContext(); - [accountImage drawInRect:CGRectMake(0, 4, 78, 78)]; - CGContextSaveGState(context); - - CGContextSetFillColorWithColor(context, [UIColor systemRedColor].CGColor); - CGContextFillEllipseInRect(context, CGRectMake(52, 0, 30, 30)); - - accountImage = UIGraphicsGetImageFromCurrentImageContext(); - - UIGraphicsEndImageContext(); - } - } - - UIAction *switchAccountAction = [UIAction actionWithTitle:accountName image:accountImage identifier:nil handler:^(UIAction *action) { - [[NCSettingsController sharedInstance] setActiveAccountWithAccountId:account.accountId]; - }]; - - if (account.unreadBadgeNumber > 0) { - switchAccountAction.subtitle = [NSString localizedStringWithFormat:NSLocalizedString(@"%ld notifications", nil), (long)account.unreadBadgeNumber]; - } else { - switchAccountAction.subtitle = [account.server stringByReplacingOccurrencesOfString:@"https://" withString:@""]; - } - - [inactiveAccounts addObject:switchAccountAction]; - } - - if (inactiveAccounts.count > 0) { - TalkAccount *activeAccount = [[NCDatabaseManager sharedInstance] activeAccount]; - UIImage *accountImage = [[NCAPIController sharedInstance] userProfileImageForAccount:activeAccount withStyle:self.traitCollection.userInterfaceStyle]; - if (accountImage) { - accountImage = [NCUtils roundedImageFromImage:accountImage]; - } - UIAction *activeAccountAction = [UIAction actionWithTitle:activeAccount.userDisplayName image:accountImage identifier:nil handler:^(UIAction *action) {}]; - activeAccountAction.subtitle = [activeAccount.server stringByReplacingOccurrencesOfString:@"https://" withString:@""]; - activeAccountAction.state = UIMenuElementStateOn; - [inactiveAccounts insertObject:activeAccountAction atIndex:0]; - } - - UIMenu *inactiveAccountsMenu = [UIMenu menuWithTitle:@"" - image:nil - identifier:nil - options:UIMenuOptionsDisplayInline - children:inactiveAccounts]; - if (@available(iOS 17.4, *)) { - UIMenuDisplayPreferences *displayPreferences = [[UIMenuDisplayPreferences alloc] init]; - displayPreferences.maximumNumberOfTitleLines = 1; - - inactiveAccountsMenu.displayPreferences = displayPreferences; - } - - completion(@[inactiveAccountsMenu]); - }]; - - return inactiveAccountMenuDeferred; -} - -- (void)updateAccountPickerMenu -{ - NSMutableArray *accountPickerMenu = [[NSMutableArray alloc] init]; - - // When no elements are returned by the deferred menu, the entries / inline-menu will be hidden - [accountPickerMenu addObject:[self getActiveAccountMenuOptions]]; - [accountPickerMenu addObject:[self getInactiveAccountMenuOptions]]; - - NSMutableArray *optionItems = [[NSMutableArray alloc] init]; - - if (multiAccountEnabled) { - UIAction *addAccountOption = [UIAction actionWithTitle:NSLocalizedString(@"Add account", nil) image:[[UIImage systemImageNamed:@"person.crop.circle.badge.plus"] imageWithTintColor:[UIColor secondaryLabelColor] renderingMode:UIImageRenderingModeAlwaysOriginal] identifier:nil handler:^(UIAction *action) { - [[NCUserInterfaceController sharedInstance] presentLoginViewController]; - }]; - - [optionItems addObject:addAccountOption]; - } - - UIAction *openSettingsOption = [UIAction actionWithTitle:NSLocalizedString(@"Settings", nil) image:[[UIImage systemImageNamed:@"gear"] imageWithTintColor:[UIColor secondaryLabelColor] renderingMode:UIImageRenderingModeAlwaysOriginal] identifier:nil handler:^(UIAction *action) { - [[NCDatabaseManager sharedInstance] removeUnreadNotificationForInactiveAccounts]; - [self setUnreadMessageForInactiveAccountsIndicator]; - [AppStoreReviewController recordAction:AppStoreReviewController.visitAppSettings]; - [[NCUserInterfaceController sharedInstance] presentSettingsViewController]; - }]; - - [optionItems addObject:openSettingsOption]; - - UIMenu *optionMenu = [UIMenu menuWithTitle:@"" - image:nil - identifier:nil - options:UIMenuOptionsDisplayInline - children:optionItems]; - - [accountPickerMenu addObject:optionMenu]; - - _profileButton.menu = [UIMenu menuWithTitle:@"" children:accountPickerMenu]; - _profileButton.showsMenuAsPrimaryAction = YES; -} - -#pragma mark - Search controller - -- (void)updateSearchResultsForSearchController:(UISearchController *)searchController -{ - NSString *searchString = _searchController.searchBar.text; - // Do not search for the same term twice (e.g. when the searchbar retrieves back the focus) - if ([_searchString isEqualToString:searchString]) {return;} - _searchString = searchString; - // Cancel previous call to search listable rooms and messages - [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(searchListableRoomsAndMessages) object:nil]; - - // Search for listable rooms and messages - if (searchString.length > 0) { - // Set searchingMessages flag if we are going to search for messages - if ([[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilityUnifiedSearch]) { - [self setLoadMoreButtonHidden:YES]; - _resultTableViewController.searchingMessages = YES; - } - // Throttle listable rooms and messages search - [self performSelector:@selector(searchListableRoomsAndMessages) withObject:nil afterDelay:1]; - } else { - // Clear search results - [self setLoadMoreButtonHidden:YES]; - _resultTableViewController.searchingMessages = NO; - [_resultTableViewController clearSearchedResults]; - } - - // Filter rooms - [self filterRooms]; -} - -- (void)willDismissSearchController:(UISearchController *)searchController -{ - _searchController.searchBar.text = @""; - [self filterRooms]; -} - -- (void)filterRooms -{ - NSArray *filteredRooms = [self filterRoomsWithFilter:_activeFilter]; - - NSString *searchString = _searchController.searchBar.text; - if (searchString.length == 0) { - _rooms = [[NSMutableArray alloc] initWithArray:filteredRooms]; - [self calculateLastRoomWithMention]; - [self.tableView reloadData]; - [self highlightSelectedRoom]; - } else { - _resultTableViewController.rooms = [self filterRooms:filteredRooms withString:searchString]; - [self calculateLastRoomWithMention]; - } - - [self updatePlaceholderView]; -} - -- (void)searchListableRoomsAndMessages -{ - NSString *searchString = _searchController.searchBar.text; - TalkAccount *account = [[NCDatabaseManager sharedInstance] activeAccount]; - // Search for contacts - _resultTableViewController.users = @[]; - [[NCAPIController sharedInstance] getContactsForAccount:account forRoom:nil forGroupRoom:NO withSearchParam:searchString completionBlock:^(NSArray * _Nullable contactList, OcsError *error) { - if (!error) { - NSArray *users = [self usersWithoutOneToOneConversations:contactList]; - if ([[NCSettingsController sharedInstance] isContactSyncEnabled] && [[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilityPhonebookSearch]) { - TalkAccount *activeAccount = [[NCDatabaseManager sharedInstance] activeAccount]; - NSArray *addressBookContacts = [NCContact contactsForAccountId:activeAccount.accountId contains:nil]; - users = [NCUser combineUsersArray:addressBookContacts withUsersArray:users]; - } - self->_resultTableViewController.users = users; - } - }]; - // Search for listable rooms - if ([[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilityListableRooms]) { - _resultTableViewController.listableRooms = @[]; - [[NCAPIController sharedInstance] getListableRoomsForAccount:account withSerachTerm:searchString completionBlock:^(NSArray * _Nullable rooms, OcsError * _Nullable error) { - if (!error) { - self->_resultTableViewController.listableRooms = rooms; - } - }]; - } - // Search for messages - if ([[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilityUnifiedSearch]) { - _unifiedSearchController = [[NCUnifiedSearchController alloc] initWithAccount:account searchTerm:searchString]; - _resultTableViewController.messages = @[]; - [self searchForMessagesWithCurrentSearchTerm]; - } -} - -- (NSArray *)usersWithoutOneToOneConversations:(NSArray *)users -{ - NSPredicate *oneToOnePredicate = [NSPredicate predicateWithFormat:@"type == %ld", kNCRoomTypeOneToOne]; - NSArray *oneToOneRooms = [_rooms filteredArrayUsingPredicate:oneToOnePredicate]; - NSPredicate *namePredicate = [NSPredicate predicateWithFormat:@"NOT (userId IN %@)", [oneToOneRooms valueForKey:@"name"]]; - - return [users filteredArrayUsingPredicate:namePredicate]; -} - -- (void)searchForMessagesWithCurrentSearchTerm -{ - [_unifiedSearchController searchMessagesWithCompletionHandler:^(NSArray *entries) { - dispatch_async(dispatch_get_main_queue(), ^{ - self->_resultTableViewController.searchingMessages = NO; - self->_resultTableViewController.messages = entries; - [self setLoadMoreButtonHidden:!self->_unifiedSearchController.showMore]; - }); - }]; -} - -- (NSArray *)filterRoomsWithFilter:(RoomsFilter)filter -{ - switch (filter) { - case kRoomsFilterUnread: - return [_allRooms filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"isVisible == YES AND unreadMessages > 0 AND isArchived == %@", @(_showingArchivedRooms)]]; - case kRoomsFilterMentioned: - return [_allRooms filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"isVisible == YES AND hasUnreadMention == YES AND isArchived == %@", @(_showingArchivedRooms)]]; - case kRoomsFilterEvent: - return [_allRooms filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"objectType == 'event' AND isArchived == %@", @(_showingArchivedRooms)]]; - default: - return [_allRooms filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"isVisible == YES AND isArchived == %@", @(_showingArchivedRooms)]]; - } -} - -- (NSArray *)filterRooms:(NSArray *)rooms withString:(NSString *)searchString -{ - return [rooms filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"displayName CONTAINS[c] %@", searchString]]; -} - -- (void)setLoadMoreButtonHidden:(BOOL)hidden -{ - if (!hidden) { - UIButton *loadMoreButton = [[UIButton alloc] initWithFrame:CGRectMake(0, 0, self.tableView.frame.size.width, 44)]; - loadMoreButton.titleLabel.font = [UIFont systemFontOfSize:15]; - [loadMoreButton setTitleColor:[UIColor systemBlueColor] forState:UIControlStateNormal]; - [loadMoreButton setTitle:NSLocalizedString(@"Load more results", @"") forState:UIControlStateNormal]; - [loadMoreButton addTarget:self action:@selector(loadMoreMessagesWithCurrentSearchTerm) forControlEvents:UIControlEventTouchUpInside]; - _resultTableViewController.tableView.tableFooterView = loadMoreButton; - } else { - _resultTableViewController.tableView.tableFooterView = nil; - } -} - -- (void)loadMoreMessagesWithCurrentSearchTerm -{ - if (_unifiedSearchController && [_unifiedSearchController.searchTerm isEqualToString:_searchController.searchBar.text]) { - [_resultTableViewController showSearchingFooterView]; - [self searchForMessagesWithCurrentSearchTerm]; - } -} - -#pragma mark - Rooms filter - -- (NSArray *)availableFilters -{ - NSMutableArray *filters = [[NSMutableArray alloc] init]; - [filters addObject:[NSNumber numberWithInt:kRoomsFilterAll]]; - [filters addObject:[NSNumber numberWithInt:kRoomsFilterUnread]]; - [filters addObject:[NSNumber numberWithInt:kRoomsFilterMentioned]]; - [filters addObject:[NSNumber numberWithInt:kRoomsFilterEvent]]; - - return [NSArray arrayWithArray:filters]; -} - -- (NSString *)filterName:(RoomsFilter)filter -{ - switch (filter) { - case kRoomsFilterAll: - return NSLocalizedString(@"No filter", @"'No filter' meaning 'No filter will be applied in conversations list'"); - case kRoomsFilterUnread: - return NSLocalizedString(@"Unread", @"'Unread' meaning 'Unread conversations'"); - case kRoomsFilterMentioned: - return NSLocalizedString(@"Mentioned", @"'Mentioned' meaning 'Mentioned conversations'"); - case kRoomsFilterEvent: - return NSLocalizedString(@"Meetings", @"'Meetings' meaning 'Conversations that were created from a calendar event'"); - default: - return @""; - } -} - -- (UIImage *)filterImage:(RoomsFilter)filter -{ - switch (filter) { - case kRoomsFilterAll: - return [UIImage imageNamed:@"custom.line.3.horizontal.decrease.slash"]; - case kRoomsFilterUnread: - return [UIImage imageNamed:@"custom.bubble.badge"]; - case kRoomsFilterMentioned: - return [UIImage systemImageNamed:@"at"]; - case kRoomsFilterEvent: - return [UIImage systemImageNamed:@"calendar"]; - default: - return nil; - } -} - -- (UIImage *)filterPlaceholderImage:(RoomsFilter)filter -{ - if (filter == kRoomsFilterAll) { - return [UIImage imageNamed:@"conversations-placeholder"]; - } - - return [self filterImage:filter]; -} - -- (NSString *)filterPlaceholderText:(RoomsFilter)filter -{ - switch (filter) { - case kRoomsFilterAll: - return NSLocalizedString(@"You are not part of any conversation. Press + to start a new one.", nil); - case kRoomsFilterUnread: - return NSLocalizedString(@"You have no unread messages.", nil); - case kRoomsFilterMentioned: - return NSLocalizedString(@"You have no unread mentions.", nil); - case kRoomsFilterEvent: - return NSLocalizedString(@"You have no meetings scheduled.", nil); - default: - return nil; - } -} - -#pragma mark - Sort menu - -- (UIMenu *)getSortOrderSectionReversed:(BOOL)reversed -{ - __weak typeof(self) weakSelf = self; - TalkAccount *account = [[NCDatabaseManager sharedInstance] activeAccount]; - ServerCapabilities *serverCapabilities = [[NCDatabaseManager sharedInstance] serverCapabilitiesForAccountId:account.accountId]; - NCRoomSortOrder currentSort = serverCapabilities.roomsSortOrder; - - UIAction *byActivity = [UIAction actionWithTitle:NSLocalizedString(@"By activity", @"Sort conversations by recent activity") - image:[UIImage systemImageNamed:@"clock"] - identifier:nil - handler:^(UIAction *action) { - [weakSelf applySortOrder:NCRoomSortOrderActivity]; - }]; - byActivity.state = (currentSort == NCRoomSortOrderActivity) ? UIMenuElementStateOn : UIMenuElementStateOff; - - UIAction *alphabetically = [UIAction actionWithTitle:NSLocalizedString(@"Alphabetically", @"Sort conversations alphabetically") - image:[UIImage systemImageNamed:@"character.square"] - identifier:nil - handler:^(UIAction *action) { - [weakSelf applySortOrder:NCRoomSortOrderAlphabetical]; - }]; - alphabetically.state = (currentSort == NCRoomSortOrderAlphabetical) ? UIMenuElementStateOn : UIMenuElementStateOff; - - NSArray *children = reversed ? @[alphabetically, byActivity] : @[byActivity, alphabetically]; - - return [UIMenu menuWithTitle:NSLocalizedString(@"Sort conversations", @"Title for conversations sorting options") - image:nil - identifier:nil - options:UIMenuOptionsDisplayInline - children:children]; -} - -- (UIMenu *)getGroupModeSectionReversed:(BOOL)reversed -{ - __weak typeof(self) weakSelf = self; - TalkAccount *account = [[NCDatabaseManager sharedInstance] activeAccount]; - ServerCapabilities *serverCapabilities = [[NCDatabaseManager sharedInstance] serverCapabilitiesForAccountId:account.accountId]; - NCRoomGroupMode currentGroup = serverCapabilities.roomsGroupMode; - - UIAction *noGrouping = [UIAction actionWithTitle:NSLocalizedString(@"No grouping", @"Do not group conversations by type") - image:[UIImage systemImageNamed:@"list.bullet"] - identifier:nil - handler:^(UIAction *action) { - [weakSelf applyGroupMode:NCRoomGroupModeNone]; - }]; - noGrouping.state = (currentGroup == NCRoomGroupModeNone) ? UIMenuElementStateOn : UIMenuElementStateOff; - - UIAction *privateFirst = [UIAction actionWithTitle:NSLocalizedString(@"Private first", @"Show private conversations before group ones") - image:[UIImage systemImageNamed:@"person"] - identifier:nil - handler:^(UIAction *action) { - [weakSelf applyGroupMode:NCRoomGroupModePrivateFirst]; - }]; - privateFirst.state = (currentGroup == NCRoomGroupModePrivateFirst) ? UIMenuElementStateOn : UIMenuElementStateOff; - - UIAction *groupFirst = [UIAction actionWithTitle:NSLocalizedString(@"Group first", @"Show group conversations before private ones") - image:[UIImage systemImageNamed:@"person.2"] - identifier:nil - handler:^(UIAction *action) { - [weakSelf applyGroupMode:NCRoomGroupModeGroupFirst]; - }]; - groupFirst.state = (currentGroup == NCRoomGroupModeGroupFirst) ? UIMenuElementStateOn : UIMenuElementStateOff; - - NSArray *children = reversed ? @[groupFirst, privateFirst, noGrouping] : @[noGrouping, privateFirst, groupFirst]; - - return [UIMenu menuWithTitle:@"" - image:nil - identifier:nil - options:UIMenuOptionsDisplayInline - children:children]; -} - -- (UIMenu *)getFiltersSectionReversed:(BOOL)reversed -{ - __weak typeof(self) weakSelf = self; - NSMutableArray *filterActions = [[NSMutableArray alloc] init]; - - for (NSNumber *filterId in [self availableFilters]) { - RoomsFilter filterValue = (RoomsFilter)filterId.intValue; - - UIAction *action = [UIAction actionWithTitle:[self filterName:filterValue] - image:[self filterImage:filterValue] - identifier:nil - handler:^(UIAction *action) { - typeof(self) strongSelf = weakSelf; - if (!strongSelf) { return; } - - strongSelf->_activeFilter = filterValue; - [strongSelf filterRooms]; - [strongSelf configureRightBarButtonItems]; - [strongSelf updateMentionsIndicator]; - }]; - - action.state = (filterValue == _activeFilter) ? UIMenuElementStateOn : UIMenuElementStateOff; - [filterActions addObject:action]; - } - - NSArray *children = reversed ? [[filterActions reverseObjectEnumerator] allObjects] : filterActions; - - return [UIMenu menuWithTitle:NSLocalizedString(@"Filters", @"Title for available conversations filters") - image:nil - identifier:nil - options:UIMenuOptionsDisplayInline - children:children]; -} - -- (UIMenu *)getFilterAndSortMenu -{ - TalkAccount *account = [[NCDatabaseManager sharedInstance] activeAccount]; - NSMutableArray *children = [[NSMutableArray alloc] init]; - - if ([[NCSettingsController sharedInstance] isRoomsSortingSupportedForAccountId:account.accountId]) { - [children addObject:[self getSortOrderSectionReversed:NO]]; - [children addObject:[self getGroupModeSectionReversed:NO]]; - } - - [children addObject:[self getFiltersSectionReversed:NO]]; - - return [UIMenu menuWithTitle:@"" children:children]; -} - -- (void)applySortOrder:(NCRoomSortOrder)sortOrder -{ - TalkAccount *account = [[NCDatabaseManager sharedInstance] activeAccount]; - - [[NCAPIController sharedInstance] setRoomSortOrder:sortOrder forAccount:account completionHandler:^(BOOL success) { - if (success) { - [[NCSettingsController sharedInstance] getCapabilitiesForAccountId:account.accountId withCompletionBlock:^(OcsError *error) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self refreshRoomList]; - [self configureRightBarButtonItems]; - }); - }]; - } - }]; -} - -- (void)applyGroupMode:(NCRoomGroupMode)groupMode -{ - TalkAccount *account = [[NCDatabaseManager sharedInstance] activeAccount]; - - [[NCAPIController sharedInstance] setRoomGroupMode:groupMode forAccount:account completionHandler:^(BOOL success) { - if (success) { - [[NCSettingsController sharedInstance] getCapabilitiesForAccountId:account.accountId withCompletionBlock:^(OcsError *error) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self refreshRoomList]; - [self configureRightBarButtonItems]; - }); - }]; - } - }]; -} - -#pragma mark - User Interface - -- (void)refreshRoomList -{ - TalkAccount *account = [[NCDatabaseManager sharedInstance] activeAccount]; - NSArray *accountRooms = [[NCDatabaseManager sharedInstance] roomsForAccountId:account.accountId withRealm:nil]; - _allRooms = [[NSMutableArray alloc] initWithArray:accountRooms]; - _rooms = [[NSMutableArray alloc] initWithArray:accountRooms]; - - // Filter rooms - [self filterRooms]; - - // Update placeholder view - [self updatePlaceholderView]; - - // Reload room list - [self.tableView reloadData]; - - // Update unread mentions indicator - [self updateMentionsIndicator]; - - [self highlightSelectedRoom]; -} - -- (void)updatePlaceholderView -{ - [_roomsBackgroundView.loadingView stopAnimating]; - [_roomsBackgroundView.loadingView setHidden:YES]; - - [_roomsBackgroundView setImage:[self filterPlaceholderImage:_activeFilter]]; - [_roomsBackgroundView.placeholderTextView setText:[self filterPlaceholderText:_activeFilter]]; - [_roomsBackgroundView.placeholderView setHidden:(_rooms.count > 0)]; -} - -- (void)adaptInterfaceForAppState:(AppState)appState -{ - switch (appState) { - case AppStateNoServerProvided: - case AppStateMissingUserProfile: - case AppStateMissingServerCapabilities: - case AppStateMissingSignalingConfiguration: - { - // Clear active user status and threads when changing users - _activeUserStatus = nil; - _threads = nil; - [self setProfileButton]; - } - break; - case AppStateReady: - { - [self setProfileButton]; - BOOL isAppActive = [[UIApplication sharedApplication] applicationState] == UIApplicationStateActive; - [[NCRoomsManager shared] updateRoomsUpdatingUserStatus:isAppActive onlyLastModified:NO]; - [self updateUserStatus]; - [self getUserThreads]; - [self startRefreshRoomsTimer]; - [self setupNavigationBar]; - } - break; - - default: - break; - } -} - -- (void)adaptInterfaceForConnectionState:(ConnectionState)connectionState -{ - switch (connectionState) { - case ConnectionStateConnected: - { - [self setOnlineAppearance]; - } - break; - - case ConnectionStateDisconnected: - { - [self setOfflineAppearance]; - } - break; - - default: - break; - } -} - -- (void)setOfflineAppearance -{ - _newConversationButton.enabled = NO; -} - -- (void)setOnlineAppearance -{ - _newConversationButton.enabled = YES; -} - -#pragma mark - UIScrollViewDelegate Methods - -- (void)scrollViewDidScroll:(UIScrollView *)scrollView -{ - if ([scrollView isEqual:self.tableView]) { - [self updateMentionsIndicator]; - } -} - -- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView -{ - if ([scrollView isEqual:self.tableView]) { - [self updateMentionsIndicator]; - } -} - -- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate -{ - if ([scrollView isEqual:self.tableView]) { - [self updateMentionsIndicator]; - } -} - -- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView -{ - if ([scrollView isEqual:self.tableView]) { - [self updateMentionsIndicator]; - } -} - -#pragma mark - Mentions - -- (void)updateMentionsIndicator -{ - NSArray *visibleRows = [self.tableView indexPathsForVisibleRows]; - NSIndexPath *lastVisibleRowIndexPath = visibleRows.lastObject; - _unreadMentionsBottomButton.hidden = YES; - - // Calculate index of first room with a mention outside visible cells - _nextRoomWithMentionIndexPath = nil; - - if (!_lastRoomWithMentionIndexPath) { - return; - } - - for (int i = (int)lastVisibleRowIndexPath.row; i <= (int)_lastRoomWithMentionIndexPath.row && i < [_rooms count]; i++) { - NCRoom *room = [_rooms objectAtIndex:i]; - if (room.hasUnreadMention) { - _nextRoomWithMentionIndexPath = [NSIndexPath indexPathForRow:i inSection:kRoomsSectionRoomList]; - break; - } - } - - // Update unread mentions indicator visibility - _unreadMentionsBottomButton.hidden = [visibleRows containsObject:_lastRoomWithMentionIndexPath] || lastVisibleRowIndexPath.row > _lastRoomWithMentionIndexPath.row; - - // Make sure the style is adjusted to current accounts theme - _unreadMentionsBottomButton.backgroundColor = [NCAppBranding themeColor]; - [_unreadMentionsBottomButton setTitleColor:[NCAppBranding themeTextColor] forState:UIControlStateNormal]; -} - -- (void)unreadMentionsBottomButtonPressed:(id)sender -{ - if (_nextRoomWithMentionIndexPath) { - [self.tableView scrollToRowAtIndexPath:_nextRoomWithMentionIndexPath atScrollPosition:UITableViewScrollPositionMiddle animated:YES]; - } -} - -- (void)calculateLastRoomWithMention -{ - _lastRoomWithMentionIndexPath = nil; - for (int i = 0; i < _rooms.count; i++) { - NCRoom *room = [_rooms objectAtIndex:i]; - if (room.hasUnreadMention) { - _lastRoomWithMentionIndexPath = [NSIndexPath indexPathForRow:i inSection:kRoomsSectionRoomList]; - } - } -} - -#pragma mark - User profile - -- (void)setProfileButton -{ - _profileButton = [UIButton buttonWithType:UIButtonTypeCustom]; - _profileButton.frame = CGRectMake(0, 0, 38, 38); - _profileButton.accessibilityLabel = NSLocalizedString(@"User profile and settings", nil); - - _settingsButton = [[UIBarButtonItem alloc] initWithCustomView:_profileButton]; - -#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 - if (@available(iOS 26.0, *)) { - if ([UIDevice currentDevice].userInterfaceIdiom != UIUserInterfaceIdiomPhone) { - // On non-iPhones we want to hide the shared background (glass effect) - _settingsButton.hidesSharedBackground = YES; - } - } -#endif - - [self.navigationItem setLeftBarButtonItem:_settingsButton]; - - [self updateProfileButtonImage]; - [self updateAccountPickerMenu]; - [self setUnreadMessageForInactiveAccountsIndicator]; -} - -- (void)updateProfileButtonImage -{ - TalkAccount *activeAccount = [[NCDatabaseManager sharedInstance] activeAccount]; - UIImage *profileImage = [[NCAPIController sharedInstance] userProfileImageForAccount:activeAccount withStyle:self.traitCollection.userInterfaceStyle]; - if (profileImage) { - // Crop the profile image into a circle - profileImage = [profileImage cropToCircleWithSize:CGSizeMake(30, 30)]; - // Increase the profile image size to leave space for the status - profileImage = [profileImage withCircularBackgroundWithBackgroundColor:[UIColor separatorColor] diameter:32.0 padding:1.0]; - profileImage = [profileImage withCircularBackgroundWithBackgroundColor:[UIColor clearColor] diameter:38.0 padding:3.0]; - - // Online status icon - UIImage *statusImage = nil; - if ([_activeUserStatus hasVisibleStatusIcon]) { - if (@available(iOS 26.0, *)) { - // TODO: Also cut out the avatar as we do in AvatarView? - statusImage = [[_activeUserStatus getSFUserStatusIcon] withCircularBackgroundWithBackgroundColor:[UIColor clearColor] - diameter:14.0 padding:1.0]; - } else { - statusImage = [[_activeUserStatus getSFUserStatusIcon] withCircularBackgroundWithBackgroundColor:self.navigationController.navigationBar.barTintColor - diameter:14.0 padding:1.0]; - } - } - - // Status message icon - if (_activeUserStatus.icon.length > 0) { - UILabel *iconLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, 14, 14)]; - iconLabel.text = _activeUserStatus.icon; - iconLabel.adjustsFontSizeToFitWidth = YES; - statusImage = [UIImage imageFrom:iconLabel]; - } - - // Set status image - if (statusImage) { - profileImage = [profileImage overlayWith:statusImage at:CGRectMake(24, 24, 14, 14)]; - } - - [_profileButton setImage:profileImage forState:UIControlStateNormal]; - // Used to distinguish between a "completely loaded" button (with a profile image) and the default gear one - _profileButton.accessibilityIdentifier = @"LoadedProfileButton"; - } else { - [_profileButton setImage:[UIImage systemImageNamed:@"gear"] forState:UIControlStateNormal]; - _profileButton.contentMode = UIViewContentModeCenter; - } -} - -- (void)updateUserStatus -{ - TalkAccount *activeAccount = [[NCDatabaseManager sharedInstance] activeAccount]; - [[NCAPIController sharedInstance] getUserStatusForAccount:activeAccount completionBlock:^(NCUserStatus * _Nullable userStatus) { - if (userStatus) { - self->_activeUserStatus = userStatus; - [self updateProfileButtonImage]; - } - }]; -} - -- (void)setUnreadMessageForInactiveAccountsIndicator -{ - NSInteger numberOfInactiveAccountsWithUnreadNotifications = [[NCDatabaseManager sharedInstance] numberOfInactiveAccountsWithUnreadNotifications]; - if (numberOfInactiveAccountsWithUnreadNotifications > 0) { - if (@available(iOS 26.0, *)) { - [_settingsButton setBadge:[UIBarButtonItemBadge badgeWithCount:numberOfInactiveAccountsWithUnreadNotifications]]; - } else { - _settingsButton.legacyBadgeValue = [NSString stringWithFormat:@"%ld", numberOfInactiveAccountsWithUnreadNotifications]; - } - } -} - -#pragma mark - Threads - -- (void)getUserThreads -{ - TalkAccount *activeAccount = [[NCDatabaseManager sharedInstance] activeAccount]; - NSInteger currentTimestamp = [[NSDate date] timeIntervalSince1970]; - - // Check if user has threads on app fresh launch or if last check was over 2 hours ago - if ((currentTimestamp - activeAccount.threadsLastCheckTimestamp) > (2 * 60 * 60)) { - [[NCAPIController sharedInstance] getSubscribedThreadsFor:activeAccount.accountId withLimit:100 andOffset:0 completionBlock:^(NSArray * _Nullable threads, NSError * _Nullable error) { - if (error) { - NSLog(@"Error getting user threads: %@", error); - } - }]; - } -} - -#pragma mark - CCCertificateDelegate - -- (void)trustedCerticateAccepted -{ - [[NCRoomsManager shared] updateRoomsUpdatingUserStatus:NO onlyLastModified:NO]; -} - -#pragma mark - Room actions - -- (UIAction *)actionForNotificationLevel:(NCRoomNotificationLevel)level forRoom:(NCRoom *)room -{ - UIAction *notificationAction = [UIAction actionWithTitle:[NCRoom stringForNotificationLevel:level] image:nil identifier:nil handler:^(UIAction *action) { - if (level == room.notificationLevel) { - return; - } - [[NCAPIController sharedInstance] setNotificationLevelWithLevel:level forRoom:room.token forAccount:[[NCDatabaseManager sharedInstance] activeAccount] completionHandler:^(BOOL success) { - if (success) { - [[JDStatusBarNotificationPresenter sharedPresenter] presentWithText:NSLocalizedString(@"Updated notification settings", "") dismissAfterDelay:5.0 includedStyle:JDStatusBarNotificationIncludedStyleSuccess]; - } else { - NSLog(@"Error setting notification level"); - } - - [[NCRoomsManager shared] updateRoomsUpdatingUserStatus:YES onlyLastModified:NO]; - }]; - }]; - - if (room.notificationLevel == level) { - notificationAction.state = UIMenuElementStateOn; - } - - return notificationAction; -} - -- (void)shareLinkFromRoom:(NCRoom *)room -{ - NSIndexPath *indexPath = [self indexPathForRoom:room]; - if (indexPath) { - [[NCUserInterfaceController sharedInstance] presentShareLinkDialogForRoom:room inViewContoller:self forIndexPath:indexPath]; - } -} - -- (void)archiveRoom:(NCRoom *)room -{ - TalkAccount *activeAccount = [[NCDatabaseManager sharedInstance] activeAccount]; - - [[NCAPIController sharedInstance] archiveRoom:room.token forAccount:activeAccount completionBlock:^(BOOL success) { - if (!success) { - NSLog(@"Error archiving room"); - } - - [[NCRoomsManager shared] updateRoomsUpdatingUserStatus:YES onlyLastModified:NO]; - }]; -} - -- (void)unarchiveRoom:(NCRoom *)room -{ - TalkAccount *activeAccount = [[NCDatabaseManager sharedInstance] activeAccount]; - - [[NCAPIController sharedInstance] unarchiveRoom:room.token forAccount:activeAccount completionBlock:^(BOOL success) { - if (!success) { - NSLog(@"Error unarchiving room"); - } - - [[NCRoomsManager shared] updateRoomsUpdatingUserStatus:YES onlyLastModified:NO]; - }]; -} - -- (void)markRoomAsRead:(NCRoom *)room -{ - [[NCAPIController sharedInstance] setChatReadMarker:room.lastMessage.messageId inRoom:room.token forAccount:[[NCDatabaseManager sharedInstance] activeAccount] completionBlock:^(OcsError *error) { - if (error) { - NSLog(@"Error marking room as read: %@", error.description); - } - [[NCRoomsManager shared] updateRoomsUpdatingUserStatus:YES onlyLastModified:NO]; - }]; -} - -- (void)markRoomAsUnread:(NCRoom *)room -{ - [[NCAPIController sharedInstance] markChatAsUnreadInRoom:room.token forAccount:[[NCDatabaseManager sharedInstance] activeAccount] completionBlock:^(OcsError *error) { - if (error) { - NSLog(@"Error marking chat as unread: %@", error.description); - } - [[NCRoomsManager shared] updateRoomsUpdatingUserStatus:YES onlyLastModified:NO]; - }]; -} - -- (void)addRoomToFavorites:(NCRoom *)room -{ - [[NCAPIController sharedInstance] addRoomToFavorites:room.token forAccount:[[NCDatabaseManager sharedInstance] activeAccount] completionBlock:^(OcsError *error) { - if (error) { - NSLog(@"Error adding room to favorites: %@", error.description); - } - [[NCRoomsManager shared] updateRoomsUpdatingUserStatus:YES onlyLastModified:NO]; - }]; -} - -- (void)removeRoomFromFavorites:(NCRoom *)room -{ - [[NCAPIController sharedInstance] removeRoomFromFavorites:room.token forAccount:[[NCDatabaseManager sharedInstance] activeAccount] completionBlock:^(OcsError *error) { - if (error) { - NSLog(@"Error removing room from favorites: %@", error.description); - } - [[NCRoomsManager shared] updateRoomsUpdatingUserStatus:YES onlyLastModified:NO]; - }]; -} - -- (void)presentRoomInfoForRoom:(NCRoom *)room -{ - UIViewController *roomInfoVC = [RoomInfoUIViewFactory createWithRoom:room showDestructiveActions:YES scrollToParticipantsSectionOnAppear:NO]; - NCNavigationController *navigationController = [[NCNavigationController alloc] initWithRootViewController:roomInfoVC]; - - UIAction *cancelAction = [UIAction actionWithHandler:^(__kindof UIAction * _Nonnull action) { - [roomInfoVC dismissModalViewControllerAnimated:YES]; - }]; - - UIBarButtonItem *cancelButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemCancel primaryAction:cancelAction]; - navigationController.navigationBar.topItem.leftBarButtonItem = cancelButton; - - [self presentViewController:navigationController animated:YES completion:nil]; -} - -- (void)leaveRoom:(NCRoom *)room -{ - UIAlertController *confirmDialog = - [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Leave conversation", nil) - message:NSLocalizedString(@"Once a conversation is left, to rejoin a closed conversation, an invite is needed. An open conversation can be rejoined at any time.", nil) - preferredStyle:UIAlertControllerStyleAlert]; - UIAlertAction *confirmAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Leave", nil) style:UIAlertActionStyleDestructive handler:^(UIAlertAction * _Nonnull action) { - [[NCUserInterfaceController sharedInstance] presentConversationsList]; - - NSIndexPath *indexPath = [self indexPathForRoom:room]; - - if (indexPath) { - [self->_rooms removeObjectAtIndex:indexPath.row]; - [self.tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade]; - } - - [[NCAPIController sharedInstance] removeSelfFromRoom:room.token forAccount:[[NCDatabaseManager sharedInstance] activeAccount] completionHandler:^(OcsResponse * _Nullable response, NSError * _Nullable error) { - if (error) { - OcsError *ocsError = [error.userInfo objectForKey:@"ocsError"]; - if (ocsError.responseStatusCode == 400) { - [self showLeaveRoomLastModeratorErrorForRoom:room]; - } else { - NSLog(@"Error leaving room: %@", error.description); - } - } - - [[NCRoomsManager shared] updateRoomsUpdatingUserStatus:YES onlyLastModified:NO]; - }]; - }]; - [confirmDialog addAction:confirmAction]; - UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", nil) style:UIAlertActionStyleCancel handler:nil]; - [confirmDialog addAction:cancelAction]; - [self presentViewController:confirmDialog animated:YES completion:nil]; -} - -- (void)deleteRoom:(NCRoom *)room -{ - [[NCRoomsManager shared] deleteRoomWithConfirmation:room withStartedBlock:^{ - NSIndexPath *indexPath = [self indexPathForRoom:room]; - - if (indexPath) { - [self->_rooms removeObjectAtIndex:indexPath.row]; - [self.tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationFade]; - } - } withFinishedBlock:nil]; -} - -- (void)presentChatForRoomAtIndexPath:(NSIndexPath *)indexPath -{ - NCRoom *room = [self roomForIndexPath:indexPath]; - ChatViewController *currentChatViewController = [NCRoomsManager shared].chatViewController; - - // When a room is selected, that is currently displayed, leave that room and optionally show the placeholder view again - if (currentChatViewController && [room.token isEqualToString:currentChatViewController.room.token]) { - [currentChatViewController leaveChat]; - [[NCUserInterfaceController sharedInstance].mainViewController showPlaceholderView]; - - return; - } - - [[NCRoomsManager shared] startChatInRoom:room]; -} - -#pragma mark - Utils - -- (NCRoom *)roomForIndexPath:(NSIndexPath *)indexPath -{ - if (_searchController.active && !_resultTableViewController.view.isHidden) { - return [_resultTableViewController roomForIndexPath:indexPath]; - } else if (indexPath.row < _rooms.count) { - return [_rooms objectAtIndex:indexPath.row]; - } - - return nil; -} - -- (NSIndexPath *)indexPathForRoom:(NCRoom *)room -{ - NSUInteger idx = [_rooms indexOfObjectPassingTest:^(id obj, NSUInteger idx, BOOL *stop){ - NCRoom *currentRoom = (NCRoom *)obj; - return [currentRoom.internalId isEqualToString:room.internalId]; - }]; - - if (idx != NSNotFound) { - return [NSIndexPath indexPathForRow:idx inSection:kRoomsSectionRoomList]; - } - - return nil; -} - -- (NSArray *)archivedRooms -{ - return [_allRooms filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"isArchived == YES"]]; -} - -- (BOOL)areArchivedRoomsWithUnreadMentions -{ - return [_allRooms filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"hasUnreadMention == YES AND isArchived == YES"]].count > 0; -} - -- (void)showLeaveRoomLastModeratorErrorForRoom:(NCRoom *)room -{ - UIAlertController *leaveRoomFailedDialog = - [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Could not leave conversation", nil) - message:[NSString stringWithFormat:NSLocalizedString(@"You need to promote a new moderator before you can leave %@.", nil), room.displayName] - preferredStyle:UIAlertControllerStyleAlert]; - - UIAlertAction *okAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"OK", nil) style:UIAlertActionStyleDefault handler:nil]; - [leaveRoomFailedDialog addAction:okAction]; - - [self presentViewController:leaveRoomFailedDialog animated:YES completion:nil]; -} - -#pragma mark - Search results - -- (void)presentSelectedMessageInChat:(NKSearchEntry *)message -{ - NSString *roomToken = [message.attributes objectForKey:@"conversation"]; - NSString *messageIdString = [message.attributes objectForKey:@"messageId"]; - NSString *threadIdString = [message.attributes objectForKey:@"threadId"]; - if (roomToken && messageIdString) { - TalkAccount *activeAccount = [[NCDatabaseManager sharedInstance] activeAccount]; - NSInteger messageId = [messageIdString intValue]; - NCRoom *room = [[NCDatabaseManager sharedInstance] roomWithToken:roomToken forAccountId:activeAccount.accountId]; - NSInteger threadId = [threadIdString intValue]; - NCThread *thread = [NCThread threadWithThreadId:threadId inRoom:roomToken forAccountId:activeAccount.accountId]; - if (room) { - [self presentContextChatInRoom:room inThread:thread forMessageId:messageId]; - } else { - [[NCAPIController sharedInstance] getRoomForAccount:activeAccount withToken:roomToken completionBlock:^(NSDictionary *roomDict, OcsError *error) { - if (!error) { - NCRoom *room = [NCRoom roomWithDictionary:roomDict andAccountId:activeAccount.accountId]; - [self presentContextChatInRoom:room inThread:thread forMessageId:messageId]; - } else { - NSString *errorMessage = NSLocalizedString(@"Unable to get conversation of the message", nil); - [[JDStatusBarNotificationPresenter sharedPresenter] presentWithText:errorMessage dismissAfterDelay:5.0 includedStyle:JDStatusBarNotificationIncludedStyleDark]; - } - }]; - } - } -} - -- (void)presentContextChatInRoom:(NCRoom *)room inThread:(NCThread *)thread forMessageId:(NSInteger)messageId -{ - TalkAccount *account = room.account; - - if (!account) { - return; - } - - ContextChatViewController *contextChatViewController = [[ContextChatViewController alloc] initForRoom:room withAccount:account withMessage:@[] withHighlightId:0]; - contextChatViewController.thread = thread; - [contextChatViewController showContextOfMessageId:messageId withLimit:50 withCloseButton:YES]; - - _contextChatNavigationController = [[NCNavigationController alloc] initWithRootViewController:contextChatViewController]; - [self presentViewController:_contextChatNavigationController animated:YES completion:nil]; -} - -- (void)createRoomForSelectedUser:(NCUser *)user -{ - [[NCAPIController sharedInstance] - createRoomForAccount:[[NCDatabaseManager sharedInstance] activeAccount] withInvite:user.userId - ofType:kNCRoomTypeOneToOne - andName:nil - completionBlock:^(NCRoom *room, OcsError *error) { - if (!error && room.token != nil) { - [self.navigationController dismissViewControllerAnimated:YES completion:^{ - [[NSNotificationCenter defaultCenter] postNotificationName:NCSelectedUserForChatNotification - object:self - userInfo:@{@"token":room.token}]; - }]; - } - - [self->_searchController setActive:NO]; - }]; -} - -#pragma mark - Table view data source - -- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView -{ - return kRoomsSectionsCount; -} - -- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section -{ - if (section == kRoomsSectionPendingFederationInvitation) { - TalkAccount *account = [[NCDatabaseManager sharedInstance] activeAccount]; - return account.pendingFederationInvitations > 0 ? 1 : 0; - } - - if (section == kRoomsSectionArchivedConversations) { - return [self archivedRooms].count > 0 || _showingArchivedRooms ? 1 : 0; - } - - if (section == kRoomsSectionThreads) { - TalkAccount *account = [[NCDatabaseManager sharedInstance] activeAccount]; - return (account.hasThreads || _threads.count > 0) ? 1 : 0; - } - - return _rooms.count; -} - -- (UISwipeActionsConfiguration *)tableView:(UITableView *)tableView trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath -{ - if (tableView == self.tableView && - (indexPath.section == kRoomsSectionPendingFederationInvitation || - indexPath.section == kRoomsSectionArchivedConversations || - indexPath.section == kRoomsSectionThreads)) { - // No swipe action for pending invitations or archived conversations - return nil; - } - - NCRoom *room = [self roomForIndexPath:indexPath]; - - // Do not show swipe actions for open conversations or messages - if ((tableView == _resultTableViewController.tableView && room.listable) || !room) { - return nil; - } - - UIContextualAction *deleteAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive title:nil - handler:^(UIContextualAction * _Nonnull action, __kindof UIView * _Nonnull sourceView, void (^ _Nonnull completionHandler)(BOOL)) { - [self deleteRoom:room]; - completionHandler(false); - }]; - deleteAction.image = [UIImage systemImageNamed:@"trash"]; - - if (room.canLeaveConversation) { - deleteAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive title:nil - handler:^(UIContextualAction * _Nonnull action, __kindof UIView * _Nonnull sourceView, void (^ _Nonnull completionHandler)(BOOL)) { - [self leaveRoom:room]; - completionHandler(false); - }]; - deleteAction.image = [UIImage systemImageNamed:@"arrow.right.square"]; - } - - return [UISwipeActionsConfiguration configurationWithActions:@[deleteAction]]; -} - -- (UISwipeActionsConfiguration *)tableView:(UITableView *)tableView leadingSwipeActionsConfigurationForRowAtIndexPath:(nonnull NSIndexPath *)indexPath -{ - if (tableView == self.tableView && - (indexPath.section == kRoomsSectionPendingFederationInvitation || - indexPath.section == kRoomsSectionArchivedConversations || - indexPath.section == kRoomsSectionThreads)) { - // No swipe action for pending invitations or archived conversations - return nil; - } - - NCRoom *room = [self roomForIndexPath:indexPath]; - - // Do not show swipe actions for open conversations or messages - if ((tableView == _resultTableViewController.tableView && room.listable) || !room) { - return nil; - } - - // Add/Remove room to/from favorites - UIContextualAction *favoriteAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleNormal title:nil - handler:^(UIContextualAction * _Nonnull action, __kindof UIView * _Nonnull sourceView, void (^ _Nonnull completionHandler)(BOOL)) { - if (room.isFavorite) { - [self removeRoomFromFavorites:room]; - } else { - [self addRoomToFavorites:room]; - } - completionHandler(true); - }]; - NSString *favImageName = (room.isFavorite) ? @"star" : @"star.fill"; - favoriteAction.image = [UIImage systemImageNamed:favImageName]; - favoriteAction.backgroundColor = [UIColor colorWithRed:0.97 green:0.80 blue:0.27 alpha:1.0]; // Favorite yellow - - // Mark room as read/unread - if ([[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilityChatReadMarker] && - (!room.isFederated || [[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilityChatReadLast])) { - - UIContextualAction *markReadAction = [UIContextualAction - contextualActionWithStyle:UIContextualActionStyleNormal title:nil - handler:^(UIContextualAction * _Nonnull action, __kindof UIView * _Nonnull sourceView, void (^ _Nonnull completionHandler)(BOOL)) { - if (room.unreadMessages > 0) { - [self markRoomAsRead:room]; - } else { - [self markRoomAsUnread:room]; - } - completionHandler(true); - }]; - - markReadAction.image = (room.unreadMessages > 0) ? [UIImage systemImageNamed:@"checkmark.bubble"] : [UIImage imageNamed:@"custom.bubble.badge"]; - markReadAction.backgroundColor = [UIColor systemBlueColor]; - - return [UISwipeActionsConfiguration configurationWithActions:@[markReadAction, favoriteAction]]; - } - - return [UISwipeActionsConfiguration configurationWithActions:@[favoriteAction]]; -} - -- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath -{ - if (indexPath.section == kRoomsSectionPendingFederationInvitation) { - InfoLabelTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:InfoLabelTableViewCell.identifier]; - if (!cell) { - cell = [[InfoLabelTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:InfoLabelTableViewCell.identifier]; - } - - // Pending federation invitations - TalkAccount *account = [[NCDatabaseManager sharedInstance] activeAccount]; - - NSString *pendingInvitationsString = [NSString localizedStringWithFormat:NSLocalizedString(@"You have %ld pending invitations", nil), (long)account.pendingFederationInvitations]; - UIFont *resultFont = [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline]; - - NSTextAttachment *pendingInvitationsAttachment = [[NSTextAttachment alloc] init]; - pendingInvitationsAttachment.image = [UIImage imageNamed:@"pending-federation-invitations"]; - pendingInvitationsAttachment.bounds = CGRectMake(0, roundf(resultFont.capHeight - 20) / 2, 20, 20); - - NSMutableAttributedString *resultString = [[NSMutableAttributedString alloc] initWithAttributedString:[NSAttributedString attributedStringWithAttachment:pendingInvitationsAttachment]]; - [resultString appendAttributedString:[[NSAttributedString alloc] initWithString:@" "]]; - [resultString appendAttributedString:[[NSAttributedString alloc] initWithString:pendingInvitationsString]]; - - NSRange range = NSMakeRange(0, [resultString length]); - [resultString addAttribute:NSFontAttributeName value:[UIFont preferredFontForTextStyle:UIFontTextStyleHeadline] range:range]; - - cell.label.attributedText = resultString; - - return cell; - } - - if (indexPath.section == kRoomsSectionArchivedConversations) { - InfoLabelTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:InfoLabelTableViewCell.identifier]; - if (!cell) { - cell = [[InfoLabelTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:InfoLabelTableViewCell.identifier]; - } - - NSString *actionString = _showingArchivedRooms ? NSLocalizedString(@"Back to conversations", nil) : NSLocalizedString(@"Archived conversations", nil); - NSString *iconName = _showingArchivedRooms ? @"arrow.left" : @"archivebox"; - UIFont *resultFont = [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline]; - - NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; - attachment.image = [[UIImage systemImageNamed:iconName] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; - attachment.bounds = CGRectMake(0, roundf(resultFont.capHeight - 20) / 2, 24, 20); - - NSMutableAttributedString *resultString = [[NSMutableAttributedString alloc] initWithAttributedString:[NSAttributedString attributedStringWithAttachment:attachment]]; - [resultString appendAttributedString:[[NSAttributedString alloc] initWithString:@" "]]; - [resultString appendAttributedString:[[NSAttributedString alloc] initWithString:actionString]]; - - NSRange range = NSMakeRange(0, [resultString length]); - [resultString addAttribute:NSFontAttributeName value:[UIFont preferredFontForTextStyle:UIFontTextStyleHeadline] range:range]; - - if (!_showingArchivedRooms && [self areArchivedRoomsWithUnreadMentions]) { - NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; - attachment.image = [[UIImage systemImageNamed:@"circle.fill"] imageWithTintColor:[NCAppBranding elementColor] renderingMode:UIImageRenderingModeAlwaysTemplate]; - attachment.bounds = CGRectMake(0, roundf(resultFont.capHeight - 20) / 2, 20, 20); - - [resultString appendAttributedString:[[NSAttributedString alloc] initWithString:@" "]]; - [resultString appendAttributedString:[[NSAttributedString alloc] initWithAttributedString:[NSAttributedString attributedStringWithAttachment:attachment]]]; - } - - cell.label.attributedText = resultString; - - return cell; - } - - if (indexPath.section == kRoomsSectionThreads) { - InfoLabelTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:InfoLabelTableViewCell.identifier]; - if (!cell) { - cell = [[InfoLabelTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:InfoLabelTableViewCell.identifier]; - } - - UIFont *resultFont = [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline]; - NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; - attachment.image = [[UIImage systemImageNamed:@"bubble.left.and.bubble.right"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; - attachment.bounds = CGRectMake(0, roundf(resultFont.capHeight - 20) / 2, 24, 20); - - NSMutableAttributedString *resultString = [[NSMutableAttributedString alloc] initWithAttributedString:[NSAttributedString attributedStringWithAttachment:attachment]]; - [resultString appendAttributedString:[[NSAttributedString alloc] initWithString:@" "]]; - [resultString appendAttributedString:[[NSAttributedString alloc] initWithString:NSLocalizedString(@"Threads", nil)]]; - - NSRange range = NSMakeRange(0, [resultString length]); - [resultString addAttribute:NSFontAttributeName value:[UIFont preferredFontForTextStyle:UIFontTextStyleHeadline] range:range]; - - cell.label.attributedText = resultString; - cell.separatorInset = UIEdgeInsetsMake(0.0f, 0.0f, 0.0f, CGFLOAT_MAX); - - return cell; - } - - RoomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:RoomTableViewCell.identifier]; - if (!cell) { - cell = [[RoomTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:RoomTableViewCell.identifier]; - } - - cell.backgroundColor = [UIColor clearColor]; - - NCRoom *room = [_rooms objectAtIndex:indexPath.row]; - - // Set room name - cell.titleLabel.text = room.displayName; - - // Set last activity - if (room.lastMessageId || room.lastMessageProxiedJSONString) { - cell.titleOnly = NO; - cell.subtitleLabel.attributedText = room.lastMessageString; - } else { - cell.titleOnly = YES; - cell.subtitleLabel.text = @""; - } - NSDate *date = [[NSDate alloc] initWithTimeIntervalSince1970:room.lastActivity]; - cell.dateLabel.text = [NCUtils readableTimeOrDateFromDate:date]; - - // Event conversation handling - if ([room isFutureEvent]) { - cell.titleOnly = NO; - cell.subtitleLabel.text = [room eventStartString]; - cell.dateLabel.text = @""; - } - - // Set unread messages - if ([[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilityDirectMentionFlag]) { - BOOL mentioned = room.unreadMentionDirect || room.type == kNCRoomTypeOneToOne || room.type == kNCRoomTypeFormerOneToOne; - BOOL groupMentioned = room.unreadMention && !room.unreadMentionDirect; - [cell setUnreadWithMessages:room.unreadMessages mentioned:mentioned groupMentioned:groupMentioned]; - } else { - BOOL mentioned = room.unreadMention || room.type == kNCRoomTypeOneToOne || room.type == kNCRoomTypeFormerOneToOne; - [cell setUnreadWithMessages:room.unreadMessages mentioned:mentioned groupMentioned:NO]; - } - - if (room.unreadMessages > 0) { - // When there are unread messages, we need to show the subtitle at the moment - cell.titleOnly = NO; - } - - [cell.avatarView setAvatarFor:room]; - - // Set favorite or call image - if (room.hasCall) { - [cell.avatarView.favoriteImageView setTintColor:[UIColor systemRedColor]]; - [cell.avatarView.favoriteImageView setImage:[UIImage systemImageNamed:@"video.fill"]]; - } else if (room.isFavorite) { - [cell.avatarView.favoriteImageView setTintColor:[UIColor systemYellowColor]]; - [cell.avatarView.favoriteImageView setImage:[UIImage systemImageNamed:@"star.fill"]]; - } - - cell.roomToken = room.token; - - return cell; -} - -- (void)tableView:(UITableView *)tableView didHighlightRowAtIndexPath:(NSIndexPath *)indexPath { - RoomTableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath]; - [cell setSelected:YES]; -} - - -- (void)tableView:(UITableView *)tableView didUnhighlightRowAtIndexPath:(NSIndexPath *)indexPath -{ - RoomTableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath]; - [cell setSelected:NO]; -} - -- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)rcell forRowAtIndexPath:(NSIndexPath *)indexPath -{ - if (tableView != self.tableView || - indexPath.section == kRoomsSectionPendingFederationInvitation || - indexPath.section == kRoomsSectionArchivedConversations || - indexPath.section == kRoomsSectionThreads) { - return; - } - - RoomTableViewCell *cell = (RoomTableViewCell *)rcell; - NCRoom *room = [_rooms objectAtIndex:indexPath.row]; - - [cell.avatarView setStatusFor:room allowCustomStatusIcon:YES]; -} - -- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath -{ - BOOL isAppInForeground = [[UIApplication sharedApplication] applicationState] == UIApplicationStateActive; - - if (!isAppInForeground) { - // In case we are not in the active state, we don't want to invoke any navigation event as this might - // lead to crashes, when the wrong NavBar is referenced - return; - } - - if (self.navigationController.transitionCoordinator != nil) { - // In case we are currently in a transition (e.g. swipe back from a conversation), - // we don't want to present any new view controller, as that leads to crashes on iOS >= 26 - [self removeRoomSelection]; - return; - } - - if (tableView == self.tableView && indexPath.section == kRoomsSectionPendingFederationInvitation) { - FederationInvitationTableViewController *federationInvitationVC = [[FederationInvitationTableViewController alloc] init]; - NCNavigationController *navigationController = [[NCNavigationController alloc] initWithRootViewController:federationInvitationVC]; - [self presentViewController:navigationController animated:YES completion:nil]; - - return; - } - - if (tableView == self.tableView && indexPath.section == kRoomsSectionArchivedConversations) { - _showingArchivedRooms = !_showingArchivedRooms; - [UIView transitionWithView:self.tableView duration:0.2 options:UIViewAnimationOptionTransitionCrossDissolve animations:^{ - [self filterRooms]; - [self updateMentionsIndicator]; - } completion:nil]; - return; - } - - if (tableView == self.tableView && indexPath.section == kRoomsSectionThreads) { - [UIView transitionWithView:self.tableView duration:0.2 options:UIViewAnimationOptionTransitionCrossDissolve animations:^{ - ThreadsTableViewController *threadsVC = [[ThreadsTableViewController alloc] initWithThreads:self->_threads]; - NCNavigationController *navigationController = [[NCNavigationController alloc] initWithRootViewController:threadsVC]; - [self presentViewController:navigationController animated:YES completion:nil]; - } completion:nil]; - return; - } - - if (tableView == _resultTableViewController.tableView) { - // Messages - NKSearchEntry *message = [_resultTableViewController messageForIndexPath:indexPath]; - if (message) { - [self presentSelectedMessageInChat:message]; - return; - } - - // Users - NCUser *user = [_resultTableViewController userForIndexPath:indexPath]; - if (user) { - [self createRoomForSelectedUser:user]; - return; - } - } - - // Present room chat - [self removeRoomSelection]; - [self presentChatForRoomAtIndexPath:indexPath]; -} - -- (UIContextMenuConfiguration *)tableView:(UITableView *)tableView contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point -{ - if (tableView != self.tableView || - indexPath.section == kRoomsSectionPendingFederationInvitation || - indexPath.section == kRoomsSectionArchivedConversations || - indexPath.section == kRoomsSectionThreads) { - return nil; - } - - __weak typeof(self) weakSelf = self; - - NCRoom *room = [self roomForIndexPath:indexPath]; - NSMutableArray *actions = [[NSMutableArray alloc] init]; - - NSString *favImageName = (room.isFavorite) ? @"star.slash" : @"star"; - UIImage *favImage = [[UIImage systemImageNamed:favImageName] imageWithTintColor:UIColor.systemYellowColor renderingMode:UIImageRenderingModeAlwaysOriginal]; - NSString *favActionName = (room.isFavorite) ? NSLocalizedString(@"Remove from favorites", nil) : NSLocalizedString(@"Add to favorites", nil); - UIAction *favAction = [UIAction actionWithTitle:favActionName image:favImage identifier:nil handler:^(UIAction *action) { - weakSelf.contextMenuActionBlock = ^{ - if (room.isFavorite) { - [weakSelf removeRoomFromFavorites:room]; - } else { - [weakSelf addRoomToFavorites:room]; - } - }; - }]; - - [actions addObject:favAction]; - - // Mark room as read/unread - if ([[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilityChatReadMarker] && - (!room.isFederated || [[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilityChatReadLast])) { - if (room.unreadMessages > 0) { - // Mark room as read - UIAction *markReadAction = [UIAction actionWithTitle:NSLocalizedString(@"Mark as read", nil) image:[UIImage systemImageNamed:@"checkmark.bubble"] identifier:nil handler:^(UIAction *action) { - weakSelf.contextMenuActionBlock = ^{ - [weakSelf markRoomAsRead:room]; - }; - }]; - - [actions addObject:markReadAction]; - } else if ([[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilityChatUnread]) { - // Mark room as unread - UIAction *markUnreadAction = [UIAction actionWithTitle:NSLocalizedString(@"Mark as unread", nil) image:[UIImage imageNamed:@"custom.bubble.badge"] identifier:nil handler:^(UIAction *action) { - weakSelf.contextMenuActionBlock = ^{ - [weakSelf markRoomAsUnread:room]; - }; - }]; - - [actions addObject:markUnreadAction]; - } - } - - // Notification levels - if ([[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilityNotificationLevels] && - room.type != kNCRoomTypeChangelog && room.type != kNCRoomTypeNoteToSelf) { - - NSMutableArray *notificationActions = [[NSMutableArray alloc] init]; - - // Chat notification settings - [notificationActions addObject:[self actionForNotificationLevel:kNCRoomNotificationLevelAlways forRoom:room]]; - [notificationActions addObject:[self actionForNotificationLevel:kNCRoomNotificationLevelMention forRoom:room]]; - [notificationActions addObject:[self actionForNotificationLevel:kNCRoomNotificationLevelNever forRoom:room]]; - - // Call notification - if ([[NCDatabaseManager sharedInstance] roomHasTalkCapability:kCapabilityNotificationCalls forRoom:room] && [room supportsCalling]) { - UIAction *callNotificationAction = [UIAction actionWithTitle:NSLocalizedString(@"Notify about calls", nil) image:nil identifier:nil handler:^(UIAction *action) { - BOOL newState = !(action.state == UIMenuElementStateOn); - - [[NCAPIController sharedInstance] setCallNotificationLevelWithEnabled:newState forRoom:room.token forAccount:[[NCDatabaseManager sharedInstance] activeAccount] completionHandler:^(BOOL success) { - if (success) { - [[JDStatusBarNotificationPresenter sharedPresenter] presentWithText:NSLocalizedString(@"Updated notification settings", "") dismissAfterDelay:5.0 includedStyle:JDStatusBarNotificationIncludedStyleSuccess]; - } else { - NSLog(@"Error setting call notification"); - } - - [[NCRoomsManager shared] updateRoomsUpdatingUserStatus:YES onlyLastModified:NO]; - }]; - }]; - - if (room.notificationCalls) { - callNotificationAction.state = UIMenuElementStateOn; - } - - UIMenu *callNotificationMenu = [UIMenu menuWithTitle:nil image:nil identifier:nil options:UIMenuOptionsDisplayInline children:@[callNotificationAction]]; - [notificationActions addObject:callNotificationMenu]; - } - - // Important conversation - if ([[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilityImportantConversations]) { - UIAction *importantConversationAction = [UIAction actionWithTitle:NSLocalizedString(@"Important conversation", nil) image:nil identifier:nil handler:^(UIAction *action) { - BOOL newState = !(action.state == UIMenuElementStateOn); - - [[NCAPIController sharedInstance] setImportantStateWithEnabled:newState forRoom:room.token forAccount:[[NCDatabaseManager sharedInstance] activeAccount] completionHandler:^(NCRoom * _Nullable room, NSError * _Nullable error) { - if (error) { - NSLog(@"Error setting call notification: %@", error.description); - } else { - [[JDStatusBarNotificationPresenter sharedPresenter] presentWithText:NSLocalizedString(@"Updated notification settings", "") dismissAfterDelay:5.0 includedStyle:JDStatusBarNotificationIncludedStyleSuccess]; - } - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^(void) { - [[NCRoomsManager shared] updateRoomsUpdatingUserStatus:YES onlyLastModified:NO]; - }); - }]; - }]; - - importantConversationAction.subtitle = NSLocalizedString(@"'Do not disturb' user status is ignored for important conversations", nil); - - if (room.isImportant) { - importantConversationAction.state = UIMenuElementStateOn; - } - - UIMenu *importantConversationMenu = [UIMenu menuWithTitle:@"" image:nil identifier:nil options:UIMenuOptionsDisplayInline children:@[importantConversationAction]]; - [notificationActions addObject:importantConversationMenu]; - } - - // Sensitive conversation - if ([[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilitySensitiveConversations]) { - UIAction *sensitiveConversationAction = [UIAction actionWithTitle:NSLocalizedString(@"Sensitive conversation", nil) image:nil identifier:nil handler:^(UIAction *action) { - BOOL newState = !(action.state == UIMenuElementStateOn); - - [[NCAPIController sharedInstance] setSensitiveStateWithEnabled:newState forRoom:room.token forAccount:[[NCDatabaseManager sharedInstance] activeAccount] completionHandler:^(NCRoom * _Nullable room, NSError * _Nullable error) { - if (error) { - NSLog(@"Error setting call notification: %@", error.description); - } else { - [[JDStatusBarNotificationPresenter sharedPresenter] presentWithText:NSLocalizedString(@"Updated notification settings", "") dismissAfterDelay:5.0 includedStyle:JDStatusBarNotificationIncludedStyleSuccess]; - } - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^(void) { - [[NCRoomsManager shared] updateRoomsUpdatingUserStatus:YES onlyLastModified:NO]; - }); - }]; - }]; - - sensitiveConversationAction.subtitle = NSLocalizedString(@"Message preview will be disabled in conversation list and notifications", nil); - - if (room.isSensitive) { - sensitiveConversationAction.state = UIMenuElementStateOn; - } - - UIMenu *sensitiveConversationMenu = [UIMenu menuWithTitle:@"" image:nil identifier:nil options:UIMenuOptionsDisplayInline children:@[sensitiveConversationAction]]; - [notificationActions addObject:sensitiveConversationMenu]; - } - - UIMenu *notificationMenu = [UIMenu menuWithTitle:NSLocalizedString(@"Notifications", nil) - image:[UIImage systemImageNamed:@"bell"] - identifier:nil - options:0 - children:notificationActions]; - - [actions addObject:notificationMenu]; - } - - // Share link - if (room.type != kNCRoomTypeChangelog && room.type != kNCRoomTypeNoteToSelf) { - UIAction *notificationActions = [UIAction actionWithTitle:NSLocalizedString(@"Share link", nil) image:[UIImage systemImageNamed:@"square.and.arrow.up"] identifier:nil handler:^(UIAction *action) { - [weakSelf shareLinkFromRoom:room]; - }]; - - [actions addObject:notificationActions]; - } - - // Archive conversation - if ([[NCDatabaseManager sharedInstance] serverHasTalkCapability:kCapabilityArchivedConversationsV2]) { - if (room.isArchived) { - UIAction *unarchiveAction = [UIAction actionWithTitle:NSLocalizedString(@"Unarchive conversation", nil) image:[UIImage systemImageNamed:@"arrow.up.bin"] identifier:nil handler:^(UIAction *action) { - [weakSelf unarchiveRoom:room]; - }]; - - [actions addObject:unarchiveAction]; - } else { - UIAction *archiveAction = [UIAction actionWithTitle:NSLocalizedString(@"Archive conversation", nil) image:[UIImage systemImageNamed:@"archivebox"] identifier:nil handler:^(UIAction *action) { - [weakSelf archiveRoom:room]; - }]; - - [actions addObject:archiveAction]; - } - } - - // Room info - UIAction *roomInfoAction = [UIAction actionWithTitle:NSLocalizedString(@"Conversation settings", nil) image:[UIImage systemImageNamed:@"gearshape"] identifier:nil handler:^(UIAction *action) { - [weakSelf presentRoomInfoForRoom:room]; - }]; - - [actions addObject:roomInfoAction]; - - NSMutableArray *destructiveActions = [[NSMutableArray alloc] init]; - - if (room.canLeaveConversation) { - UIAction *leaveAction = [UIAction actionWithTitle:NSLocalizedString(@"Leave conversation", nil) image:[UIImage systemImageNamed:@"arrow.right.square"] identifier:nil handler:^(UIAction *action) { - [weakSelf leaveRoom:room]; - }]; - - leaveAction.attributes = UIMenuElementAttributesDestructive; - [destructiveActions addObject:leaveAction]; - } - - if (room.canDeleteConversation) { - UIAction *deleteAction = [UIAction actionWithTitle:NSLocalizedString(@"Delete conversation", nil) image:[UIImage systemImageNamed:@"trash"] identifier:nil handler:^(UIAction *action) { - [weakSelf deleteRoom:room]; - }]; - - deleteAction.attributes = UIMenuElementAttributesDestructive; - [destructiveActions addObject:deleteAction]; - } - - if (destructiveActions.count > 0) { - UIMenu *deleteMenu = [UIMenu menuWithTitle:@"" - image:nil - identifier:nil - options:UIMenuOptionsDisplayInline - children:destructiveActions]; - - [actions addObject:deleteMenu]; - } - - UIMenu *menu = [UIMenu menuWithTitle:@"" children:actions]; - - UIContextMenuConfiguration *configuration = [UIContextMenuConfiguration configurationWithIdentifier:indexPath previewProvider:^UIViewController * _Nullable{ - return nil; - } actionProvider:^UIMenu * _Nullable(NSArray * _Nonnull suggestedActions) { - return menu; - }]; - - return configuration; -} - -- (UITargetedPreview *)tableView:(UITableView *)tableView previewForHighlightingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration -{ - if (![tableView isEqual:self.tableView]) { - return nil; - } - - if (@available(iOS 26.0, *)) { - // Don't provide a preview here in case of iOS 26 as it just looks bad - return nil; - } - - NSIndexPath *indexPath = (NSIndexPath *)configuration.identifier; - - // Use a snapshot here to not interfere with room refresh - UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; - UIView *previewView = [cell.contentView snapshotViewAfterScreenUpdates:NO]; - previewView.backgroundColor = UIColor.systemBackgroundColor; - - // On large iPhones (with regular landscape size, like iPhone X) we need to take the safe area into account when calculating the center - CGFloat cellCenterX = cell.center.x + self.view.safeAreaInsets.left / 2 - self.view.safeAreaInsets.right / 2; - CGPoint cellCenter = CGPointMake(cellCenterX, cell.center.y); - - // Create a preview target which allows us to have a transparent background - UIPreviewTarget *previewTarget = [[UIPreviewTarget alloc] initWithContainer:self.view center:cellCenter]; - UIPreviewParameters *previewParameter = [[UIPreviewParameters alloc] init]; - - // Remove the background and the drop shadow from our custom preview view - previewParameter.backgroundColor = UIColor.systemBackgroundColor; - previewParameter.shadowPath = [[UIBezierPath alloc] init]; - - return [[UITargetedPreview alloc] initWithView:previewView parameters:previewParameter target:previewTarget]; -} - -- (void)tableView:(UITableView *)tableView willEndContextMenuInteractionWithConfiguration:(UIContextMenuConfiguration *)configuration animator:(id)animator -{ - if (![tableView isEqual:self.tableView]) { - return; - } - - [animator addCompletion:^{ - // Wait until the context menu is completely hidden before we execute any method - if (self->_contextMenuActionBlock) { - self->_contextMenuActionBlock(); - self->_contextMenuActionBlock = nil; - } - }]; -} - -- (void)setSelectedRoomToken:(NSString *)selectedRoomToken -{ - _selectedRoomToken = selectedRoomToken; - [self highlightSelectedRoom]; -} - -- (void)removeRoomSelection { - [self setSelectedRoomToken:nil]; -} - -- (void)highlightSelectedRoom -{ - if(_selectedRoomToken != nil) { - NSUInteger idx = [_rooms indexOfObjectPassingTest:^(id obj, NSUInteger idx, BOOL *stop){ - NCRoom* room = (NCRoom*)obj; - return [room.token isEqualToString:_selectedRoomToken]; - }]; - - if (idx != NSNotFound) { - NSIndexPath* indexPath = [NSIndexPath indexPathForRow:idx inSection:kRoomsSectionRoomList]; - [self.tableView selectRowAtIndexPath:indexPath animated:YES scrollPosition:UITableViewScrollPositionNone]; - } - } else { - NSIndexPath *selectedRow = [self.tableView indexPathForSelectedRow]; - if (selectedRow != nil) { - [self.tableView deselectRowAtIndexPath:selectedRow animated:YES]; - - // It might happen that this is called while we are switching accounts, so wait for the reload to be finished. - // Example: Active account has 1 pending invitation, switch to an account with no pending invitation -> crash. - dispatch_async(dispatch_get_main_queue(), ^{ - // Needed to make sure the highlight is really removed - [self.tableView reloadRowsAtIndexPaths:@[selectedRow] withRowAnimation:UITableViewRowAnimationNone]; - }); - } - } -} - -@end diff --git a/NextcloudTalk/Rooms/RoomsTableViewController.swift b/NextcloudTalk/Rooms/RoomsTableViewController.swift new file mode 100644 index 000000000..0cacdbec0 --- /dev/null +++ b/NextcloudTalk/Rooms/RoomsTableViewController.swift @@ -0,0 +1,2108 @@ +// +// SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit +import AudioToolbox +import Realm +import NextcloudKit + +@objc(RoomsTableViewController) +class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UISearchBarDelegate, UISearchControllerDelegate, UISearchResultsUpdating, UserStatusViewDelegate { + + private enum RoomsFilter: Int { + case all = 0 + case unread + case mentioned + case event + } + + private enum RoomsSection: Int, CaseIterable { + case pendingFederationInvitation = 0 + case threads + case archivedConversations + case roomList + } + + @objc public var selectedRoomToken: String? { + didSet { + highlightSelectedRoom() + } + } + + private var rlmNotificationToken: RLMNotificationToken? + private var rooms: [NCRoom] = [] + private var allRooms: [NCRoom] = [] + private var threads: [NCThread]? + private var showingArchivedRooms = false + private var roomRefreshControl: UIRefreshControl! + private var searchController: UISearchController! + private var searchString: String? + private var resultTableViewController: RoomSearchTableViewController! + private var unifiedSearchController: NCUnifiedSearchController? + private var roomsBackgroundView: PlaceholderView! + private var newConversationButton: UIBarButtonItem? + private var filterButton: UIBarButtonItem? + private var settingsButton: UIBarButtonItem! + private var profileButton: UIButton! + private var activeUserStatus: NCUserStatus? + private var refreshRoomsTimer: Timer? + private var nextRoomWithMentionIndexPath: IndexPath? + private var lastRoomWithMentionIndexPath: IndexPath? + private var unreadMentionsBottomButton: UIButton! + private var contextChatNavigationController: NCNavigationController? + private var activeFilter: RoomsFilter = .all + + private var contextMenuActionBlock: (() -> Void)? + + // While a context menu is being displayed we defer room list reloads, otherwise reloading the + // table moves the cells out from under the floating context menu preview, making it overlay + // unrelated cells. Any refresh that arrives meanwhile is coalesced and applied once the menu ends. + private var isContextMenuActive = false + private var pendingRoomListRefresh = false + + override func viewDidLoad() { + super.viewDidLoad() + + rlmNotificationToken = NCRoom.allObjects().addNotificationBlock { [weak self] _, _, _ in + self?.refreshRoomList() + } + + self.tableView.register(UINib(nibName: RoomTableViewCell.nibName, bundle: nil), forCellReuseIdentifier: RoomTableViewCell.identifier) + self.tableView.register(InfoLabelTableViewCell.self, forCellReuseIdentifier: InfoLabelTableViewCell.identifier) + + self.tableView.separatorStyle = .none + + self.tableView.rowHeight = UITableView.automaticDimension + self.tableView.estimatedRowHeight = UITableView.automaticDimension + self.tableView.tableFooterView = UIView(frame: .zero) + + resultTableViewController = RoomSearchTableViewController(style: .insetGrouped) + searchController = UISearchController(searchResultsController: resultTableViewController) + searchController.searchResultsUpdater = self + searchController.searchBar.sizeToFit() + + setupNavigationBar() + + // We want ourselves to be the delegate for the result table so didSelectRowAtIndexPath is called for both tables. + resultTableViewController.tableView.delegate = self + searchController.delegate = self + searchController.searchBar.delegate = self + + self.definesPresentationContext = true + + // Rooms placeholder view + roomsBackgroundView = PlaceholderView() + roomsBackgroundView.placeholderView.isHidden = true + roomsBackgroundView.loadingView.startAnimating() + self.tableView.backgroundView = roomsBackgroundView + + // Unread mentions bottom indicator + unreadMentionsBottomButton = UIButton(frame: CGRect(x: 0, y: 0, width: 126, height: 28)) + unreadMentionsBottomButton.backgroundColor = NCAppBranding.themeColor() + unreadMentionsBottomButton.setTitleColor(NCAppBranding.themeTextColor(), for: .normal) + unreadMentionsBottomButton.titleLabel?.font = .systemFont(ofSize: 14) + unreadMentionsBottomButton.layer.cornerRadius = 14 + unreadMentionsBottomButton.clipsToBounds = true + unreadMentionsBottomButton.isHidden = false + unreadMentionsBottomButton.translatesAutoresizingMaskIntoConstraints = false + unreadMentionsBottomButton.contentEdgeInsets = UIEdgeInsets(top: 0.0, left: 12.0, bottom: 0.0, right: 12.0) + unreadMentionsBottomButton.titleLabel?.minimumScaleFactor = 0.9 + unreadMentionsBottomButton.titleLabel?.numberOfLines = 1 + unreadMentionsBottomButton.titleLabel?.adjustsFontSizeToFitWidth = true + + let unreadMentionsString = NSLocalizedString("Unread mentions", comment: "") + let buttonText = "↓ \(unreadMentionsString)" + let attributes: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 14)] + let textSize = NSString(string: buttonText).boundingRect(with: CGSize(width: 300, height: 28), options: .usesLineFragmentOrigin, attributes: attributes, context: nil) + let buttonWidth = textSize.size.width + 20 + + unreadMentionsBottomButton.addTarget(self, action: #selector(unreadMentionsBottomButtonPressed(_:)), for: .touchUpInside) + unreadMentionsBottomButton.setTitle(buttonText, for: .normal) + + self.view.addSubview(unreadMentionsBottomButton) + + // Set selection color for selected cells + self.tableView.tintColor = .clear + + // The title is used when long-pressing the back button in a conversation + self.navigationItem.backButtonTitle = NSLocalizedString("Conversations", comment: "") + + let views: [String: Any] = ["unreadMentionsButton": unreadMentionsBottomButton as Any] + let metrics: [String: Any] = ["buttonWidth": buttonWidth] + let margins = self.view.layoutMarginsGuide + + NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "V:|-(>=0)-[unreadMentionsButton(28)]-30-|", options: [], metrics: nil, views: views)) + NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "H:|-(>=0)-[unreadMentionsButton(buttonWidth)]-(>=0)-|", options: [], metrics: metrics, views: views)) + NSLayoutConstraint.activate([unreadMentionsBottomButton.centerXAnchor.constraint(equalTo: margins.centerXAnchor)]) + NSLayoutConstraint.activate([unreadMentionsBottomButton.bottomAnchor.constraint(equalTo: self.tableView.safeAreaLayoutGuide.bottomAnchor, constant: -20)]) + + let notificationCenter = NotificationCenter.default + notificationCenter.addObserver(self, selector: #selector(appStateHasChanged(_:)), name: .NCAppStateHasChangedNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(connectionStateHasChanged(_:)), name: .NCConnectionStateHasChangedNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(roomsDidUpdate(_:)), name: .NCRoomsManagerDidUpdateRooms, object: nil) + notificationCenter.addObserver(self, selector: #selector(notificationWillBePresented(_:)), name: .NCNotificationControllerWillPresent, object: nil) + notificationCenter.addObserver(self, selector: #selector(serverCapabilitiesUpdated(_:)), name: .NCServerCapabilitiesUpdated, object: nil) + notificationCenter.addObserver(self, selector: #selector(userProfileImageUpdated(_:)), name: .NCUserProfileImageUpdated, object: nil) + notificationCenter.addObserver(self, selector: #selector(appWillEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(appWillResignActive(_:)), name: UIApplication.willResignActiveNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(roomCreated(_:)), name: .NCRoomCreated, object: nil) + notificationCenter.addObserver(self, selector: #selector(activeAccountDidChange(_:)), name: .NCSettingsControllerDidChangeActiveAccount, object: nil) + notificationCenter.addObserver(self, selector: #selector(pendingInvitationsDidUpdate(_:)), name: NSNotification.Name(NCDatabaseManagerPendingFederationInvitationsDidChange), object: nil) + 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) + } + + private func configureFilterButtonInToolbar() { + if #available(iOS 26, *) { + if UIDevice.current.userInterfaceIdiom == .phone { + let account = NCDatabaseManager.sharedInstance().activeAccount() + + var menuChildren: [UIMenuElement] = [] + menuChildren.append(getFiltersSection(reversed: true)) + if NCSettingsController.sharedInstance().isRoomsSortingSupported(forAccountId: account.accountId) { + menuChildren.append(getGroupModeSection(reversed: true)) + menuChildren.append(getSortOrderSection(reversed: true)) + } + + let menu = UIMenu(title: "", children: menuChildren) + + let filterBarButton = UIBarButtonItem(image: UIImage(systemName: "line.3.horizontal.decrease"), menu: menu) + + if activeFilter != .all { + filterBarButton.style = .prominent + filterBarButton.tintColor = NCAppBranding.elementColor() + } + + self.setToolbarItems([ + self.navigationItem.searchBarPlacementBarButtonItem, + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), + filterBarButton + ], animated: true) + + self.navigationController?.setToolbarHidden(false, animated: true) + } + } + } + + private func setupNavigationBar() { + setNavigationLogoButton() + configureRightBarButtonItems() + createRefreshControl() + + self.navigationItem.searchController = searchController + + if #available(iOS 26.0, *) { + self.tableView.backgroundColor = .clear + + // Set a solid background in collapsed mode, as otherwise we have a weird color transition + // when navigating back in light mode + if self.splitViewController?.isCollapsed == true { + self.view.backgroundColor = .systemBackground + } else { + self.view.backgroundColor = .clear + } + } else { + NCAppBranding.styleViewController(self) + } + } + + private func setNavigationLogoButton() { + let logoImageView = UIImageView(image: NCAppBranding.navigationLogoImage()) + if !customNavigationLogo.boolValue { + logoImageView.tintColor = .label + } + self.navigationItem.titleView = logoImageView + self.navigationItem.titleView?.accessibilityLabel = talkAppName + } + + private func configureRightBarButtonItems() { + var rightItems: [UIBarButtonItem] = [] + + // New conversation button + if NCSettingsController.sharedInstance().canCreateGroupAndPublicRooms() || + NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityListableRooms) { + + let newConversationButton = UIBarButtonItem(image: UIImage(systemName: "plus.circle.fill"), style: .plain, target: self, action: #selector(presentNewRoomViewController)) + newConversationButton.accessibilityLabel = NSLocalizedString("Create or join a conversation", comment: "") + self.newConversationButton = newConversationButton + rightItems.append(newConversationButton) + } + + // Filter and sort button (only when not already in the iOS 26 toolbar menu) + if !hasFilterAndSortMenuInToolbar() { + let filterButton = UIBarButtonItem(image: nil, menu: getFilterAndSortMenu()) + filterButton.image = UIImage(systemName: (activeFilter != .all) ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") + filterButton.accessibilityLabel = NSLocalizedString("Filter and sort conversations", comment: "") + self.filterButton = filterButton + rightItems.append(filterButton) + } else { + configureFilterButtonInToolbar() + } + + // iOS 26 style + if #available(iOS 26.0, *) { + newConversationButton?.tintColor = NCAppBranding.elementColor() + + if UIDevice.current.userInterfaceIdiom != .phone { + // On non-iPhones we want to hide the shared background (glass effect) + for item in rightItems { + item.hidesSharedBackground = true + } + } else { + // On iPhones we want to have a prominent glass button with non-filled icon + newConversationButton?.image = UIImage(systemName: "plus") + newConversationButton?.style = .prominent + } + } + + self.navigationItem.rightBarButtonItems = rightItems + } + + private func hasFilterAndSortMenuInToolbar() -> Bool { + if #available(iOS 26, *) { + return UIDevice.current.userInterfaceIdiom == .phone + } + return false + } + + @objc private func presentNewRoomViewController() { + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + let newRoomVC = NewRoomTableViewController(account: activeAccount) + let navigationController = NCNavigationController(rootViewController: newRoomVC) + self.present(navigationController, animated: true) + } + + deinit { + rlmNotificationToken?.invalidate() + NotificationCenter.default.removeObserver(self) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + adaptInterface(forAppState: NCConnectionController.shared.appState) + adaptInterface(forConnectionState: NCConnectionController.shared.connectionState) + + if NCSettingsController.sharedInstance().isContactSyncEnabled() && NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityPhonebookSearch) { + NCContactsManager.sharedInstance().searchInServer(forAddressBookContacts: false) + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + refreshRoomList() + + self.clearsSelectionOnViewWillAppear = self.splitViewController?.isCollapsed ?? false + + if self.splitViewController?.isCollapsed == true { + self.selectedRoomToken = nil + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + stopRefreshRoomsTimer() + + // Reset deferred-refresh state in case the context menu was dismissed by navigating away + // without a willEndContextMenuInteraction callback, so refreshes aren't skipped indefinitely. + isContextMenuActive = false + pendingRoomListRefresh = false + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + if self.traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + setProfileButton() + setupNavigationBar() + } + } + + // MARK: - Notifications + + @objc private func appStateHasChanged(_ notification: Notification) { + let appStateRaw = (notification.userInfo?["appState"] as? NSNumber)?.intValue ?? 0 + let appState = AppState(rawValue: appStateRaw) ?? .unknown + adaptInterface(forAppState: appState) + } + + @objc private func connectionStateHasChanged(_ notification: Notification) { + let connectionStateRaw = (notification.userInfo?["connectionState"] as? NSNumber)?.intValue ?? 0 + let connectionState = ConnectionState(rawValue: connectionStateRaw) ?? .unknown + adaptInterface(forConnectionState: connectionState) + } + + @objc private func roomsDidUpdate(_ notification: Notification) { + if let error = notification.userInfo?["error"] as? OcsError { + NSLog("Error while trying to get rooms: %@", error.description) + if error.underlyingError.code == NSURLErrorServerCertificateUntrusted { + NSLog("Untrusted certificate") + DispatchQueue.main.async { + CCCertificate.sharedManager().presentViewControllerCertificate(withTitle: error.underlyingError.localizedDescription, viewController: self, delegate: self) + } + } + } + + roomRefreshControl?.endRefreshing() + } + + @objc private func pendingInvitationsDidUpdate(_ notification: Notification) { + refreshRoomList() + } + + @objc private func inviationDidAccept(_ notification: Notification) { + // We accepted an invitation, so we refresh the rooms from the API to show it directly + refreshRooms() + } + + @objc private func userThreadsUpdated(_ notification: Notification) { + let accountId = notification.userInfo?["accountId"] as? String + let threads = notification.userInfo?["threads"] as? [NCThread] + + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + if activeAccount.accountId == accountId { + self.threads = threads + refreshRoomList() + } + } + + @objc private func userHasThreadsUpdated(_ notification: Notification) { + let accountId = notification.userInfo?["accountId"] as? String + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + if activeAccount.accountId == accountId { + refreshRoomList() + } + } + + @objc private func notificationWillBePresented(_ notification: Notification) { + NCRoomsManager.shared.updateRoomsAndChats(updatingUserStatus: false, onlyLastModified: false, withCompletionBlock: nil) + setUnreadMessageForInactiveAccountsIndicator() + } + + @objc private func serverCapabilitiesUpdated(_ notification: Notification) { + setupNavigationBar() + } + + @objc private func userProfileImageUpdated(_ notification: Notification) { + setProfileButton() + } + + @objc private func appWillEnterForeground(_ notification: Notification) { + if NCConnectionController.shared.appState == .ready { + NCRoomsManager.shared.updateRoomsAndChats(updatingUserStatus: true, onlyLastModified: false, withCompletionBlock: nil) + startRefreshRoomsTimer() + + DispatchQueue.main.async { + // Dispatch to main, otherwise the traitCollection is not updated yet and profile buttons shows wrong style + self.setProfileButton() + self.setUnreadMessageForInactiveAccountsIndicator() + } + } + } + + @objc private func appWillResignActive(_ notification: Notification) { + stopRefreshRoomsTimer() + self.tableView.contextMenuInteraction?.dismissMenu() + } + + @objc private func roomCreated(_ notification: Notification) { + DispatchQueue.main.async { + self.refreshRooms() + let roomToken = notification.userInfo?["token"] as? String + self.selectedRoomToken = roomToken + } + } + + @objc private func activeAccountDidChange(_ notification: Notification) { + DispatchQueue.main.async { + self.activeFilter = .all + self.refreshRoomList() + + // Setup the navigation bar here, otherwise it would only be updated + // when the capabilities were updated, which fails when the server is not reachable. + self.setupNavigationBar() + } + } + + // MARK: - Refresh Timer + + private func startRefreshRoomsTimer() { + stopRefreshRoomsTimer() + refreshRoomsTimer = Timer.scheduledTimer(timeInterval: 30.0, target: self, selector: #selector(refreshRooms), userInfo: nil, repeats: true) + } + + private func stopRefreshRoomsTimer() { + refreshRoomsTimer?.invalidate() + refreshRoomsTimer = nil + } + + @objc private func refreshRooms() { + NCRoomsManager.shared.updateRoomsAndChats(updatingUserStatus: true, onlyLastModified: false, withCompletionBlock: nil) + + if NCConnectionController.shared.connectionState == .connected { + NCRoomsManager.shared.resendOfflineMessagesWithCompletionBlock(nil) + } + + updateUserStatus() + + DispatchQueue.main.async { + // Dispatch to main, otherwise the traitCollection is not updated yet and profile buttons shows wrong style + self.setUnreadMessageForInactiveAccountsIndicator() + } + } + + // MARK: - Refresh Control + + private func createRefreshControl() { + roomRefreshControl = UIRefreshControl() + + if #available(iOS 26.0, *) { + roomRefreshControl.tintColor = .label + } else { + roomRefreshControl.tintColor = NCAppBranding.themeTextColor() + } + + roomRefreshControl.addTarget(self, action: #selector(refreshControlTarget), for: .valueChanged) + self.tableView.refreshControl = roomRefreshControl + } + + private func deleteRefreshControl() { + roomRefreshControl?.endRefreshing() + self.refreshControl = nil + } + + @objc private func refreshControlTarget() { + NCRoomsManager.shared.updateRoomsAndChats(updatingUserStatus: true, onlyLastModified: false, withCompletionBlock: nil) + + updateUserStatus() + + // Actuate `Peek` feedback (weak boom) + AudioServicesPlaySystemSound(1519) + } + + // MARK: - User Status SwiftUI View Delegate + + func userStatusViewDidDisappear() { + updateUserStatus() + } + + // MARK: - Title menu + + private func getActiveAccountMenuOptions() -> UIMenu { + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + let serverCapabilities = NCDatabaseManager.sharedInstance().serverCapabilities(forAccountId: activeAccount.accountId) + + let userStatusDeferred = UIDeferredMenuElement.uncached { [weak self] completion in + guard let self else { + completion([]) + return + } + + if serverCapabilities == nil || !(serverCapabilities?.userStatus ?? false) { + completion([]) + return + } + + NCAPIController.sharedInstance().getUserStatus(forAccount: activeAccount) { userStatus in + guard let userStatus else { + completion([]) + return + } + + let userStatusImage = userStatus.getSFUserStatusIcon() + let vc = UserStatusSwiftUIViewFactory.create(userStatus: userStatus, delegate: self) + + let onlineOption = UIAction(title: userStatus.readableUserStatusOrMessage(), image: userStatusImage, identifier: nil) { _ in + self.present(vc, animated: true) + } + + self.activeUserStatus = userStatus + self.updateProfileButtonImage() + + completion([onlineOption]) + } + } + + return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: [userStatusDeferred]) + } + + private func getInactiveAccountMenuOptions() -> UIDeferredMenuElement { + // We use a deferred action here to always have an up-to-date list of inactive accounts and their notifications + let inactiveAccountMenuDeferred = UIDeferredMenuElement.uncached { [weak self] completion in + guard let self else { + completion([]) + return + } + + var inactiveAccounts: [UIMenuElement] = [] + + for account in NCDatabaseManager.sharedInstance().inactiveAccounts() { + let accountName = account.userDisplayName + var accountImage = NCAPIController.sharedInstance().userProfileImage(forAccount: account, withStyle: self.traitCollection.userInterfaceStyle) + + if var image = accountImage { + image = NCUtils.roundedImage(fromImage: image) + + // Draw a red circle to the image in case we have unread notifications for that account + if account.unreadNotification { + UIGraphicsBeginImageContextWithOptions(CGSize(width: 82, height: 82), false, 3) + let context = UIGraphicsGetCurrentContext() + image.draw(in: CGRect(x: 0, y: 4, width: 78, height: 78)) + context?.saveGState() + + context?.setFillColor(UIColor.systemRed.cgColor) + context?.fillEllipse(in: CGRect(x: 52, y: 0, width: 30, height: 30)) + + image = UIGraphicsGetImageFromCurrentImageContext() ?? image + + UIGraphicsEndImageContext() + } + + accountImage = image + } + + let switchAccountAction = UIAction(title: accountName, image: accountImage, identifier: nil) { _ in + NCSettingsController.sharedInstance().setActiveAccountWithAccountId(account.accountId) + } + + if account.unreadBadgeNumber > 0 { + switchAccountAction.subtitle = String.localizedStringWithFormat(NSLocalizedString("%ld notifications", comment: ""), account.unreadBadgeNumber) + } else { + switchAccountAction.subtitle = account.server.replacingOccurrences(of: "https://", with: "") + } + + inactiveAccounts.append(switchAccountAction) + } + + if !inactiveAccounts.isEmpty { + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + var accountImage = NCAPIController.sharedInstance().userProfileImage(forAccount: activeAccount, withStyle: self.traitCollection.userInterfaceStyle) + if let image = accountImage { + accountImage = NCUtils.roundedImage(fromImage: image) + } + let activeAccountAction = UIAction(title: activeAccount.userDisplayName, image: accountImage, identifier: nil) { _ in } + activeAccountAction.subtitle = activeAccount.server.replacingOccurrences(of: "https://", with: "") + activeAccountAction.state = .on + inactiveAccounts.insert(activeAccountAction, at: 0) + } + + let inactiveAccountsMenu = UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: inactiveAccounts) + if #available(iOS 17.4, *) { + let displayPreferences = UIMenuDisplayPreferences() + displayPreferences.maximumNumberOfTitleLines = 1 + + inactiveAccountsMenu.displayPreferences = displayPreferences + } + + completion([inactiveAccountsMenu]) + } + + return inactiveAccountMenuDeferred + } + + private func updateAccountPickerMenu() { + var accountPickerMenu: [UIMenuElement] = [] + + // When no elements are returned by the deferred menu, the entries / inline-menu will be hidden + accountPickerMenu.append(getActiveAccountMenuOptions()) + accountPickerMenu.append(getInactiveAccountMenuOptions()) + + var optionItems: [UIMenuElement] = [] + + if multiAccountEnabled.boolValue { + let addAccountOption = UIAction(title: NSLocalizedString("Add account", comment: ""), image: UIImage(systemName: "person.crop.circle.badge.plus")?.withTintColor(.secondaryLabel, renderingMode: .alwaysOriginal), identifier: nil) { _ in + NCUserInterfaceController.sharedInstance().presentLoginViewController() + } + + optionItems.append(addAccountOption) + } + + let openSettingsOption = UIAction(title: NSLocalizedString("Settings", comment: ""), image: UIImage(systemName: "gear")?.withTintColor(.secondaryLabel, renderingMode: .alwaysOriginal), identifier: nil) { _ in + NCDatabaseManager.sharedInstance().removeUnreadNotificationForInactiveAccounts() + self.setUnreadMessageForInactiveAccountsIndicator() + AppStoreReviewController.recordAction(AppStoreReviewController.visitAppSettings) + NCUserInterfaceController.sharedInstance().presentSettingsViewController() + } + + optionItems.append(openSettingsOption) + + let optionMenu = UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: optionItems) + + accountPickerMenu.append(optionMenu) + + profileButton.menu = UIMenu(title: "", children: accountPickerMenu) + profileButton.showsMenuAsPrimaryAction = true + } + + // MARK: - Search controller + + func updateSearchResults(for searchController: UISearchController) { + let searchString = self.searchController.searchBar.text + // Do not search for the same term twice (e.g. when the searchbar retrieves back the focus) + if self.searchString == searchString { return } + self.searchString = searchString + // Cancel previous call to search listable rooms and messages + NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(searchListableRoomsAndMessages), object: nil) + + // Search for listable rooms and messages + if let searchString, !searchString.isEmpty { + // Set searchingMessages flag if we are going to search for messages + if NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityUnifiedSearch) { + setLoadMoreButtonHidden(true) + resultTableViewController.searchingMessages = true + } + // Throttle listable rooms and messages search + self.perform(#selector(searchListableRoomsAndMessages), with: nil, afterDelay: 1) + } else { + // Clear search results + setLoadMoreButtonHidden(true) + resultTableViewController.searchingMessages = false + resultTableViewController.clearSearchedResults() + } + + // Filter rooms + filterRooms() + } + + func willDismissSearchController(_ searchController: UISearchController) { + self.searchController.searchBar.text = "" + filterRooms() + } + + private func filterRooms() { + let filteredRooms = filterRooms(with: activeFilter) + + let searchString = searchController.searchBar.text ?? "" + if searchString.isEmpty { + rooms = filteredRooms + calculateLastRoomWithMention() + self.tableView.reloadData() + highlightSelectedRoom() + } else { + resultTableViewController.rooms = filterRooms(filteredRooms, with: searchString) + calculateLastRoomWithMention() + } + + updatePlaceholderView() + } + + @objc private func searchListableRoomsAndMessages() { + let searchString = searchController.searchBar.text + let account = NCDatabaseManager.sharedInstance().activeAccount() + // Search for contacts + resultTableViewController.users = [] + NCAPIController.sharedInstance().getContacts(forAccount: account, forRoom: nil, forGroupRoom: false, withSearchParam: searchString) { [weak self] contactList, error in + guard let self else { return } + if error == nil { + var users = self.usersWithoutOneToOneConversations(contactList ?? []) + if NCSettingsController.sharedInstance().isContactSyncEnabled() && NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityPhonebookSearch) { + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + let addressBookContacts = NCContact.contacts(forAccountId: activeAccount.accountId, contains: nil) + users = NCUser.combineUsersArray(addressBookContacts, withUsersArray: users) + } + self.resultTableViewController.users = users + } + } + // Search for listable rooms + if NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityListableRooms) { + resultTableViewController.listableRooms = [] + NCAPIController.sharedInstance().getListableRooms(forAccount: account, withSerachTerm: searchString) { [weak self] rooms, error in + if error == nil { + self?.resultTableViewController.listableRooms = rooms ?? [] + } + } + } + // Search for messages + if NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityUnifiedSearch) { + unifiedSearchController = NCUnifiedSearchController(account: account, searchTerm: searchString ?? "") + resultTableViewController.messages = [] + searchForMessagesWithCurrentSearchTerm() + } + } + + private func usersWithoutOneToOneConversations(_ users: [NCUser]) -> [NCUser] { + let oneToOnePredicate = NSPredicate(format: "type == %ld", NCRoomType.oneToOne.rawValue) + let oneToOneRooms = (rooms as NSArray).filtered(using: oneToOnePredicate) + let names = (oneToOneRooms as NSArray).value(forKey: "name") as? [Any] ?? [] + let namePredicate = NSPredicate(format: "NOT (userId IN %@)", argumentArray: [names]) + + return (users as NSArray).filtered(using: namePredicate) as? [NCUser] ?? [] + } + + private func searchForMessagesWithCurrentSearchTerm() { + unifiedSearchController?.searchMessages { [weak self] entries in + DispatchQueue.main.async { + guard let self else { return } + self.resultTableViewController.searchingMessages = false + self.resultTableViewController.messages = entries ?? [] + self.setLoadMoreButtonHidden(!(self.unifiedSearchController?.showMore ?? false)) + } + } + } + + private func filterRooms(with filter: RoomsFilter) -> [NCRoom] { + let predicate: NSPredicate + switch filter { + case .unread: + predicate = NSPredicate(format: "isVisible == YES AND unreadMessages > 0 AND isArchived == %@", NSNumber(value: showingArchivedRooms)) + case .mentioned: + predicate = NSPredicate(format: "isVisible == YES AND hasUnreadMention == YES AND isArchived == %@", NSNumber(value: showingArchivedRooms)) + case .event: + predicate = NSPredicate(format: "objectType == 'event' AND isArchived == %@", NSNumber(value: showingArchivedRooms)) + default: + predicate = NSPredicate(format: "isVisible == YES AND isArchived == %@", NSNumber(value: showingArchivedRooms)) + } + + return (allRooms as NSArray).filtered(using: predicate) as? [NCRoom] ?? [] + } + + private func filterRooms(_ rooms: [NCRoom], with searchString: String) -> [NCRoom] { + return (rooms as NSArray).filtered(using: NSPredicate(format: "displayName CONTAINS[c] %@", searchString)) as? [NCRoom] ?? [] + } + + private func setLoadMoreButtonHidden(_ hidden: Bool) { + if !hidden { + let loadMoreButton = UIButton(frame: CGRect(x: 0, y: 0, width: self.tableView.frame.size.width, height: 44)) + loadMoreButton.titleLabel?.font = .systemFont(ofSize: 15) + loadMoreButton.setTitleColor(.systemBlue, for: .normal) + loadMoreButton.setTitle(NSLocalizedString("Load more results", comment: ""), for: .normal) + loadMoreButton.addTarget(self, action: #selector(loadMoreMessagesWithCurrentSearchTerm), for: .touchUpInside) + resultTableViewController.tableView.tableFooterView = loadMoreButton + } else { + resultTableViewController.tableView.tableFooterView = nil + } + } + + @objc private func loadMoreMessagesWithCurrentSearchTerm() { + if let unifiedSearchController, unifiedSearchController.searchTerm == searchController.searchBar.text { + resultTableViewController.showSearchingFooterView() + searchForMessagesWithCurrentSearchTerm() + } + } + + // MARK: - Rooms filter + + private func availableFilters() -> [RoomsFilter] { + return [.all, .unread, .mentioned, .event] + } + + private func filterName(_ filter: RoomsFilter) -> String { + switch filter { + case .all: + return NSLocalizedString("No filter", comment: "'No filter' meaning 'No filter will be applied in conversations list'") + case .unread: + return NSLocalizedString("Unread", comment: "'Unread' meaning 'Unread conversations'") + case .mentioned: + return NSLocalizedString("Mentioned", comment: "'Mentioned' meaning 'Mentioned conversations'") + case .event: + return NSLocalizedString("Meetings", comment: "'Meetings' meaning 'Conversations that were created from a calendar event'") + } + } + + private func filterImage(_ filter: RoomsFilter) -> UIImage? { + switch filter { + case .all: + return UIImage(named: "custom.line.3.horizontal.decrease.slash") + case .unread: + return UIImage(named: "custom.bubble.badge") + case .mentioned: + return UIImage(systemName: "at") + case .event: + return UIImage(systemName: "calendar") + } + } + + private func filterPlaceholderImage(_ filter: RoomsFilter) -> UIImage? { + if filter == .all { + return UIImage(named: "conversations-placeholder") + } + + return filterImage(filter) + } + + private func filterPlaceholderText(_ filter: RoomsFilter) -> String? { + switch filter { + case .all: + return NSLocalizedString("You are not part of any conversation. Press + to start a new one.", comment: "") + case .unread: + return NSLocalizedString("You have no unread messages.", comment: "") + case .mentioned: + return NSLocalizedString("You have no unread mentions.", comment: "") + case .event: + return NSLocalizedString("You have no meetings scheduled.", comment: "") + } + } + + // MARK: - Sort menu + + private func getSortOrderSection(reversed: Bool) -> UIMenu { + let account = NCDatabaseManager.sharedInstance().activeAccount() + let serverCapabilities = NCDatabaseManager.sharedInstance().serverCapabilities(forAccountId: account.accountId) + let currentSort = NCRoomSortOrder(rawValue: serverCapabilities?.roomsSortOrder ?? 0) ?? .activity + + let byActivity = UIAction(title: NSLocalizedString("By activity", comment: "Sort conversations by recent activity"), image: UIImage(systemName: "clock"), identifier: nil) { [weak self] _ in + self?.applySortOrder(.activity) + } + byActivity.state = (currentSort == .activity) ? .on : .off + + let alphabetically = UIAction(title: NSLocalizedString("Alphabetically", comment: "Sort conversations alphabetically"), image: UIImage(systemName: "character.square"), identifier: nil) { [weak self] _ in + self?.applySortOrder(.alphabetical) + } + alphabetically.state = (currentSort == .alphabetical) ? .on : .off + + let children: [UIMenuElement] = reversed ? [alphabetically, byActivity] : [byActivity, alphabetically] + + return UIMenu(title: NSLocalizedString("Sort conversations", comment: "Title for conversations sorting options"), image: nil, identifier: nil, options: .displayInline, children: children) + } + + private func getGroupModeSection(reversed: Bool) -> UIMenu { + let account = NCDatabaseManager.sharedInstance().activeAccount() + let serverCapabilities = NCDatabaseManager.sharedInstance().serverCapabilities(forAccountId: account.accountId) + let currentGroup = NCRoomGroupMode(rawValue: serverCapabilities?.roomsGroupMode ?? 0) ?? .none + + let noGrouping = UIAction(title: NSLocalizedString("No grouping", comment: "Do not group conversations by type"), image: UIImage(systemName: "list.bullet"), identifier: nil) { [weak self] _ in + self?.applyGroupMode(.none) + } + noGrouping.state = (currentGroup == .none) ? .on : .off + + let privateFirst = UIAction(title: NSLocalizedString("Private first", comment: "Show private conversations before group ones"), image: UIImage(systemName: "person"), identifier: nil) { [weak self] _ in + self?.applyGroupMode(.privateFirst) + } + privateFirst.state = (currentGroup == .privateFirst) ? .on : .off + + let groupFirst = UIAction(title: NSLocalizedString("Group first", comment: "Show group conversations before private ones"), image: UIImage(systemName: "person.2"), identifier: nil) { [weak self] _ in + self?.applyGroupMode(.groupFirst) + } + groupFirst.state = (currentGroup == .groupFirst) ? .on : .off + + let children: [UIMenuElement] = reversed ? [groupFirst, privateFirst, noGrouping] : [noGrouping, privateFirst, groupFirst] + + return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) + } + + private func getFiltersSection(reversed: Bool) -> UIMenu { + var filterActions: [UIAction] = [] + + for filterValue in availableFilters() { + let action = UIAction(title: filterName(filterValue), image: filterImage(filterValue), identifier: nil) { [weak self] _ in + guard let self else { return } + + self.activeFilter = filterValue + self.filterRooms() + self.configureRightBarButtonItems() + self.updateMentionsIndicator() + } + + action.state = (filterValue == activeFilter) ? .on : .off + filterActions.append(action) + } + + let children: [UIMenuElement] = reversed ? filterActions.reversed() : filterActions + + return UIMenu(title: NSLocalizedString("Filters", comment: "Title for available conversations filters"), image: nil, identifier: nil, options: .displayInline, children: children) + } + + private func getFilterAndSortMenu() -> UIMenu { + let account = NCDatabaseManager.sharedInstance().activeAccount() + var children: [UIMenuElement] = [] + + if NCSettingsController.sharedInstance().isRoomsSortingSupported(forAccountId: account.accountId) { + children.append(getSortOrderSection(reversed: false)) + children.append(getGroupModeSection(reversed: false)) + } + + children.append(getFiltersSection(reversed: false)) + + return UIMenu(title: "", children: children) + } + + private func applySortOrder(_ sortOrder: NCRoomSortOrder) { + let account = NCDatabaseManager.sharedInstance().activeAccount() + + Task { + let success = await NCAPIController.sharedInstance().setRoomSortOrder(sortOrder, forAccount: account) + if success { + NCSettingsController.sharedInstance().getCapabilitiesForAccountId(account.accountId) { _ in + DispatchQueue.main.async { + self.refreshRoomList() + self.configureRightBarButtonItems() + } + } + } + } + } + + private func applyGroupMode(_ groupMode: NCRoomGroupMode) { + let account = NCDatabaseManager.sharedInstance().activeAccount() + + Task { + let success = await NCAPIController.sharedInstance().setRoomGroupMode(groupMode, forAccount: account) + if success { + NCSettingsController.sharedInstance().getCapabilitiesForAccountId(account.accountId) { _ in + DispatchQueue.main.async { + self.refreshRoomList() + self.configureRightBarButtonItems() + } + } + } + } + } + + // MARK: - User Interface + + @objc func refreshRoomList() { + // Don't reload while a context menu is open, as that would detach the preview from its cell. + // The refresh is applied once the context menu interaction ends. + if isContextMenuActive { + pendingRoomListRefresh = true + return + } + + let account = NCDatabaseManager.sharedInstance().activeAccount() + let accountRooms = NCDatabaseManager.sharedInstance().roomsForAccountId(account.accountId, withRealm: nil) + allRooms = accountRooms + rooms = accountRooms + + // Filter rooms + filterRooms() + + // Update placeholder view + updatePlaceholderView() + + // Reload room list + self.tableView.reloadData() + + // Update unread mentions indicator + updateMentionsIndicator() + + highlightSelectedRoom() + } + + private func updatePlaceholderView() { + roomsBackgroundView.loadingView.stopAnimating() + roomsBackgroundView.loadingView.isHidden = true + + roomsBackgroundView.setImage(filterPlaceholderImage(activeFilter)) + roomsBackgroundView.placeholderTextView.text = filterPlaceholderText(activeFilter) + roomsBackgroundView.placeholderView.isHidden = !rooms.isEmpty + } + + private func adaptInterface(forAppState appState: AppState) { + switch appState { + case .noServerProvided, .missingUserProfile, .missingServerCapabilities, .missingSignalingConfiguration: + // Clear active user status and threads when changing users + activeUserStatus = nil + threads = nil + setProfileButton() + case .ready: + setProfileButton() + let isAppActive = UIApplication.shared.applicationState == .active + NCRoomsManager.shared.updateRooms(updatingUserStatus: isAppActive, onlyLastModified: false) + updateUserStatus() + getUserThreads() + startRefreshRoomsTimer() + setupNavigationBar() + default: + break + } + } + + private func adaptInterface(forConnectionState connectionState: ConnectionState) { + switch connectionState { + case .connected: + setOnlineAppearance() + case .disconnected: + setOfflineAppearance() + default: + break + } + } + + private func setOfflineAppearance() { + newConversationButton?.isEnabled = false + } + + private func setOnlineAppearance() { + newConversationButton?.isEnabled = true + } + + // MARK: - UIScrollViewDelegate Methods + + override func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollView == self.tableView { + updateMentionsIndicator() + } + } + + override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + if scrollView == self.tableView { + updateMentionsIndicator() + } + } + + override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if scrollView == self.tableView { + updateMentionsIndicator() + } + } + + override func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + if scrollView == self.tableView { + updateMentionsIndicator() + } + } + + // MARK: - Mentions + + private func updateMentionsIndicator() { + let visibleRows = self.tableView.indexPathsForVisibleRows ?? [] + let lastVisibleRowIndexPath = visibleRows.last + unreadMentionsBottomButton.isHidden = true + + // Calculate index of first room with a mention outside visible cells + nextRoomWithMentionIndexPath = nil + + guard let lastRoomWithMentionIndexPath else { + return + } + + var i = lastVisibleRowIndexPath?.row ?? 0 + while i <= lastRoomWithMentionIndexPath.row && i < rooms.count { + let room = rooms[i] + if room.hasUnreadMention { + nextRoomWithMentionIndexPath = IndexPath(row: i, section: RoomsSection.roomList.rawValue) + break + } + i += 1 + } + + // Update unread mentions indicator visibility + unreadMentionsBottomButton.isHidden = visibleRows.contains(lastRoomWithMentionIndexPath) || (lastVisibleRowIndexPath?.row ?? 0) > lastRoomWithMentionIndexPath.row + + // Make sure the style is adjusted to current accounts theme + unreadMentionsBottomButton.backgroundColor = NCAppBranding.themeColor() + unreadMentionsBottomButton.setTitleColor(NCAppBranding.themeTextColor(), for: .normal) + } + + @objc private func unreadMentionsBottomButtonPressed(_ sender: Any) { + if let nextRoomWithMentionIndexPath { + self.tableView.scrollToRow(at: nextRoomWithMentionIndexPath, at: .middle, animated: true) + } + } + + private func calculateLastRoomWithMention() { + lastRoomWithMentionIndexPath = nil + for i in 0.. 0 { + if #available(iOS 26.0, *) { + settingsButton.badge = .count(inactiveUnreadCount) + } else { + settingsButton.legacyBadgeValue = "\(inactiveUnreadCount)" + } + } + } + + // MARK: - Threads + + private func getUserThreads() { + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + let currentTimestamp = Int(Date().timeIntervalSince1970) + + // Check if user has threads on app fresh launch or if last check was over 2 hours ago + if (currentTimestamp - activeAccount.threadsLastCheckTimestamp) > (2 * 60 * 60) { + NCAPIController.sharedInstance().getSubscribedThreads(for: activeAccount.accountId, withLimit: 100, andOffset: 0) { _, error in + if let error { + NSLog("Error getting user threads: %@", error.localizedDescription) + } + } + } + } + + // MARK: - CCCertificateDelegate + + func trustedCerticateAccepted() { + NCRoomsManager.shared.updateRooms(updatingUserStatus: false, onlyLastModified: false) + } + + // MARK: - Room actions + + private func actionForNotificationLevel(_ level: NCRoomNotificationLevel, forRoom room: NCRoom) -> UIAction { + let notificationAction = UIAction(title: NCRoom.stringFor(notificationLevel: level), image: nil, identifier: nil) { _ in + if level == room.notificationLevel { + return + } + Task { @MainActor in + let success = await NCAPIController.sharedInstance().setNotificationLevel(level: level, forRoom: room.token, forAccount: NCDatabaseManager.sharedInstance().activeAccount()) + if success { + NotificationPresenter.shared().present(text: NSLocalizedString("Updated notification settings", comment: ""), dismissAfterDelay: 5.0, includedStyle: .success) + } else { + NSLog("Error setting notification level") + } + + NCRoomsManager.shared.updateRooms(updatingUserStatus: true, onlyLastModified: false) + } + } + + if room.notificationLevel == level { + notificationAction.state = .on + } + + return notificationAction + } + + private func shareLink(fromRoom room: NCRoom) { + if let indexPath = indexPath(for: room) { + NCUserInterfaceController.sharedInstance().presentShareLinkDialog(for: room, inViewContoller: self, for: indexPath) + } + } + + private func archiveRoom(_ room: NCRoom) { + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + + NCAPIController.sharedInstance().archiveRoom(room.token, forAccount: activeAccount) { success in + if !success { + NSLog("Error archiving room") + } + + NCRoomsManager.shared.updateRooms(updatingUserStatus: true, onlyLastModified: false) + } + } + + private func unarchiveRoom(_ room: NCRoom) { + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + + NCAPIController.sharedInstance().unarchiveRoom(room.token, forAccount: activeAccount) { success in + if !success { + NSLog("Error unarchiving room") + } + + NCRoomsManager.shared.updateRooms(updatingUserStatus: true, onlyLastModified: false) + } + } + + private func markRoomAsRead(_ room: NCRoom) { + NCAPIController.sharedInstance().setChatReadMarker(room.lastMessage?.messageId ?? 0, inRoom: room.token, forAccount: NCDatabaseManager.sharedInstance().activeAccount()) { error in + if let error { + NSLog("Error marking room as read: %@", error.description) + } + NCRoomsManager.shared.updateRooms(updatingUserStatus: true, onlyLastModified: false) + } + } + + private func markRoomAsUnread(_ room: NCRoom) { + NCAPIController.sharedInstance().markChatAsUnread(inRoom: room.token, forAccount: NCDatabaseManager.sharedInstance().activeAccount()) { error in + if let error { + NSLog("Error marking chat as unread: %@", error.description) + } + NCRoomsManager.shared.updateRooms(updatingUserStatus: true, onlyLastModified: false) + } + } + + private func addRoomToFavorites(_ room: NCRoom) { + NCAPIController.sharedInstance().addRoomToFavorites(room.token, forAccount: NCDatabaseManager.sharedInstance().activeAccount()) { error in + if let error { + NSLog("Error adding room to favorites: %@", error.description) + } + NCRoomsManager.shared.updateRooms(updatingUserStatus: true, onlyLastModified: false) + } + } + + private func removeRoomFromFavorites(_ room: NCRoom) { + NCAPIController.sharedInstance().removeRoomFromFavorites(room.token, forAccount: NCDatabaseManager.sharedInstance().activeAccount()) { error in + if let error { + NSLog("Error removing room from favorites: %@", error.description) + } + NCRoomsManager.shared.updateRooms(updatingUserStatus: true, onlyLastModified: false) + } + } + + private func presentRoomInfo(forRoom room: NCRoom) { + let roomInfoVC = RoomInfoUIViewFactory.create(room: room, showDestructiveActions: true, scrollToParticipantsSectionOnAppear: false) + let navigationController = NCNavigationController(rootViewController: roomInfoVC) + + let cancelAction = UIAction { _ in + roomInfoVC.dismiss(animated: true) + } + + let cancelButton = UIBarButtonItem(systemItem: .cancel, primaryAction: cancelAction) + navigationController.navigationBar.topItem?.leftBarButtonItem = cancelButton + + self.present(navigationController, animated: true) + } + + private func leaveRoom(_ room: NCRoom) { + let confirmDialog = UIAlertController(title: NSLocalizedString("Leave conversation", comment: ""), + message: NSLocalizedString("Once a conversation is left, to rejoin a closed conversation, an invite is needed. An open conversation can be rejoined at any time.", comment: ""), + preferredStyle: .alert) + let confirmAction = UIAlertAction(title: NSLocalizedString("Leave", comment: ""), style: .destructive) { _ in + NCUserInterfaceController.sharedInstance().presentConversationsList() + + if let indexPath = self.indexPath(for: room) { + self.rooms.remove(at: indexPath.row) + self.tableView.deleteRows(at: [indexPath], with: .fade) + } + + Task { @MainActor in + do { + _ = try await NCAPIController.sharedInstance().removeSelf(fromRoom: room.token, forAccount: NCDatabaseManager.sharedInstance().activeAccount()) + } catch let ocsError as OcsError { + if ocsError.responseStatusCode == 400 { + self.showLeaveRoomLastModeratorError(forRoom: room) + } else { + NSLog("Error leaving room: %@", ocsError.description) + } + } catch { + NSLog("Error leaving room: %@", error.localizedDescription) + } + + NCRoomsManager.shared.updateRooms(updatingUserStatus: true, onlyLastModified: false) + } + } + confirmDialog.addAction(confirmAction) + let cancelAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel, handler: nil) + confirmDialog.addAction(cancelAction) + self.present(confirmDialog, animated: true) + } + + private func deleteRoom(_ room: NCRoom) { + NCRoomsManager.shared.deleteRoom(withConfirmation: room, withStartedBlock: { + if let indexPath = self.indexPath(for: room) { + self.rooms.remove(at: indexPath.row) + self.tableView.deleteRows(at: [indexPath], with: .fade) + } + }, withFinishedBlock: nil) + } + + private func presentChatForRoom(at indexPath: IndexPath) { + guard let room = room(for: indexPath) else { return } + let currentChatViewController = NCRoomsManager.shared.chatViewController + + // When a room is selected, that is currently displayed, leave that room and optionally show the placeholder view again + if let currentChatViewController, room.token == currentChatViewController.room.token { + currentChatViewController.leaveChat() + NCUserInterfaceController.sharedInstance().mainViewController.showPlaceholderView() + + return + } + + NCRoomsManager.shared.startChat(inRoom: room) + } + + // MARK: - Utils + + private func room(for indexPath: IndexPath) -> NCRoom? { + if searchController.isActive && !resultTableViewController.view.isHidden { + return resultTableViewController.room(for: indexPath) + } else if indexPath.row < rooms.count { + return rooms[indexPath.row] + } + + return nil + } + + private func indexPath(for room: NCRoom) -> IndexPath? { + if let idx = rooms.firstIndex(where: { $0.internalId == room.internalId }) { + return IndexPath(row: idx, section: RoomsSection.roomList.rawValue) + } + + return nil + } + + private func archivedRooms() -> [NCRoom] { + return (allRooms as NSArray).filtered(using: NSPredicate(format: "isArchived == YES")) as? [NCRoom] ?? [] + } + + private func areArchivedRoomsWithUnreadMentions() -> Bool { + return !(allRooms as NSArray).filtered(using: NSPredicate(format: "hasUnreadMention == YES AND isArchived == YES")).isEmpty + } + + private func showLeaveRoomLastModeratorError(forRoom room: NCRoom) { + let leaveRoomFailedDialog = UIAlertController(title: NSLocalizedString("Could not leave conversation", comment: ""), + message: String(format: NSLocalizedString("You need to promote a new moderator before you can leave %@.", comment: ""), room.displayName), + preferredStyle: .alert) + + let okAction = UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default, handler: nil) + leaveRoomFailedDialog.addAction(okAction) + + self.present(leaveRoomFailedDialog, animated: true) + } + + // MARK: - Search results + + private func presentSelectedMessageInChat(_ message: NKSearchEntry) { + let roomToken = message.attributes?["conversation"] as? String + let messageIdString = message.attributes?["messageId"] as? String + let threadIdString = message.attributes?["threadId"] as? String + if let roomToken, let messageIdString { + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + let messageId = (messageIdString as NSString).integerValue + let room = NCDatabaseManager.sharedInstance().room(withToken: roomToken, forAccountId: activeAccount.accountId) + let threadId = (threadIdString as NSString?)?.integerValue ?? 0 + let thread = NCThread(threadId: threadId, inRoom: roomToken, forAccountId: activeAccount.accountId) + if let room { + presentContextChat(inRoom: room, inThread: thread, forMessageId: messageId) + } else { + NCAPIController.sharedInstance().getRoom(forAccount: activeAccount, withToken: roomToken) { [weak self] roomDict, error in + if error == nil { + if let room = NCRoom(dictionary: roomDict, andAccountId: activeAccount.accountId) { + self?.presentContextChat(inRoom: room, inThread: thread, forMessageId: messageId) + } + } else { + let errorMessage = NSLocalizedString("Unable to get conversation of the message", comment: "") + NotificationPresenter.shared().present(text: errorMessage, dismissAfterDelay: 5.0, includedStyle: .dark) + } + } + } + } + } + + private func presentContextChat(inRoom room: NCRoom, inThread thread: NCThread?, forMessageId messageId: Int) { + guard let account = room.account else { + return + } + + guard let contextChatViewController = ContextChatViewController(forRoom: room, withAccount: account, withMessage: [], withHighlightId: 0) else { + return + } + contextChatViewController.thread = thread + contextChatViewController.showContext(ofMessageId: messageId, withLimit: 50, withCloseButton: true) + + let contextChatNavigationController = NCNavigationController(rootViewController: contextChatViewController) + self.contextChatNavigationController = contextChatNavigationController + self.present(contextChatNavigationController, animated: true) + } + + private func createRoom(forSelectedUser user: NCUser) { + NCAPIController.sharedInstance().createRoom(forAccount: NCDatabaseManager.sharedInstance().activeAccount(), withInvite: user.userId, ofType: .oneToOne, andName: nil) { [weak self] room, error in + if error == nil, let token = room?.token { + self?.navigationController?.dismiss(animated: true) { + NotificationCenter.default.post(name: .NCSelectedUserForChat, object: self, userInfo: ["token": token]) + } + } + + self?.searchController.isActive = false + } + } + + // MARK: - Table view data source + + override func numberOfSections(in tableView: UITableView) -> Int { + return RoomsSection.allCases.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + if section == RoomsSection.pendingFederationInvitation.rawValue { + let account = NCDatabaseManager.sharedInstance().activeAccount() + 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 + return nil + } + + guard let room = room(for: indexPath) else { + return nil + } + + // Do not show swipe actions for open conversations or messages + if tableView == resultTableViewController.tableView && room.listable != .participantsOnly { + return nil + } + + var deleteAction = UIContextualAction(style: .destructive, title: nil) { _, _, completionHandler in + self.deleteRoom(room) + completionHandler(false) + } + deleteAction.image = UIImage(systemName: "trash") + + if room.canLeaveConversation { + deleteAction = UIContextualAction(style: .destructive, title: nil) { _, _, completionHandler in + self.leaveRoom(room) + completionHandler(false) + } + deleteAction.image = UIImage(systemName: "arrow.right.square") + } + + return UISwipeActionsConfiguration(actions: [deleteAction]) + } + + 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 + return nil + } + + guard let room = room(for: indexPath) else { + return nil + } + + // Do not show swipe actions for open conversations or messages + if tableView == resultTableViewController.tableView && room.listable != .participantsOnly { + return nil + } + + // Add/Remove room to/from favorites + let favoriteAction = UIContextualAction(style: .normal, title: nil) { _, _, completionHandler in + if room.isFavorite { + self.removeRoomFromFavorites(room) + } else { + self.addRoomToFavorites(room) + } + completionHandler(true) + } + let favImageName = room.isFavorite ? "star" : "star.fill" + favoriteAction.image = UIImage(systemName: favImageName) + favoriteAction.backgroundColor = UIColor(red: 0.97, green: 0.80, blue: 0.27, alpha: 1.0) // Favorite yellow + + // Mark room as read/unread + if NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityChatReadMarker) && + (!room.isFederated || NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityChatReadLast)) { + + let markReadAction = UIContextualAction(style: .normal, title: nil) { _, _, completionHandler in + if room.unreadMessages > 0 { + self.markRoomAsRead(room) + } else { + self.markRoomAsUnread(room) + } + completionHandler(true) + } + + markReadAction.image = (room.unreadMessages > 0) ? UIImage(systemName: "checkmark.bubble") : UIImage(named: "custom.bubble.badge") + markReadAction.backgroundColor = .systemBlue + + return UISwipeActionsConfiguration(actions: [markReadAction, favoriteAction]) + } + + return UISwipeActionsConfiguration(actions: [favoriteAction]) + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if indexPath.section == RoomsSection.pendingFederationInvitation.rawValue { + let cell = tableView.dequeueReusableCell(withIdentifier: InfoLabelTableViewCell.identifier) as? InfoLabelTableViewCell ?? InfoLabelTableViewCell(style: .default, reuseIdentifier: InfoLabelTableViewCell.identifier) + + // Pending federation invitations + let account = NCDatabaseManager.sharedInstance().activeAccount() + + let pendingInvitationsString = String.localizedStringWithFormat(NSLocalizedString("You have %ld pending invitations", comment: ""), account.pendingFederationInvitations) + let resultFont = UIFont.preferredFont(forTextStyle: .headline) + + let pendingInvitationsAttachment = NSTextAttachment() + pendingInvitationsAttachment.image = UIImage(named: "pending-federation-invitations") + pendingInvitationsAttachment.bounds = CGRect(x: 0, y: CGFloat(roundf(Float(resultFont.capHeight) - 20)) / 2, width: 20, height: 20) + + let resultString = NSMutableAttributedString(attributedString: NSAttributedString(attachment: pendingInvitationsAttachment)) + resultString.append(NSAttributedString(string: " ")) + resultString.append(NSAttributedString(string: pendingInvitationsString)) + + let range = NSRange(location: 0, length: resultString.length) + resultString.addAttribute(.font, value: UIFont.preferredFont(forTextStyle: .headline), range: range) + + cell.label.attributedText = resultString + + 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 + + let room = rooms[indexPath.row] + + // Set room name + cell.titleLabel.text = room.displayName + + // Set last activity + if room.lastMessageId != nil || room.lastMessageProxiedJSONString != nil { + cell.titleOnly = false + cell.subtitleLabel.attributedText = room.lastMessageString + } else { + cell.titleOnly = true + cell.subtitleLabel.text = "" + } + let date = Date(timeIntervalSince1970: TimeInterval(room.lastActivity)) + cell.dateLabel.text = NCUtils.readableTimeOrDate(fromDate: date) + + // Event conversation handling + if room.isFutureEvent { + cell.titleOnly = false + cell.subtitleLabel.text = room.eventStartString + cell.dateLabel.text = "" + } + + // Set unread messages + if NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityDirectMentionFlag) { + 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) + } + + if room.unreadMessages > 0 { + // When there are unread messages, we need to show the subtitle at the moment + cell.titleOnly = false + } + + cell.avatarView.setAvatar(for: room) + + // Set favorite or call image + if room.hasCall { + cell.avatarView.favoriteImageView.tintColor = .systemRed + cell.avatarView.favoriteImageView.image = UIImage(systemName: "video.fill") + } else if room.isFavorite { + cell.avatarView.favoriteImageView.tintColor = .systemYellow + cell.avatarView.favoriteImageView.image = UIImage(systemName: "star.fill") + } + + cell.roomToken = room.token + + return cell + } + + override func tableView(_ tableView: UITableView, didHighlightRowAt indexPath: IndexPath) { + let cell = tableView.cellForRow(at: indexPath) as? RoomTableViewCell + cell?.isSelected = true + } + + override func tableView(_ tableView: UITableView, didUnhighlightRowAt indexPath: IndexPath) { + let cell = tableView.cellForRow(at: indexPath) as? RoomTableViewCell + cell?.isSelected = false + } + + 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 { + return + } + + guard let cell = rcell as? RoomTableViewCell else { return } + let room = rooms[indexPath.row] + + cell.avatarView.setStatus(for: room, allowCustomStatusIcon: true) + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let isAppInForeground = UIApplication.shared.applicationState == .active + + if !isAppInForeground { + // In case we are not in the active state, we don't want to invoke any navigation event as this might + // lead to crashes, when the wrong NavBar is referenced + return + } + + if self.navigationController?.transitionCoordinator != nil { + // In case we are currently in a transition (e.g. swipe back from a conversation), + // we don't want to present any new view controller, as that leads to crashes on iOS >= 26 + removeRoomSelection() + return + } + + if tableView == self.tableView && indexPath.section == RoomsSection.pendingFederationInvitation.rawValue { + let federationInvitationVC = FederationInvitationTableViewController() + let navigationController = NCNavigationController(rootViewController: federationInvitationVC) + self.present(navigationController, animated: true) + + 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) { + presentSelectedMessageInChat(message) + return + } + + // Users + if let user = resultTableViewController.user(for: indexPath) { + createRoom(forSelectedUser: user) + return + } + } + + // Present room chat + removeRoomSelection() + presentChatForRoom(at: indexPath) + } + + // 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 { + return nil + } + + guard let room = room(for: indexPath) else { return nil } + var actions: [UIMenuElement] = [] + + let favImageName = room.isFavorite ? "star.slash" : "star" + let favImage = UIImage(systemName: favImageName)?.withTintColor(.systemYellow, renderingMode: .alwaysOriginal) + let favActionName = room.isFavorite ? NSLocalizedString("Remove from favorites", comment: "") : NSLocalizedString("Add to favorites", comment: "") + let favAction = UIAction(title: favActionName, image: favImage, identifier: nil) { [weak self] _ in + self?.contextMenuActionBlock = { + if room.isFavorite { + self?.removeRoomFromFavorites(room) + } else { + self?.addRoomToFavorites(room) + } + } + } + + actions.append(favAction) + + // Mark room as read/unread + if NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityChatReadMarker) && + (!room.isFederated || NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityChatReadLast)) { + if room.unreadMessages > 0 { + // Mark room as read + let markReadAction = UIAction(title: NSLocalizedString("Mark as read", comment: ""), image: UIImage(systemName: "checkmark.bubble"), identifier: nil) { [weak self] _ in + self?.contextMenuActionBlock = { + self?.markRoomAsRead(room) + } + } + + actions.append(markReadAction) + } else if NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityChatUnread) { + // Mark room as unread + let markUnreadAction = UIAction(title: NSLocalizedString("Mark as unread", comment: ""), image: UIImage(named: "custom.bubble.badge"), identifier: nil) { [weak self] _ in + self?.contextMenuActionBlock = { + self?.markRoomAsUnread(room) + } + } + + actions.append(markUnreadAction) + } + } + + // Notification levels + if NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityNotificationLevels) && + room.type != .changelog && room.type != .noteToSelf { + + var notificationActions: [UIMenuElement] = [] + + // Chat notification settings + notificationActions.append(actionForNotificationLevel(.always, forRoom: room)) + notificationActions.append(actionForNotificationLevel(.mention, forRoom: room)) + notificationActions.append(actionForNotificationLevel(.never, forRoom: room)) + + // Call notification + if NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityNotificationCalls, for: room) && room.supportsCalling { + let callNotificationAction = UIAction(title: NSLocalizedString("Notify about calls", comment: ""), image: nil, identifier: nil) { action in + let newState = !(action.state == .on) + + Task { @MainActor in + let success = await NCAPIController.sharedInstance().setCallNotificationLevel(enabled: newState, forRoom: room.token, forAccount: NCDatabaseManager.sharedInstance().activeAccount()) + if success { + NotificationPresenter.shared().present(text: NSLocalizedString("Updated notification settings", comment: ""), dismissAfterDelay: 5.0, includedStyle: .success) + } else { + NSLog("Error setting call notification") + } + + NCRoomsManager.shared.updateRooms(updatingUserStatus: true, onlyLastModified: false) + } + } + + if room.notificationCalls { + callNotificationAction.state = .on + } + + let callNotificationMenu = UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: [callNotificationAction]) + notificationActions.append(callNotificationMenu) + } + + // Important conversation + if NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityImportantConversations) { + let importantConversationAction = UIAction(title: NSLocalizedString("Important conversation", comment: ""), image: nil, identifier: nil) { action in + let newState = !(action.state == .on) + + Task { @MainActor in + do { + _ = try await NCAPIController.sharedInstance().setImportantState(enabled: newState, forRoom: room.token, forAccount: NCDatabaseManager.sharedInstance().activeAccount()) + NotificationPresenter.shared().present(text: NSLocalizedString("Updated notification settings", comment: ""), dismissAfterDelay: 5.0, includedStyle: .success) + } catch { + NSLog("Error setting call notification: %@", error.localizedDescription) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NCRoomsManager.shared.updateRooms(updatingUserStatus: true, onlyLastModified: false) + } + } + } + + importantConversationAction.subtitle = NSLocalizedString("'Do not disturb' user status is ignored for important conversations", comment: "") + + if room.isImportant { + importantConversationAction.state = .on + } + + let importantConversationMenu = UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: [importantConversationAction]) + notificationActions.append(importantConversationMenu) + } + + // Sensitive conversation + if NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilitySensitiveConversations) { + let sensitiveConversationAction = UIAction(title: NSLocalizedString("Sensitive conversation", comment: ""), image: nil, identifier: nil) { action in + let newState = !(action.state == .on) + + Task { @MainActor in + do { + _ = try await NCAPIController.sharedInstance().setSensitiveState(enabled: newState, forRoom: room.token, forAccount: NCDatabaseManager.sharedInstance().activeAccount()) + NotificationPresenter.shared().present(text: NSLocalizedString("Updated notification settings", comment: ""), dismissAfterDelay: 5.0, includedStyle: .success) + } catch { + NSLog("Error setting call notification: %@", error.localizedDescription) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NCRoomsManager.shared.updateRooms(updatingUserStatus: true, onlyLastModified: false) + } + } + } + + sensitiveConversationAction.subtitle = NSLocalizedString("Message preview will be disabled in conversation list and notifications", comment: "") + + if room.isSensitive { + sensitiveConversationAction.state = .on + } + + let sensitiveConversationMenu = UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: [sensitiveConversationAction]) + notificationActions.append(sensitiveConversationMenu) + } + + let notificationMenu = UIMenu(title: NSLocalizedString("Notifications", comment: ""), image: UIImage(systemName: "bell"), identifier: nil, options: [], children: notificationActions) + + actions.append(notificationMenu) + } + + // Share link + if room.type != .changelog && room.type != .noteToSelf { + let shareLinkAction = UIAction(title: NSLocalizedString("Share link", comment: ""), image: UIImage(systemName: "square.and.arrow.up"), identifier: nil) { [weak self] _ in + self?.shareLink(fromRoom: room) + } + + actions.append(shareLinkAction) + } + + // Archive conversation + if NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityArchivedConversationsV2) { + if room.isArchived { + let unarchiveAction = UIAction(title: NSLocalizedString("Unarchive conversation", comment: ""), image: UIImage(systemName: "arrow.up.bin"), identifier: nil) { [weak self] _ in + self?.unarchiveRoom(room) + } + + actions.append(unarchiveAction) + } else { + let archiveAction = UIAction(title: NSLocalizedString("Archive conversation", comment: ""), image: UIImage(systemName: "archivebox"), identifier: nil) { [weak self] _ in + self?.archiveRoom(room) + } + + actions.append(archiveAction) + } + } + + // Room info + let roomInfoAction = UIAction(title: NSLocalizedString("Conversation settings", comment: ""), image: UIImage(systemName: "gearshape"), identifier: nil) { [weak self] _ in + self?.presentRoomInfo(forRoom: room) + } + + actions.append(roomInfoAction) + + var destructiveActions: [UIMenuElement] = [] + + if room.canLeaveConversation { + let leaveAction = UIAction(title: NSLocalizedString("Leave conversation", comment: ""), image: UIImage(systemName: "arrow.right.square"), identifier: nil) { [weak self] _ in + self?.leaveRoom(room) + } + + leaveAction.attributes = .destructive + destructiveActions.append(leaveAction) + } + + if room.canDeleteConversation { + let deleteAction = UIAction(title: NSLocalizedString("Delete conversation", comment: ""), image: UIImage(systemName: "trash"), identifier: nil) { [weak self] _ in + self?.deleteRoom(room) + } + + deleteAction.attributes = .destructive + destructiveActions.append(deleteAction) + } + + if !destructiveActions.isEmpty { + let deleteMenu = UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: destructiveActions) + + actions.append(deleteMenu) + } + + let menu = UIMenu(title: "", children: actions) + + let configuration = UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: { () -> UIViewController? in + return nil + }, actionProvider: { _ -> UIMenu? in + return menu + }) + + return configuration + } + + override func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + if tableView != self.tableView { + return nil + } + + guard let indexPath = configuration.identifier as? IndexPath else { return nil } + + // Use a snapshot and a new cell (from dataSource) here to not interfere with room refresh + guard let cell = self.tableView.dataSource?.tableView(self.tableView, cellForRowAt: indexPath) else { return nil } + guard let previewView = cell.contentView.snapshotView(afterScreenUpdates: false) else { return nil } + previewView.backgroundColor = .systemBackground + + // On large iPhones (with regular landscape size, like iPhone X) we need to take the safe area into account when calculating the center + let cellCenterX = cell.center.x + self.view.safeAreaInsets.left / 2 - self.view.safeAreaInsets.right / 2 + let cellCenter = CGPoint(x: cellCenterX, y: cell.center.y) + + // Create a preview target which allows us to have a transparent background + let previewTarget = UIPreviewTarget(container: self.view, center: cellCenter) + let previewParameter = UIPreviewParameters() + + // Remove the background and the drop shadow from our custom preview view + previewParameter.backgroundColor = .systemBackground + previewParameter.shadowPath = UIBezierPath() + + return UITargetedPreview(view: previewView, parameters: previewParameter, target: previewTarget) + } + + override func tableView(_ tableView: UITableView, willDisplayContextMenu configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { + if tableView != self.tableView { + return + } + + isContextMenuActive = true + } + + override func tableView(_ tableView: UITableView, willEndContextMenuInteraction configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { + if tableView != self.tableView { + return + } + + animator?.addCompletion { + self.isContextMenuActive = false + + // Wait until the context menu is completely hidden before we execute any method + if let contextMenuActionBlock = self.contextMenuActionBlock { + contextMenuActionBlock() + self.contextMenuActionBlock = nil + } + + // Apply any room list refresh that was deferred while the context menu was visible + if self.pendingRoomListRefresh { + self.pendingRoomListRefresh = false + self.refreshRoomList() + } + } + } + + @objc func removeRoomSelection() { + self.selectedRoomToken = nil + } + + @objc func highlightSelectedRoom() { + if let selectedRoomToken { + if let idx = rooms.firstIndex(where: { $0.token == selectedRoomToken }) { + let indexPath = IndexPath(row: idx, section: RoomsSection.roomList.rawValue) + self.tableView.selectRow(at: indexPath, animated: true, scrollPosition: .none) + } + } else { + if let selectedRow = self.tableView.indexPathForSelectedRow { + self.tableView.deselectRow(at: selectedRow, animated: true) + + // It might happen that this is called while we are switching accounts, so wait for the reload to be finished. + // Example: Active account has 1 pending invitation, switch to an account with no pending invitation -> crash. + DispatchQueue.main.async { + // Needed to make sure the highlight is really removed + self.tableView.reloadRows(at: [selectedRow], with: .none) + } + } + } + } +} diff --git a/NextcloudTalk/User Interface/NCUserInterfaceController.h b/NextcloudTalk/User Interface/NCUserInterfaceController.h index c2a8476a5..a1f112840 100644 --- a/NextcloudTalk/User Interface/NCUserInterfaceController.h +++ b/NextcloudTalk/User Interface/NCUserInterfaceController.h @@ -8,12 +8,12 @@ #import "NCNotificationController.h" #import "NCPushNotification.h" -#import "RoomsTableViewController.h" #import "NCRoom.h" @class NCSplitViewController; @class ChatViewController; @class CallViewController; +@class RoomsTableViewController; typedef void (^PresentCallControllerCompletionBlock)(void);