diff --git a/CHANGES.md b/CHANGES.md index 68cc98ab3d..b2b3f97d84 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,11 @@ +## Changes in 1.8.12 (2022-04-06) + +🐛 Bugfixes + +- RecentsViewController: Room context preview dismissed unexpectedly ([#5992](https://github.com/vector-im/element-ios/issues/5992)) +- Notifications: Strings now fall back to English if they're missing for the current language. ([#5996](https://github.com/vector-im/element-ios/issues/5996)) + + ## Changes in 1.8.11 (2022-04-05) ✨ Features diff --git a/Config/AppVersion.xcconfig b/Config/AppVersion.xcconfig index a9ed9e3288..00d305eddb 100644 --- a/Config/AppVersion.xcconfig +++ b/Config/AppVersion.xcconfig @@ -15,5 +15,5 @@ // // Version -MARKETING_VERSION = 1.8.11 -CURRENT_PROJECT_VERSION = 1.8.11 +MARKETING_VERSION = 1.8.12 +CURRENT_PROJECT_VERSION = 1.8.12 diff --git a/Riot/Categories/Bundle.swift b/Riot/Categories/Bundle.swift index 5b34301548..4584697429 100644 --- a/Riot/Categories/Bundle.swift +++ b/Riot/Categories/Bundle.swift @@ -19,7 +19,7 @@ import Foundation public extension Bundle { /// Returns the real app bundle. /// Can also be used in app extensions. - static var app: Bundle { + @objc static var app: Bundle { let bundle = main if bundle.bundleURL.pathExtension == "appex" { // Peel off two directory levels - MY_APP.app/PlugIns/MY_APP_EXTENSION.appex @@ -31,6 +31,14 @@ public extension Bundle { return bundle } + /// Get an lproj language bundle from the main app bundle. + /// - Parameter language: The language to try to load. + /// - Returns: The lproj bundle if found otherwise `nil`. + @objc static func lprojBundle(for language: String) -> Bundle? { + guard let lprojURL = Bundle.app.url(forResource: language, withExtension: "lproj") else { return nil } + return Bundle(url: lprojURL) + } + /// Whether or not the bundle is the RiotShareExtension. var isShareExtension: Bool { bundleURL.lastPathComponent.contains("RiotShareExtension.appex") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index a09e122870..e20180e33d 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -8006,7 +8006,7 @@ public class VectorL10n: NSObject { extension VectorL10n { static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { - let format = NSLocalizedString(key, tableName: table, bundle: Bundle(for: BundleToken.self), comment: "") + let format = NSLocalizedString(key, tableName: table, bundle: Bundle.app, comment: "") let locale: Locale if let providedLocale = LocaleProvider.locale { locale = providedLocale @@ -8018,4 +8018,3 @@ extension VectorL10n { } } -private final class BundleToken {} diff --git a/Riot/Modules/Common/Recents/RecentsViewController.m b/Riot/Modules/Common/Recents/RecentsViewController.m index 772b23260d..59a7c60844 100644 --- a/Riot/Modules/Common/Recents/RecentsViewController.m +++ b/Riot/Modules/Common/Recents/RecentsViewController.m @@ -366,6 +366,14 @@ - (void)viewDidLayoutSubviews - (void)refreshRecentsTable { + if (!self.recentsUpdateEnabled) + { + isRefreshNeeded = NO; + return; + } + + isRefreshNeeded = NO; + // Refresh the tabBar icon badges [[AppDelegate theDelegate].masterTabBarController refreshTabBarBadges]; @@ -1034,6 +1042,12 @@ - (void)dataSource:(MXKDataSource *)dataSource didRecognizeAction:(NSString *)ac - (void)dataSource:(MXKDataSource *)dataSource didCellChange:(id)changes { + if (!self.recentsUpdateEnabled) + { + [super dataSource:dataSource didCellChange:changes]; + return; + } + BOOL cellReloaded = NO; if ([changes isKindOfClass:RecentsSectionUpdate.class]) { @@ -2502,6 +2516,7 @@ - (UIContextMenuConfiguration *)tableView:(UITableView *)tableView contextMenuCo return nil; } + self.recentsUpdateEnabled = NO; return [self.contextMenuProvider contextMenuConfigurationWith:cellData from:cell session:self.dataSource.mxSession]; } @@ -2511,14 +2526,22 @@ - (void)tableView:(UITableView *)tableView willPerformPreviewActionForMenuWithCo if (!roomId) { + self.recentsUpdateEnabled = YES; return; } [animator addCompletion:^{ + self.recentsUpdateEnabled = YES; [self showRoomWithRoomId:roomId inMatrixSession:self.mainSession]; }]; } +- (UITargetedPreview *)tableView:(UITableView *)tableView previewForDismissingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration API_AVAILABLE(ios(13.0)) +{ + self.recentsUpdateEnabled = YES; + return nil; +} + #pragma mark - RoomContextActionServiceDelegate - (void)roomContextActionServiceDidJoinRoom:(id)service diff --git a/Riot/Modules/Home/HomeViewController.m b/Riot/Modules/Home/HomeViewController.m index 419cc50e14..fedc5825b1 100644 --- a/Riot/Modules/Home/HomeViewController.m +++ b/Riot/Modules/Home/HomeViewController.m @@ -902,6 +902,7 @@ - (UIContextMenuConfiguration *)collectionView:(UICollectionView *)collectionVie return nil; } + self.recentsUpdateEnabled = NO; return [self.contextMenuProvider contextMenuConfigurationWith:cellData from:cell session:self.dataSource.mxSession]; } @@ -911,12 +912,20 @@ - (void)collectionView:(UICollectionView *)collectionView willPerformPreviewActi if (!roomId) { + self.recentsUpdateEnabled = YES; return; } [animator addCompletion:^{ + self.recentsUpdateEnabled = YES; [self showRoomWithRoomId:roomId inMatrixSession:self.mainSession]; }]; } +- (UITargetedPreview *)collectionView:(UICollectionView *)collectionView previewForDismissingContextMenuWithConfiguration:(UIContextMenuConfiguration *)configuration API_AVAILABLE(ios(13.0)) +{ + self.recentsUpdateEnabled = YES; + return nil; +} + @end diff --git a/Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.m b/Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.m index a0112bf995..9615420cd0 100644 --- a/Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.m +++ b/Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.m @@ -15,6 +15,7 @@ */ #import "NSBundle+MXKLanguage.h" +#import "GeneratedInterface-Swift.h" #import @@ -55,37 +56,37 @@ + (void)mxk_setLanguage:(NSString *)language [self setupMXKLanguageBundle]; // [NSBundle localizedStringForKey] calls will be redirected to the bundle corresponding - // to "language" - objc_setAssociatedObject([NSBundle mainBundle], - &_bundle, language ? [NSBundle bundleWithPath:[[NSBundle mainBundle] pathForResource:language ofType:@"lproj"]] : nil, + // to "language". `lprojBundleFor` loads this from the main app bundle as we might be running in an extension. + objc_setAssociatedObject(NSBundle.app, + &_bundle, language ? [NSBundle lprojBundleFor:language] : nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - objc_setAssociatedObject([NSBundle mainBundle], + objc_setAssociatedObject(NSBundle.app, &_language, language, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } + (NSString *)mxk_language { - return objc_getAssociatedObject([NSBundle mainBundle], &_language); + return objc_getAssociatedObject(NSBundle.app, &_language); } + (void)mxk_setFallbackLanguage:(NSString *)language { [self setupMXKLanguageBundle]; - objc_setAssociatedObject([NSBundle mainBundle], - &_fallbackBundle, language ? [NSBundle bundleWithPath:[[NSBundle mainBundle] pathForResource:language ofType:@"lproj"]] : nil, + objc_setAssociatedObject(NSBundle.app, + &_fallbackBundle, language ? [NSBundle lprojBundleFor:language] : nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - objc_setAssociatedObject([NSBundle mainBundle], + objc_setAssociatedObject(NSBundle.app, &_fallbackLanguage, language, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } + (NSString *)mxk_fallbackLanguage { - return objc_getAssociatedObject([NSBundle mainBundle], &_fallbackLanguage); + return objc_getAssociatedObject(NSBundle.app, &_fallbackLanguage); } #pragma mark - Private methods @@ -96,7 +97,7 @@ + (void)setupMXKLanguageBundle dispatch_once(&onceToken, ^{ // Use MXKLanguageBundle as the [NSBundle mainBundle] class - object_setClass([NSBundle mainBundle], [MXKLanguageBundle class]); + object_setClass(NSBundle.app, MXKLanguageBundle.class); }); } diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.h index 5e064d295b..32df689114 100644 --- a/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.h +++ b/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.h @@ -59,6 +59,12 @@ limitations under the License. The fake top view displayed in case of vertical bounce. */ __weak UIView *topview; + + /** + `isRefreshNeeded` is set to `YES` if an update of the datasource has been triggered but the UI has not been updated. + It's set to `NO` after a refresh of the UI. + */ + BOOL isRefreshNeeded; } @property (weak, nonatomic) IBOutlet UISearchBar *recentsSearchBar; @@ -83,6 +89,11 @@ limitations under the License. */ @property (nonatomic) BOOL enableBarButtonSearch; +/** + Enabled or disabled the UI update after recents syncs. Default YES. + */ +@property (nonatomic, getter=isRecentsUpdateEnabled) BOOL recentsUpdateEnabled; + #pragma mark - Class methods /** diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.m index 43c112efdc..39eae4d1cf 100644 --- a/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.m +++ b/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.m @@ -83,6 +83,7 @@ - (void)finalizeInit { [super finalizeInit]; + _recentsUpdateEnabled = YES; _enableBarButtonSearch = YES; } @@ -169,6 +170,8 @@ - (void)viewWillAppear:(BOOL)animated // Observe the server sync [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onSyncNotification) name:kMXSessionDidSyncNotification object:nil]; + + self.recentsUpdateEnabled = YES; } - (void)viewWillDisappear:(BOOL)animated @@ -319,6 +322,10 @@ - (void)displayList:(MXKRecentsDataSource *)listDataSource - (void)refreshRecentsTable { + if (!self.recentsUpdateEnabled) return; + + isRefreshNeeded = NO; + // For now, do a simple full reload [self.recentsTableView reloadData]; } @@ -330,6 +337,16 @@ - (void)hideSearchBar:(BOOL)hidden [self.view setNeedsUpdateConstraints]; } +- (void)setRecentsUpdateEnabled:(BOOL)activeUpdate +{ + _recentsUpdateEnabled = activeUpdate; + + if (_recentsUpdateEnabled && isRefreshNeeded) + { + [self refreshRecentsTable]; + } +} + #pragma mark - Action - (IBAction)search:(id)sender @@ -385,6 +402,12 @@ - (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData - (void)dataSource:(MXKDataSource *)dataSource didCellChange:(id)changes { + if (!_recentsUpdateEnabled) + { + isRefreshNeeded = YES; + return; + } + // For now, do a simple full reload [self refreshRecentsTable]; } diff --git a/RiotNSE/NotificationService.swift b/RiotNSE/NotificationService.swift index e72804449b..5e141c5742 100644 --- a/RiotNSE/NotificationService.swift +++ b/RiotNSE/NotificationService.swift @@ -74,6 +74,17 @@ class NotificationService: UNNotificationServiceExtension { private static let backgroundServiceInitQueue = DispatchQueue(label: "io.element.NotificationService.backgroundServiceInitQueue") // MARK: - Method Overrides + override init() { + super.init() + + // Set up runtime language and fallback by considering the userDefaults object shared within the application group. + let sharedUserDefaults = MXKAppSettings.standard().sharedUserDefaults + if let language = sharedUserDefaults?.string(forKey: "appLanguage") { + Bundle.mxk_setLanguage(language) + } + Bundle.mxk_setFallbackLanguage("en") + } + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { let userInfo = request.content.userInfo @@ -341,9 +352,9 @@ class NotificationService: UNNotificationServiceExtension { let isVideoCall = sdp?.contains("m=video") ?? false if isVideoCall { - notificationBody = NSString.localizedUserNotificationString(forKey: "VIDEO_CALL_FROM_USER", arguments: [eventSenderName as Any]) + notificationBody = NotificationService.localizedString(forKey: "VIDEO_CALL_FROM_USER", eventSenderName) } else { - notificationBody = NSString.localizedUserNotificationString(forKey: "VOICE_CALL_FROM_USER", arguments: [eventSenderName as Any]) + notificationBody = NotificationService.localizedString(forKey: "VOICE_CALL_FROM_USER", eventSenderName) } // call notifications should stand out from normal messages, so we don't stack them @@ -405,7 +416,7 @@ class NotificationService: UNNotificationServiceExtension { } let msgType = event.content[kMXMessageTypeKey] as? String - let messageContent = event.content[kMXMessageBodyKey] as? String + let messageContent = event.content[kMXMessageBodyKey] as? String ?? "" let isReply = event.isReply() if isReply { @@ -416,30 +427,30 @@ class NotificationService: UNNotificationServiceExtension { if event.isEncrypted && !self.showDecryptedContentInNotifications { // Hide the content - notificationBody = NSString.localizedUserNotificationString(forKey: "MESSAGE", arguments: []) + notificationBody = NotificationService.localizedString(forKey: "MESSAGE") break } if event.location != nil { - notificationBody = NSString.localizedUserNotificationString(forKey: "LOCATION_FROM_USER", arguments: [eventSenderName]) + notificationBody = NotificationService.localizedString(forKey: "LOCATION_FROM_USER", eventSenderName) break } switch msgType { case kMXMessageTypeEmote: - notificationBody = NSString.localizedUserNotificationString(forKey: "ACTION_FROM_USER", arguments: [eventSenderName, messageContent as Any]) + notificationBody = NotificationService.localizedString(forKey: "ACTION_FROM_USER", eventSenderName, messageContent) case kMXMessageTypeImage: - notificationBody = NSString.localizedUserNotificationString(forKey: "PICTURE_FROM_USER", arguments: [eventSenderName]) + notificationBody = NotificationService.localizedString(forKey: "PICTURE_FROM_USER", eventSenderName) case kMXMessageTypeVideo: - notificationBody = NSString.localizedUserNotificationString(forKey: "VIDEO_FROM_USER", arguments: [eventSenderName]) + notificationBody = NotificationService.localizedString(forKey: "VIDEO_FROM_USER", eventSenderName) case kMXMessageTypeAudio: if event.isVoiceMessage() { - notificationBody = NSString.localizedUserNotificationString(forKey: "VOICE_MESSAGE_FROM_USER", arguments: [eventSenderName]) + notificationBody = NotificationService.localizedString(forKey: "VOICE_MESSAGE_FROM_USER", eventSenderName) } else { - notificationBody = NSString.localizedUserNotificationString(forKey: "AUDIO_FROM_USER", arguments: [eventSenderName, messageContent as Any]) + notificationBody = NotificationService.localizedString(forKey: "AUDIO_FROM_USER", eventSenderName, messageContent) } case kMXMessageTypeFile: - notificationBody = NSString.localizedUserNotificationString(forKey: "FILE_FROM_USER", arguments: [eventSenderName, messageContent as Any]) + notificationBody = NotificationService.localizedString(forKey: "FILE_FROM_USER", eventSenderName, messageContent) // All other message types such as text, notice, server notice etc default: @@ -469,50 +480,50 @@ class NotificationService: UNNotificationServiceExtension { // If there was a change, use the sender's userID if one was blank and show the change. if let oldDisplayname = oldContent.displayname ?? event.sender, let displayname = newContent.displayname ?? event.sender { - notificationBody = NSString.localizedUserNotificationString(forKey: "USER_UPDATED_DISPLAYNAME", arguments: [oldDisplayname, displayname]) + notificationBody = NotificationService.localizedString(forKey: "USER_UPDATED_DISPLAYNAME", oldDisplayname, displayname) } else { // Should never be reached as the event should always have a sender. - notificationBody = NSString.localizedUserNotificationString(forKey: "GENERIC_USER_UPDATED_DISPLAYNAME", arguments: [eventSenderName]) + notificationBody = NotificationService.localizedString(forKey: "GENERIC_USER_UPDATED_DISPLAYNAME", eventSenderName) } } else { // If the display name hasn't changed, handle as an avatar change. - notificationBody = NSString.localizedUserNotificationString(forKey: "USER_UPDATED_AVATAR", arguments: [eventSenderName]) + notificationBody = NotificationService.localizedString(forKey: "USER_UPDATED_AVATAR", eventSenderName) } } else { // No known reports of having reached this situation for a membership notification // So use a generic membership updated fallback. - notificationBody = NSString.localizedUserNotificationString(forKey: "USER_MEMBERSHIP_UPDATED", arguments: [eventSenderName]) + notificationBody = NotificationService.localizedString(forKey: "USER_MEMBERSHIP_UPDATED", eventSenderName) } // Otherwise treat the notification as an invite. // This is the expected notification content for a membership event. } else { - if roomDisplayName != nil && roomDisplayName != eventSenderName { - notificationBody = NSString.localizedUserNotificationString(forKey: "USER_INVITE_TO_NAMED_ROOM", arguments: [eventSenderName, roomDisplayName as Any]) + if let roomDisplayName = roomDisplayName, roomDisplayName != eventSenderName { + notificationBody = NotificationService.localizedString(forKey: "USER_INVITE_TO_NAMED_ROOM", eventSenderName, roomDisplayName) } else { - notificationBody = NSString.localizedUserNotificationString(forKey: "USER_INVITE_TO_CHAT", arguments: [eventSenderName]) + notificationBody = NotificationService.localizedString(forKey: "USER_INVITE_TO_CHAT", eventSenderName) } } case .sticker: notificationTitle = self.messageTitle(for: eventSenderName, in: roomDisplayName) - notificationBody = NSString.localizedUserNotificationString(forKey: "STICKER_FROM_USER", arguments: [eventSenderName as Any]) + notificationBody = NotificationService.localizedString(forKey: "STICKER_FROM_USER", eventSenderName) // Reactions are unexpected notification types, but have been seen in some circumstances. case .reaction: notificationTitle = self.messageTitle(for: eventSenderName, in: roomDisplayName) if let reactionKey = event.relatesTo?.key { // Try to show the reaction key in the notification. - notificationBody = NSString.localizedUserNotificationString(forKey: "REACTION_FROM_USER", arguments: [eventSenderName, reactionKey]) + notificationBody = NotificationService.localizedString(forKey: "REACTION_FROM_USER", eventSenderName, reactionKey) } else { // Otherwise show a generic reaction. - notificationBody = NSString.localizedUserNotificationString(forKey: "GENERIC_REACTION_FROM_USER", arguments: [eventSenderName]) + notificationBody = NotificationService.localizedString(forKey: "GENERIC_REACTION_FROM_USER", eventSenderName) } case .custom: if (event.type == kWidgetMatrixEventTypeString || event.type == kWidgetModularEventTypeString), let type = event.content?["type"] as? String, (type == kWidgetTypeJitsiV1 || type == kWidgetTypeJitsiV2) { - notificationBody = NSString.localizedUserNotificationString(forKey: "GROUP_CALL_STARTED", arguments: nil) + notificationBody = NotificationService.localizedString(forKey: "GROUP_CALL_STARTED") notificationTitle = roomDisplayName // call notifications should stand out from normal messages, so we don't stack them @@ -566,7 +577,7 @@ class NotificationService: UNNotificationServiceExtension { var validatedNotificationTitle: String? = notificationTitle if self.localAuthenticationService.isProtectionSet { MXLog.debug("[NotificationService] validateNotificationContentAndComplete: Resetting title and body because app protection is set") - validatedNotificationBody = NSString.localizedUserNotificationString(forKey: "MESSAGE_PROTECTED", arguments: []) + validatedNotificationBody = NotificationService.localizedString(forKey: "MESSAGE_PROTECTED") validatedNotificationTitle = nil } @@ -596,7 +607,7 @@ class NotificationService: UNNotificationServiceExtension { private func messageTitle(for eventSenderName: String, in roomDisplayName: String?) -> String { // Display the room name only if it is different than the sender name if let roomDisplayName = roomDisplayName, roomDisplayName != eventSenderName { - return NSString.localizedUserNotificationString(forKey: "MSG_FROM_USER_IN_ROOM_TITLE", arguments: [eventSenderName, roomDisplayName]) + return NotificationService.localizedString(forKey: "MSG_FROM_USER_IN_ROOM_TITLE", eventSenderName, roomDisplayName) } else { return eventSenderName } @@ -605,9 +616,9 @@ class NotificationService: UNNotificationServiceExtension { private func replyTitle(for eventSenderName: String, in roomDisplayName: String?) -> String { // Display the room name only if it is different than the sender name if let roomDisplayName = roomDisplayName, roomDisplayName != eventSenderName { - return NSString.localizedUserNotificationString(forKey: "REPLY_FROM_USER_IN_ROOM_TITLE", arguments: [eventSenderName, roomDisplayName]) + return NotificationService.localizedString(forKey: "REPLY_FROM_USER_IN_ROOM_TITLE", eventSenderName, roomDisplayName) } else { - return NSString.localizedUserNotificationString(forKey: "REPLY_FROM_USER_TITLE", arguments: [eventSenderName]) + return NotificationService.localizedString(forKey: "REPLY_FROM_USER_TITLE", eventSenderName) } } @@ -816,4 +827,13 @@ class NotificationService: UNNotificationServiceExtension { } } } + + private static func localizedString(forKey key: String, _ args: CVarArg...) -> String { + // The bundle needs to be an MXKLanguageBundle and contain the lproj files. + // MatrixKit now sets the app bundle as the MXKLanguageBundle + let format = NSLocalizedString(key, bundle: Bundle.app, comment: "") + let locale = LocaleProvider.locale ?? Locale.current + + return String(format: format, locale: locale, arguments: args) + } } diff --git a/Tools/SwiftGen/Templates/Strings/flat-swift4-vector.stencil b/Tools/SwiftGen/Templates/Strings/flat-swift4-vector.stencil index f76780d247..3e3e6f5941 100644 --- a/Tools/SwiftGen/Templates/Strings/flat-swift4-vector.stencil +++ b/Tools/SwiftGen/Templates/Strings/flat-swift4-vector.stencil @@ -64,7 +64,7 @@ import Foundation extension {{className}} { static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { - let format = NSLocalizedString(key, tableName: table, bundle: Bundle(for: BundleToken.self), comment: "") + let format = NSLocalizedString(key, tableName: table, bundle: Bundle.app, comment: "") let locale: Locale if let providedLocale = LocaleProvider.locale { @@ -77,7 +77,6 @@ extension {{className}} { } } -private final class BundleToken {} {% else %} // No string found {% endif %}