From e7d4cd770758388bd413f8073f1ac64d8237c447 Mon Sep 17 00:00:00 2001 From: SBiOSoftWhare Date: Fri, 3 Dec 2021 11:47:24 +0100 Subject: [PATCH] Merge MatrixKit develop with commit hash: b85b736313bec0592bd1cabc68035d97f5331137 --- .../Animators/MXKAttachmentAnimator.h | 45 + .../Animators/MXKAttachmentAnimator.m | 161 + .../MXKAttachmentInteractionController.h | 26 + .../MXKAttachmentInteractionController.m | 204 + .../MatrixKit/Assets/InfoPlist.strings | 25 + .../Images/back_icon.png | Bin 0 -> 601 bytes .../Images/back_icon@2x.png | Bin 0 -> 1244 bytes .../Images/back_icon@3x.png | Bin 0 -> 1654 bytes .../Images/back_icon@4x.png | Bin 0 -> 3058 bytes .../Images/bubble_ios_messages_right.png | Bin 0 -> 802 bytes .../Images/bubble_ios_messages_right@2x.png | Bin 0 -> 1253 bytes .../Images/default-profile.png | Bin 0 -> 2817 bytes .../Images/default-profile@2x.png | Bin 0 -> 1722 bytes .../Images/disclosure.png | Bin 0 -> 211 bytes .../Images/disclosure@2x.png | Bin 0 -> 248 bytes .../Images/filetype-gif.png | Bin 0 -> 1681 bytes .../Images/filetype-gif@2x.png | Bin 0 -> 786 bytes .../Images/icon_audio_mute.png | Bin 0 -> 2324 bytes .../Images/icon_audio_mute@2x.png | Bin 0 -> 4956 bytes .../Images/icon_audio_unmute.png | Bin 0 -> 2000 bytes .../Images/icon_audio_unmute@2x.png | Bin 0 -> 4119 bytes .../Images/icon_backtoapp.png | Bin 0 -> 1726 bytes .../Images/icon_backtoapp@2x.png | Bin 0 -> 2479 bytes .../Images/icon_keyboard.png | Bin 0 -> 3229 bytes .../Images/icon_keyboard@2x.png | Bin 0 -> 3700 bytes .../Images/icon_minus.png | Bin 0 -> 1337 bytes .../Images/icon_minus@2x.png | Bin 0 -> 616 bytes .../Images/icon_pause.png | Bin 0 -> 766 bytes .../Images/icon_pause@2x.png | Bin 0 -> 308 bytes .../Images/icon_play.png | Bin 0 -> 815 bytes .../Images/icon_play@2x.png | Bin 0 -> 317 bytes .../Images/icon_speaker_off.png | Bin 0 -> 2386 bytes .../Images/icon_speaker_off@2x.png | Bin 0 -> 4834 bytes .../Images/icon_speaker_on.png | Bin 0 -> 2644 bytes .../Images/icon_speaker_on@2x.png | Bin 0 -> 4966 bytes .../Images/icon_video.png | Bin 0 -> 1438 bytes .../Images/icon_video@2x.png | Bin 0 -> 2599 bytes .../Images/icon_video_mute.png | Bin 0 -> 3231 bytes .../Images/icon_video_mute@2x.png | Bin 0 -> 5915 bytes .../Images/icon_video_unmute.png | Bin 0 -> 2849 bytes .../Images/icon_video_unmute@2x.png | Bin 0 -> 5926 bytes .../Images/logoHighRes.png | Bin 0 -> 8165 bytes .../Images/logoHighRes@2x.png | Bin 0 -> 4060 bytes .../Images/matrixUser.png | Bin 0 -> 373 bytes .../Images/matrixUser@2x.png | Bin 0 -> 1515 bytes .../Images/network_matrix.png | Bin 0 -> 535 bytes .../Images/network_matrix@2x.png | Bin 0 -> 958 bytes .../MatrixKitAssets.bundle/Images/play.png | Bin 0 -> 2470 bytes .../MatrixKitAssets.bundle/Images/play@2x.png | Bin 0 -> 3705 bytes .../MatrixKitAssets.bundle/Images/shrink.png | Bin 0 -> 226 bytes .../Images/shrink@2x.png | Bin 0 -> 287 bytes .../MatrixKitAssets.bundle/Sounds/busy.mp3 | Bin 0 -> 24834 bytes .../MatrixKitAssets.bundle/Sounds/callend.mp3 | Bin 0 -> 12971 bytes .../MatrixKitAssets.bundle/Sounds/message.mp3 | Bin 0 -> 4712 bytes .../MatrixKitAssets.bundle/Sounds/ring.mp3 | Bin 0 -> 19662 bytes .../Sounds/ringback.mp3 | Bin 0 -> 18398 bytes .../ar.lproj/MatrixKit.strings | 549 +++ .../bg.lproj/MatrixKit.strings | 466 ++ .../ca.lproj/MatrixKit.strings | 363 ++ .../cy.lproj/MatrixKit.strings | 383 ++ .../da.lproj/MatrixKit.strings | 1 + .../de.lproj/MatrixKit.strings | 499 ++ .../en.lproj/MatrixKit.strings | 581 +++ .../eo.lproj/MatrixKit.strings | 533 +++ .../es.lproj/MatrixKit.strings | 399 ++ .../et.lproj/MatrixKit.strings | 478 ++ .../eu.lproj/MatrixKit.strings | 383 ++ .../fa.lproj/MatrixKit.strings | 549 +++ .../fi.lproj/MatrixKit.strings | 151 + .../fr.lproj/MatrixKit.strings | 475 ++ .../hu.lproj/MatrixKit.strings | 479 ++ .../id.lproj/MatrixKit.strings | 557 +++ .../is.lproj/MatrixKit.strings | 1 + .../it.lproj/MatrixKit.strings | 478 ++ .../ja.lproj/MatrixKit.strings | 409 ++ .../kab.lproj/MatrixKit.strings | 530 +++ .../ko.lproj/MatrixKit.strings | 381 ++ .../lv.lproj/MatrixKit.strings | 119 + .../nb-NO.lproj/MatrixKit.strings | 544 +++ .../nl.lproj/MatrixKit.strings | 534 +++ .../pl.lproj/MatrixKit.strings | 498 ++ .../pt_BR.lproj/MatrixKit.strings | 479 ++ .../ru.lproj/MatrixKit.strings | 478 ++ .../si.lproj/MatrixKit.strings | 25 + .../sq.lproj/MatrixKit.strings | 477 ++ .../sv.lproj/MatrixKit.strings | 472 ++ .../szl.lproj/MatrixKit.strings | 1 + .../tzm.lproj/MatrixKit.strings | 14 + .../uk.lproj/MatrixKit.strings | 521 +++ .../vi.lproj/MatrixKit.strings | 359 ++ .../vls.lproj/MatrixKit.strings | 123 + .../zh_Hans.lproj/MatrixKit.strings | 480 ++ .../zh_Hant.lproj/MatrixKit.strings | 247 + .../Categories/DTHTMLElement+MatrixKit.swift | 97 + .../MXAggregatedReactions+MatrixKit.h | 30 + .../MXAggregatedReactions+MatrixKit.m | 44 + .../MatrixKit/Categories/MXEvent+MatrixKit.h | 34 + .../MatrixKit/Categories/MXEvent+MatrixKit.m | 52 + .../MatrixKit/Categories/MXRoom+Sync.h | 34 + .../MatrixKit/Categories/MXRoom+Sync.m | 36 + .../Categories/MXSession+MatrixKit.h | 28 + .../Categories/MXSession+MatrixKit.m | 34 + .../NSAttributedString+MatrixKit.swift | 32 + .../Categories/NSBundle+MXKLanguage.h | 54 + .../Categories/NSBundle+MXKLanguage.m | 103 + .../MatrixKit/Categories/NSBundle+MatrixKit.h | 61 + .../MatrixKit/Categories/NSBundle+MatrixKit.m | 150 + .../Categories/NSString+MatrixKit.swift | 66 + .../Categories/UIAlertController+MatrixKit.h | 31 + .../Categories/UIAlertController+MatrixKit.m | 38 + .../Categories/UITextView+MatrixKit.h | 33 + .../Categories/UITextView+MatrixKit.m | 54 + .../Categories/UIViewController+MatrixKit.h | 30 + .../Categories/UIViewController+MatrixKit.m | 45 + .../MXKAccountDetailsViewController.h | 124 + .../MXKAccountDetailsViewController.m | 1172 +++++ .../MXKAccountDetailsViewController.xib | 95 + .../MXKActivityHandlingViewController.h | 26 + .../MXKActivityHandlingViewController.m | 83 + .../MXKAttachmentsViewController.h | 123 + .../MXKAttachmentsViewController.m | 1439 ++++++ .../MXKAttachmentsViewController.xib | 69 + .../MXKAuthenticationViewController.h | 311 ++ .../MXKAuthenticationViewController.m | 2150 +++++++++ .../MXKAuthenticationViewController.xib | 298 ++ .../Controllers/MXKCallViewController.h | 243 + .../Controllers/MXKCallViewController.m | 1547 ++++++ .../Controllers/MXKCallViewController.xib | 423 ++ .../MXKContactDetailsViewController.h | 90 + .../MXKContactDetailsViewController.m | 207 + .../MXKContactDetailsViewController.xib | 65 + .../MXKContactListViewController.h | 122 + .../MXKContactListViewController.m | 663 +++ .../MXKContactListViewController.xib | 49 + .../MXKCountryPickerViewController.h | 81 + .../MXKCountryPickerViewController.m | 299 ++ .../MXKCountryPickerViewController.xib | 27 + .../Controllers/MXKGroupListViewController.h | 118 + .../Controllers/MXKGroupListViewController.m | 608 +++ .../MXKGroupListViewController.xib | 53 + .../MXKLanguagePickerViewController.h | 104 + .../MXKLanguagePickerViewController.m | 308 ++ .../MXKLanguagePickerViewController.xib | 27 + .../MXKNotificationSettingsViewController.h | 34 + .../MXKNotificationSettingsViewController.m | 637 +++ .../Controllers/MXKPreviewViewController.h | 74 + .../Controllers/MXKPreviewViewController.m | 104 + .../Controllers/MXKRecentListViewController.h | 129 + .../Controllers/MXKRecentListViewController.m | 624 +++ .../MXKRecentListViewController.xib | 53 + .../MXKRoomMemberDetailsViewController.h | 212 + .../MXKRoomMemberDetailsViewController.m | 1037 +++++ .../MXKRoomMemberDetailsViewController.xib | 73 + .../MXKRoomMemberListViewController.h | 116 + .../MXKRoomMemberListViewController.m | 571 +++ .../MXKRoomMemberListViewController.xib | 53 + .../MXKRoomSettingsViewController.h | 81 + .../MXKRoomSettingsViewController.m | 215 + .../MXKRoomSettingsViewController.xib | 27 + .../Controllers/MXKRoomViewController.h | 440 ++ .../Controllers/MXKRoomViewController.m | 4066 ++++++++++++++++ .../Controllers/MXKRoomViewController.xib | 64 + .../Controllers/MXKSearchViewController.h | 75 + .../Controllers/MXKSearchViewController.m | 423 ++ .../Controllers/MXKSearchViewController.xib | 66 + .../Controllers/MXKTableViewController.h | 27 + .../Controllers/MXKTableViewController.m | 574 +++ .../MatrixKit/Controllers/MXKViewController.h | 52 + .../MatrixKit/Controllers/MXKViewController.m | 657 +++ .../MXKViewControllerActivityHandling.h | 50 + .../Controllers/MXKViewControllerHandling.h | 148 + .../Controllers/MXKWebViewViewController.h | 69 + .../Controllers/MXKWebViewViewController.m | 349 ++ Riot/Modules/MatrixKit/Libs/SwiftUTI/LICENSE | 21 + .../Libs/SwiftUTI/SWIFT_UTI_README.md | 4 + .../Modules/MatrixKit/Libs/SwiftUTI/UTI.swift | 517 +++ Riot/Modules/MatrixKit/MatrixKit.h | 155 + Riot/Modules/MatrixKit/MatrixKitVersion.m | 19 + .../MatrixKit/Models/Account/MXKAccount.h | 435 ++ .../MatrixKit/Models/Account/MXKAccount.m | 2228 +++++++++ .../Models/Account/MXKAccountManager.h | 218 + .../Models/Account/MXKAccountManager.m | 726 +++ .../MatrixKit/Models/Contact/MXKContact.h | 170 + .../MatrixKit/Models/Contact/MXKContact.m | 659 +++ .../Models/Contact/MXKContactField.h | 50 + .../Models/Contact/MXKContactField.m | 235 + .../Models/Contact/MXKContactManager.h | 243 + .../Models/Contact/MXKContactManager.m | 1939 ++++++++ .../MatrixKit/Models/Contact/MXKEmail.h | 30 + .../MatrixKit/Models/Contact/MXKEmail.m | 91 + .../MatrixKit/Models/Contact/MXKPhoneNumber.h | 78 + .../MatrixKit/Models/Contact/MXKPhoneNumber.m | 213 + .../Models/Contact/MXKSectionedContacts.h | 33 + .../Models/Contact/MXKSectionedContacts.m | 32 + .../MatrixKit/Models/Group/MXKGroupCellData.h | 24 + .../MatrixKit/Models/Group/MXKGroupCellData.m | 49 + .../Models/Group/MXKGroupCellDataStoring.h | 53 + .../Models/Group/MXKSessionGroupsDataSource.h | 94 + .../Models/Group/MXKSessionGroupsDataSource.m | 611 +++ Riot/Modules/MatrixKit/Models/MXK3PID.h | 119 + Riot/Modules/MatrixKit/Models/MXK3PID.m | 316 ++ .../Modules/MatrixKit/Models/MXKAppSettings.h | 290 ++ .../Modules/MatrixKit/Models/MXKAppSettings.m | 865 ++++ Riot/Modules/MatrixKit/Models/MXKCellData.h | 27 + Riot/Modules/MatrixKit/Models/MXKCellData.m | 21 + Riot/Modules/MatrixKit/Models/MXKDataSource.h | 225 + Riot/Modules/MatrixKit/Models/MXKDataSource.m | 148 + .../Models/MXKPasteboardManager.swift | 33 + .../MXKDirectoryServerCellData.h | 27 + .../MXKDirectoryServerCellData.m | 66 + .../MXKDirectoryServerCellDataStoring.h | 75 + .../MXKDirectoryServersDataSource.h | 82 + .../MXKDirectoryServersDataSource.m | 230 + .../MatrixKit/Models/Room/MXKAttachment.h | 212 + .../MatrixKit/Models/Room/MXKAttachment.m | 718 +++ .../MatrixKit/Models/Room/MXKQueuedEvent.h | 52 + .../MatrixKit/Models/Room/MXKQueuedEvent.m | 43 + .../Models/Room/MXKRoomBubbleCellData.h | 166 + .../Models/Room/MXKRoomBubbleCellData.m | 923 ++++ .../Room/MXKRoomBubbleCellDataStoring.h | 348 ++ .../MXKRoomBubbleCellDataWithAppendingMode.h | 45 + .../MXKRoomBubbleCellDataWithAppendingMode.m | 356 ++ ...mBubbleCellDataWithIncomingAppendingMode.h | 27 + ...mBubbleCellDataWithIncomingAppendingMode.m | 45 + .../Models/Room/MXKRoomBubbleComponent.h | 127 + .../Models/Room/MXKRoomBubbleComponent.m | 189 + .../Models/Room/MXKRoomCreationInputs.h | 78 + .../Models/Room/MXKRoomCreationInputs.m | 74 + .../MatrixKit/Models/Room/MXKRoomDataSource.h | 779 ++++ .../MatrixKit/Models/Room/MXKRoomDataSource.m | 4127 +++++++++++++++++ .../Models/Room/MXKRoomDataSourceManager.h | 124 + .../Models/Room/MXKRoomDataSourceManager.m | 271 ++ .../Room/MXKSendReplyEventStringLocalizer.h | 25 + .../Room/MXKSendReplyEventStringLocalizer.m | 53 + .../MatrixKit/Models/Room/MXKSlashCommands.h | 33 + .../MatrixKit/Models/Room/MXKSlashCommands.m | 29 + .../Models/Room/MXKURLPreviewDataProtocol.h | 40 + .../MXKInterleavedRecentsDataSource.h | 26 + .../MXKInterleavedRecentsDataSource.m | 439 ++ .../Models/RoomList/MXKRecentCellData.h | 26 + .../Models/RoomList/MXKRecentCellData.m | 133 + .../RoomList/MXKRecentCellDataStoring.h | 75 + .../Models/RoomList/MXKRecentsDataSource.h | 140 + .../Models/RoomList/MXKRecentsDataSource.m | 657 +++ .../RoomList/MXKSessionRecentsDataSource.h | 90 + .../RoomList/MXKSessionRecentsDataSource.m | 552 +++ .../RoomMemberList/MXKRoomMemberCellData.h | 33 + .../RoomMemberList/MXKRoomMemberCellData.m | 66 + .../MXKRoomMemberCellDataStoring.h | 67 + .../MXKRoomMemberListDataSource.h | 97 + .../MXKRoomMemberListDataSource.m | 464 ++ .../Models/Search/MXKSearchCellData.h | 25 + .../Models/Search/MXKSearchCellData.m | 69 + .../Models/Search/MXKSearchCellDataStoring.h | 83 + .../Models/Search/MXKSearchDataSource.h | 108 + .../Models/Search/MXKSearchDataSource.m | 275 ++ .../MXKErrorAlertPresentation.h | 26 + .../MXKErrorAlertPresentation.m | 108 + .../ErrorPresentation/MXKErrorPresentable.h | 29 + .../MXKErrorPresentableBuilder.h | 41 + .../MXKErrorPresentableBuilder.m | 56 + .../ErrorPresentation/MXKErrorPresentation.h | 49 + .../ErrorPresentation/MXKErrorViewModel.h | 26 + .../ErrorPresentation/MXKErrorViewModel.m | 41 + .../Utils/EventFormatter/MXKEventFormatter.h | 409 ++ .../Utils/EventFormatter/MXKEventFormatter.m | 2215 +++++++++ .../MXKRoomNameStringLocalizer.h | 26 + .../MXKRoomNameStringLocalizer.m | 42 + .../MarkdownToHTMLRenderer.swift | 52 + .../MatrixKit/Utils/MXKAnalyticsConstants.h | 33 + Riot/Modules/MatrixKit/Utils/MXKConstants.h | 41 + Riot/Modules/MatrixKit/Utils/MXKConstants.m | 23 + .../Utils/MXKDocumentPickerPresenter.swift | 80 + .../MatrixKit/Utils/MXKResponderRageShaking.h | 47 + Riot/Modules/MatrixKit/Utils/MXKSoundPlayer.h | 36 + Riot/Modules/MatrixKit/Utils/MXKSoundPlayer.m | 145 + Riot/Modules/MatrixKit/Utils/MXKTools.h | 426 ++ Riot/Modules/MatrixKit/Utils/MXKTools.m | 1230 +++++ Riot/Modules/MatrixKit/Utils/MXKUTI.swift | 203 + .../Utils/MXKVideoThumbnailGenerator.swift | 76 + .../Views/Account/MXKAccountTableViewCell.h | 43 + .../Views/Account/MXKAccountTableViewCell.m | 96 + .../Views/Account/MXKAccountTableViewCell.xib | 56 + .../MXKAuthInputsEmailCodeBasedView.h | 41 + .../MXKAuthInputsEmailCodeBasedView.m | 202 + .../MXKAuthInputsEmailCodeBasedView.xib | 85 + .../MXKAuthInputsPasswordBasedView.h | 46 + .../MXKAuthInputsPasswordBasedView.m | 253 + .../MXKAuthInputsPasswordBasedView.xib | 102 + .../Views/Authentication/MXKAuthInputsView.h | 242 + .../Views/Authentication/MXKAuthInputsView.m | 156 + .../MXKAuthenticationFallbackWebView.h | 30 + .../MXKAuthenticationFallbackWebView.m | 181 + .../MXKAuthenticationRecaptchaWebView.h | 33 + .../MXKAuthenticationRecaptchaWebView.m | 126 + .../Views/BarButtonItem/MXKBarButtonItem.h | 38 + .../Views/BarButtonItem/MXKBarButtonItem.m | 67 + .../Views/Contact/MXKContactTableCell.h | 90 + .../Views/Contact/MXKContactTableCell.m | 349 ++ .../Views/Contact/MXKContactTableCell.xib | 112 + .../Views/DeviceView/MXKDeviceView.h | 85 + .../Views/DeviceView/MXKDeviceView.m | 481 ++ .../Views/DeviceView/MXKDeviceView.xib | 108 + .../MXKEncryptionInfoView.h | 103 + .../MXKEncryptionInfoView.m | 492 ++ .../MXKEncryptionInfoView.xib | 90 + .../MXKEncryptionKeysExportView.h | 69 + .../MXKEncryptionKeysExportView.m | 192 + .../MXKEncryptionKeysImportView.h | 49 + .../MXKEncryptionKeysImportView.m | 122 + .../Views/Group/MXKGroupTableViewCell.h | 39 + .../Views/Group/MXKGroupTableViewCell.m | 92 + .../Views/Group/MXKGroupTableViewCell.xib | 62 + .../MatrixKit/Views/MXKCellRendering.h | 121 + .../MXKCollectionViewCell.h | 47 + .../MXKCollectionViewCell.m | 76 + .../MXKMediaCollectionViewCell.h | 45 + .../MXKMediaCollectionViewCell.m | 73 + .../MXKMediaCollectionViewCell.xib | 88 + .../MatrixKit/Views/MXKEventDetailsView.h | 32 + .../MatrixKit/Views/MXKEventDetailsView.m | 204 + .../MatrixKit/Views/MXKEventDetailsView.xib | 72 + Riot/Modules/MatrixKit/Views/MXKImageView.h | 126 + Riot/Modules/MatrixKit/Views/MXKImageView.m | 925 ++++ .../MatrixKit/Views/MXKMessageTextView.h | 31 + .../MatrixKit/Views/MXKMessageTextView.m | 57 + Riot/Modules/MatrixKit/Views/MXKPieChartHUD.h | 26 + Riot/Modules/MatrixKit/Views/MXKPieChartHUD.m | 115 + .../MatrixKit/Views/MXKPieChartHUD.xib | 75 + .../Modules/MatrixKit/Views/MXKPieChartView.h | 35 + .../Modules/MatrixKit/Views/MXKPieChartView.m | 139 + .../Views/MXKReceiptSendersContainer.h | 99 + .../Views/MXKReceiptSendersContainer.m | 174 + .../MatrixKit/Views/MXKRoomActivitiesView.h | 71 + .../MatrixKit/Views/MXKRoomActivitiesView.m | 58 + .../MatrixKit/Views/MXKRoomCreationView.h | 140 + .../MatrixKit/Views/MXKRoomCreationView.m | 578 +++ .../MatrixKit/Views/MXKRoomCreationView.xib | 131 + .../Views/MXKTableViewCell/MXKTableViewCell.h | 82 + .../Views/MXKTableViewCell/MXKTableViewCell.m | 100 + .../MXKTableViewCellWithButton.h | 27 + .../MXKTableViewCellWithButton.m | 34 + .../MXKTableViewCellWithButton.xib | 37 + .../MXKTableViewCellWithButtons.h | 36 + .../MXKTableViewCellWithButtons.m | 156 + .../MXKTableViewCellWithLabelAndButton.h | 38 + .../MXKTableViewCellWithLabelAndButton.m | 22 + .../MXKTableViewCellWithLabelAndButton.xib | 53 + .../MXKTableViewCellWithLabelAndImageView.h | 44 + .../MXKTableViewCellWithLabelAndImageView.m | 44 + .../MXKTableViewCellWithLabelAndImageView.xib | 53 + ...MXKTableViewCellWithLabelAndMXKImageView.h | 46 + ...MXKTableViewCellWithLabelAndMXKImageView.m | 44 + ...KTableViewCellWithLabelAndMXKImageView.xib | 56 + .../MXKTableViewCellWithLabelAndSlider.h | 42 + .../MXKTableViewCellWithLabelAndSlider.m | 22 + .../MXKTableViewCellWithLabelAndSlider.xib | 60 + .../MXKTableViewCellWithLabelAndSubLabel.h | 39 + .../MXKTableViewCellWithLabelAndSubLabel.m | 21 + .../MXKTableViewCellWithLabelAndSubLabel.xib | 54 + .../MXKTableViewCellWithLabelAndSwitch.h | 35 + .../MXKTableViewCellWithLabelAndSwitch.m | 22 + .../MXKTableViewCellWithLabelAndSwitch.xib | 50 + .../MXKTableViewCellWithLabelAndTextField.h | 47 + .../MXKTableViewCellWithLabelAndTextField.m | 58 + .../MXKTableViewCellWithLabelAndTextField.xib | 57 + ...TableViewCellWithLabelTextFieldAndButton.h | 58 + ...TableViewCellWithLabelTextFieldAndButton.m | 57 + ...bleViewCellWithLabelTextFieldAndButton.xib | 73 + .../MXKTableViewCellWithPicker.h | 35 + .../MXKTableViewCellWithPicker.m | 22 + .../MXKTableViewCellWithPicker.xib | 40 + .../MXKTableViewCellWithSearchBar.h | 35 + .../MXKTableViewCellWithSearchBar.m | 22 + .../MXKTableViewCellWithSearchBar.xib | 41 + .../MXKTableViewCellWithTextFieldAndButton.h | 50 + .../MXKTableViewCellWithTextFieldAndButton.m | 57 + ...MXKTableViewCellWithTextFieldAndButton.xib | 57 + .../MXKTableViewCellWithTextView.h | 35 + .../MXKTableViewCellWithTextView.m | 22 + .../MXKTableViewCellWithTextView.xib | 44 + .../MXKTableViewHeaderFooterView.h | 50 + .../MXKTableViewHeaderFooterView.m | 100 + .../MXKTableViewHeaderFooterWithLabel.h | 37 + .../MXKTableViewHeaderFooterWithLabel.m | 22 + .../MXKTableViewHeaderFooterWithLabel.xib | 54 + Riot/Modules/MatrixKit/Views/MXKView.h | 34 + Riot/Modules/MatrixKit/Views/MXKView.m | 53 + .../MXKPushRuleCreationTableViewCell.h | 67 + .../MXKPushRuleCreationTableViewCell.m | 208 + .../MXKPushRuleCreationTableViewCell.xib | 130 + .../Views/PushRule/MXKPushRuleTableViewCell.h | 57 + .../Views/PushRule/MXKPushRuleTableViewCell.m | 174 + .../PushRule/MXKPushRuleTableViewCell.xib | 88 + .../MXKReadReceiptTableViewCell.h | 29 + .../MXKReadReceiptTableViewCell.m | 44 + .../MXKReadReceiptTableViewCell.xib | 78 + .../MXKRoomBubbleTableViewCell.h | 328 ++ .../MXKRoomBubbleTableViewCell.m | 1563 +++++++ .../MXKRoomEmptyBubbleTableViewCell.h | 25 + .../MXKRoomEmptyBubbleTableViewCell.m | 21 + .../MXKRoomEmptyBubbleTableViewCell.xib | 25 + .../MXKRoomIOSBubbleTableViewCell.h | 26 + .../MXKRoomIOSBubbleTableViewCell.m | 45 + .../MXKRoomIOSOutgoingBubbleTableViewCell.h | 36 + .../MXKRoomIOSOutgoingBubbleTableViewCell.m | 130 + .../MXKRoomIOSOutgoingBubbleTableViewCell.xib | 161 + .../MXKRoomIncomingAttachmentBubbleCell.h | 24 + .../MXKRoomIncomingAttachmentBubbleCell.m | 21 + .../MXKRoomIncomingAttachmentBubbleCell.xib | 171 + ...ingAttachmentWithoutSenderInfoBubbleCell.h | 24 + ...ingAttachmentWithoutSenderInfoBubbleCell.m | 21 + ...gAttachmentWithoutSenderInfoBubbleCell.xib | 127 + .../MXKRoomIncomingBubbleTableViewCell.h | 29 + .../MXKRoomIncomingBubbleTableViewCell.m | 65 + .../MXKRoomIncomingTextMsgBubbleCell.h | 24 + .../MXKRoomIncomingTextMsgBubbleCell.m | 21 + .../MXKRoomIncomingTextMsgBubbleCell.xib | 124 + ...comingTextMsgWithoutSenderInfoBubbleCell.h | 24 + ...comingTextMsgWithoutSenderInfoBubbleCell.m | 21 + ...mingTextMsgWithoutSenderInfoBubbleCell.xib | 80 + .../MXKRoomOutgoingAttachmentBubbleCell.h | 26 + .../MXKRoomOutgoingAttachmentBubbleCell.m | 144 + .../MXKRoomOutgoingAttachmentBubbleCell.xib | 144 + ...ingAttachmentWithoutSenderInfoBubbleCell.h | 24 + ...ingAttachmentWithoutSenderInfoBubbleCell.m | 21 + ...gAttachmentWithoutSenderInfoBubbleCell.xib | 132 + .../MXKRoomOutgoingBubbleTableViewCell.h | 27 + .../MXKRoomOutgoingBubbleTableViewCell.m | 117 + .../MXKRoomOutgoingTextMsgBubbleCell.h | 24 + .../MXKRoomOutgoingTextMsgBubbleCell.m | 21 + .../MXKRoomOutgoingTextMsgBubbleCell.xib | 91 + ...tgoingTextMsgWithoutSenderInfoBubbleCell.h | 24 + ...tgoingTextMsgWithoutSenderInfoBubbleCell.m | 21 + ...oingTextMsgWithoutSenderInfoBubbleCell.xib | 80 + .../MXKRoomInputToolbarView.h | 358 ++ .../MXKRoomInputToolbarView.m | 1399 ++++++ .../MXKRoomInputToolbarView.xib | 72 + ...MXKRoomInputToolbarViewWithHPGrowingText.h | 33 + ...MXKRoomInputToolbarViewWithHPGrowingText.m | 187 + ...KRoomInputToolbarViewWithHPGrowingText.xib | 85 + ...XKRoomInputToolbarViewWithSimpleTextView.h | 32 + ...XKRoomInputToolbarViewWithSimpleTextView.m | 123 + ...RoomInputToolbarViewWithSimpleTextView.xib | 90 + .../MXKInterleavedRecentTableViewCell.h | 28 + .../MXKInterleavedRecentTableViewCell.m | 64 + .../MXKInterleavedRecentTableViewCell.xib | 72 + .../RoomList/MXKPublicRoomTableViewCell.h | 36 + .../RoomList/MXKPublicRoomTableViewCell.m | 75 + .../RoomList/MXKPublicRoomTableViewCell.xib | 59 + .../Views/RoomList/MXKRecentTableViewCell.h | 39 + .../Views/RoomList/MXKRecentTableViewCell.m | 99 + .../Views/RoomList/MXKRecentTableViewCell.xib | 61 + .../MXKRoomMemberTableViewCell.h | 66 + .../MXKRoomMemberTableViewCell.m | 299 ++ .../MXKRoomMemberTableViewCell.xib | 74 + .../Views/RoomTitle/MXKRoomTitleView.h | 120 + .../Views/RoomTitle/MXKRoomTitleView.m | 279 ++ .../Views/RoomTitle/MXKRoomTitleView.xib | 40 + .../RoomTitle/MXKRoomTitleViewWithTopic.h | 36 + .../RoomTitle/MXKRoomTitleViewWithTopic.m | 520 +++ .../RoomTitle/MXKRoomTitleViewWithTopic.xib | 53 + .../Views/Search/MXKSearchTableViewCell.h | 34 + .../Views/Search/MXKSearchTableViewCell.m | 74 + .../Views/Search/MXKSearchTableViewCell.xib | 61 + RiotTests/MatrixKitTests/Assets/test.png | Bin 0 -> 2759 bytes .../MatrixKitTests/EncryptedAttachmentsTest.m | 114 + RiotTests/MatrixKitTests/Info.plist | 24 + .../MatrixKitTests/MXKEventFormatter+Tests.h | 24 + .../MatrixKitTests/MXKEventFormatterTests.m | 435 ++ .../MatrixKitTests/MXKRoomDataSource+Tests.h | 27 + .../MatrixKitTests/MXKRoomDataSource+Tests.m | 29 + .../MXKRoomDataSourceTests.swift | 155 + RiotTests/MatrixKitTests/UTI/Files/Text.txt | 1 + .../MatrixKitTests/UTI/MXKUTITests.swift | 99 + 475 files changed, 87437 insertions(+) create mode 100644 Riot/Modules/MatrixKit/Animators/MXKAttachmentAnimator.h create mode 100644 Riot/Modules/MatrixKit/Animators/MXKAttachmentAnimator.m create mode 100644 Riot/Modules/MatrixKit/Animators/MXKAttachmentInteractionController.h create mode 100644 Riot/Modules/MatrixKit/Animators/MXKAttachmentInteractionController.m create mode 100644 Riot/Modules/MatrixKit/Assets/InfoPlist.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon@2x.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon@3x.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon@4x.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/bubble_ios_messages_right.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/bubble_ios_messages_right@2x.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/default-profile.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/default-profile@2x.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/disclosure.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/disclosure@2x.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/filetype-gif.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/filetype-gif@2x.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_mute.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_mute@2x.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_unmute.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_unmute@2x.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_backtoapp.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_backtoapp@2x.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_keyboard.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_keyboard@2x.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_minus.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_minus@2x.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_pause.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_pause@2x.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_play.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_play@2x.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_off.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_off@2x.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_on.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_on@2x.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video@2x.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_mute.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_mute@2x.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_unmute.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_unmute@2x.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/logoHighRes.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/logoHighRes@2x.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/matrixUser.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/matrixUser@2x.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/network_matrix.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/network_matrix@2x.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/play.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/play@2x.png create mode 100755 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/shrink.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/shrink@2x.png create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/busy.mp3 create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/callend.mp3 create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/message.mp3 create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/ring.mp3 create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/ringback.mp3 create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ar.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/bg.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ca.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/cy.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/da.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/de.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/en.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/eo.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/es.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/et.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/eu.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/fa.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/fi.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/fr.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/hu.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/id.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/is.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/it.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ja.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/kab.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ko.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/lv.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/nb-NO.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/nl.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/pl.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/pt_BR.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/ru.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/si.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/sq.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/sv.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/szl.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/tzm.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/uk.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/vi.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/vls.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/zh_Hans.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/zh_Hant.lproj/MatrixKit.strings create mode 100644 Riot/Modules/MatrixKit/Categories/DTHTMLElement+MatrixKit.swift create mode 100644 Riot/Modules/MatrixKit/Categories/MXAggregatedReactions+MatrixKit.h create mode 100644 Riot/Modules/MatrixKit/Categories/MXAggregatedReactions+MatrixKit.m create mode 100644 Riot/Modules/MatrixKit/Categories/MXEvent+MatrixKit.h create mode 100644 Riot/Modules/MatrixKit/Categories/MXEvent+MatrixKit.m create mode 100644 Riot/Modules/MatrixKit/Categories/MXRoom+Sync.h create mode 100644 Riot/Modules/MatrixKit/Categories/MXRoom+Sync.m create mode 100644 Riot/Modules/MatrixKit/Categories/MXSession+MatrixKit.h create mode 100644 Riot/Modules/MatrixKit/Categories/MXSession+MatrixKit.m create mode 100644 Riot/Modules/MatrixKit/Categories/NSAttributedString+MatrixKit.swift create mode 100644 Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.h create mode 100644 Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.m create mode 100644 Riot/Modules/MatrixKit/Categories/NSBundle+MatrixKit.h create mode 100644 Riot/Modules/MatrixKit/Categories/NSBundle+MatrixKit.m create mode 100644 Riot/Modules/MatrixKit/Categories/NSString+MatrixKit.swift create mode 100644 Riot/Modules/MatrixKit/Categories/UIAlertController+MatrixKit.h create mode 100644 Riot/Modules/MatrixKit/Categories/UIAlertController+MatrixKit.m create mode 100644 Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.h create mode 100644 Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.m create mode 100644 Riot/Modules/MatrixKit/Categories/UIViewController+MatrixKit.h create mode 100644 Riot/Modules/MatrixKit/Categories/UIViewController+MatrixKit.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.xib create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKActivityHandlingViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKActivityHandlingViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.xib create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.xib create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKCallViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKCallViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKCallViewController.xib create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.xib create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.xib create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.xib create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.xib create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.xib create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKNotificationSettingsViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKNotificationSettingsViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKPreviewViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKPreviewViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.xib create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.xib create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.xib create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.xib create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.xib create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.xib create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKTableViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKTableViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKViewController.m create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKViewControllerActivityHandling.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKViewControllerHandling.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.h create mode 100644 Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.m create mode 100644 Riot/Modules/MatrixKit/Libs/SwiftUTI/LICENSE create mode 100644 Riot/Modules/MatrixKit/Libs/SwiftUTI/SWIFT_UTI_README.md create mode 100644 Riot/Modules/MatrixKit/Libs/SwiftUTI/UTI.swift create mode 100644 Riot/Modules/MatrixKit/MatrixKit.h create mode 100644 Riot/Modules/MatrixKit/MatrixKitVersion.m create mode 100644 Riot/Modules/MatrixKit/Models/Account/MXKAccount.h create mode 100644 Riot/Modules/MatrixKit/Models/Account/MXKAccount.m create mode 100644 Riot/Modules/MatrixKit/Models/Account/MXKAccountManager.h create mode 100644 Riot/Modules/MatrixKit/Models/Account/MXKAccountManager.m create mode 100644 Riot/Modules/MatrixKit/Models/Contact/MXKContact.h create mode 100644 Riot/Modules/MatrixKit/Models/Contact/MXKContact.m create mode 100644 Riot/Modules/MatrixKit/Models/Contact/MXKContactField.h create mode 100644 Riot/Modules/MatrixKit/Models/Contact/MXKContactField.m create mode 100644 Riot/Modules/MatrixKit/Models/Contact/MXKContactManager.h create mode 100644 Riot/Modules/MatrixKit/Models/Contact/MXKContactManager.m create mode 100644 Riot/Modules/MatrixKit/Models/Contact/MXKEmail.h create mode 100644 Riot/Modules/MatrixKit/Models/Contact/MXKEmail.m create mode 100644 Riot/Modules/MatrixKit/Models/Contact/MXKPhoneNumber.h create mode 100644 Riot/Modules/MatrixKit/Models/Contact/MXKPhoneNumber.m create mode 100644 Riot/Modules/MatrixKit/Models/Contact/MXKSectionedContacts.h create mode 100644 Riot/Modules/MatrixKit/Models/Contact/MXKSectionedContacts.m create mode 100644 Riot/Modules/MatrixKit/Models/Group/MXKGroupCellData.h create mode 100644 Riot/Modules/MatrixKit/Models/Group/MXKGroupCellData.m create mode 100644 Riot/Modules/MatrixKit/Models/Group/MXKGroupCellDataStoring.h create mode 100644 Riot/Modules/MatrixKit/Models/Group/MXKSessionGroupsDataSource.h create mode 100644 Riot/Modules/MatrixKit/Models/Group/MXKSessionGroupsDataSource.m create mode 100644 Riot/Modules/MatrixKit/Models/MXK3PID.h create mode 100644 Riot/Modules/MatrixKit/Models/MXK3PID.m create mode 100644 Riot/Modules/MatrixKit/Models/MXKAppSettings.h create mode 100644 Riot/Modules/MatrixKit/Models/MXKAppSettings.m create mode 100644 Riot/Modules/MatrixKit/Models/MXKCellData.h create mode 100644 Riot/Modules/MatrixKit/Models/MXKCellData.m create mode 100644 Riot/Modules/MatrixKit/Models/MXKDataSource.h create mode 100644 Riot/Modules/MatrixKit/Models/MXKDataSource.m create mode 100644 Riot/Modules/MatrixKit/Models/MXKPasteboardManager.swift create mode 100644 Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellData.h create mode 100644 Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellData.m create mode 100644 Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellDataStoring.h create mode 100644 Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServersDataSource.h create mode 100644 Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServersDataSource.m create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKAttachment.h create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKAttachment.m create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKQueuedEvent.h create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKQueuedEvent.m create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataStoring.h create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithAppendingMode.h create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithAppendingMode.m create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithIncomingAppendingMode.h create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithIncomingAppendingMode.m create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.h create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKRoomCreationInputs.h create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKRoomCreationInputs.m create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.h create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSourceManager.h create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSourceManager.m create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.h create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.m create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m create mode 100644 Riot/Modules/MatrixKit/Models/Room/MXKURLPreviewDataProtocol.h create mode 100644 Riot/Modules/MatrixKit/Models/RoomList/MXKInterleavedRecentsDataSource.h create mode 100644 Riot/Modules/MatrixKit/Models/RoomList/MXKInterleavedRecentsDataSource.m create mode 100644 Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellData.h create mode 100644 Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellData.m create mode 100644 Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellDataStoring.h create mode 100644 Riot/Modules/MatrixKit/Models/RoomList/MXKRecentsDataSource.h create mode 100644 Riot/Modules/MatrixKit/Models/RoomList/MXKRecentsDataSource.m create mode 100644 Riot/Modules/MatrixKit/Models/RoomList/MXKSessionRecentsDataSource.h create mode 100644 Riot/Modules/MatrixKit/Models/RoomList/MXKSessionRecentsDataSource.m create mode 100644 Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellData.h create mode 100644 Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellData.m create mode 100644 Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellDataStoring.h create mode 100644 Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberListDataSource.h create mode 100644 Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberListDataSource.m create mode 100644 Riot/Modules/MatrixKit/Models/Search/MXKSearchCellData.h create mode 100644 Riot/Modules/MatrixKit/Models/Search/MXKSearchCellData.m create mode 100644 Riot/Modules/MatrixKit/Models/Search/MXKSearchCellDataStoring.h create mode 100644 Riot/Modules/MatrixKit/Models/Search/MXKSearchDataSource.h create mode 100644 Riot/Modules/MatrixKit/Models/Search/MXKSearchDataSource.m create mode 100644 Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorAlertPresentation.h create mode 100644 Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorAlertPresentation.m create mode 100644 Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentable.h create mode 100644 Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentableBuilder.h create mode 100644 Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentableBuilder.m create mode 100644 Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentation.h create mode 100644 Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorViewModel.h create mode 100644 Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorViewModel.m create mode 100644 Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.h create mode 100644 Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m create mode 100644 Riot/Modules/MatrixKit/Utils/EventFormatter/MXKRoomNameStringLocalizer.h create mode 100644 Riot/Modules/MatrixKit/Utils/EventFormatter/MXKRoomNameStringLocalizer.m create mode 100644 Riot/Modules/MatrixKit/Utils/EventFormatter/MarkdownToHTMLRenderer.swift create mode 100644 Riot/Modules/MatrixKit/Utils/MXKAnalyticsConstants.h create mode 100644 Riot/Modules/MatrixKit/Utils/MXKConstants.h create mode 100644 Riot/Modules/MatrixKit/Utils/MXKConstants.m create mode 100644 Riot/Modules/MatrixKit/Utils/MXKDocumentPickerPresenter.swift create mode 100644 Riot/Modules/MatrixKit/Utils/MXKResponderRageShaking.h create mode 100644 Riot/Modules/MatrixKit/Utils/MXKSoundPlayer.h create mode 100644 Riot/Modules/MatrixKit/Utils/MXKSoundPlayer.m create mode 100644 Riot/Modules/MatrixKit/Utils/MXKTools.h create mode 100644 Riot/Modules/MatrixKit/Utils/MXKTools.m create mode 100644 Riot/Modules/MatrixKit/Utils/MXKUTI.swift create mode 100644 Riot/Modules/MatrixKit/Utils/MXKVideoThumbnailGenerator.swift create mode 100644 Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.h create mode 100644 Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.m create mode 100644 Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.xib create mode 100644 Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.h create mode 100644 Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.m create mode 100644 Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.xib create mode 100644 Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.h create mode 100644 Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.m create mode 100644 Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationFallbackWebView.h create mode 100644 Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationFallbackWebView.m create mode 100644 Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationRecaptchaWebView.h create mode 100644 Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationRecaptchaWebView.m create mode 100644 Riot/Modules/MatrixKit/Views/BarButtonItem/MXKBarButtonItem.h create mode 100644 Riot/Modules/MatrixKit/Views/BarButtonItem/MXKBarButtonItem.m create mode 100644 Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.h create mode 100644 Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.m create mode 100644 Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.h create mode 100644 Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.m create mode 100644 Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.xib create mode 100644 Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.h create mode 100644 Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.m create mode 100644 Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.xib create mode 100644 Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysExportView.h create mode 100644 Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysExportView.m create mode 100644 Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysImportView.h create mode 100644 Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysImportView.m create mode 100644 Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKCellRendering.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKCollectionViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKCollectionViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKEventDetailsView.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKEventDetailsView.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKEventDetailsView.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKImageView.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKImageView.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKMessageTextView.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKMessageTextView.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKPieChartHUD.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKPieChartHUD.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKPieChartHUD.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKPieChartView.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKPieChartView.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKReceiptSendersContainer.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKReceiptSendersContainer.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKRoomActivitiesView.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKRoomActivitiesView.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKRoomCreationView.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKRoomCreationView.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKRoomCreationView.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButtons.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButtons.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterView.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterView.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.m create mode 100644 Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.xib create mode 100644 Riot/Modules/MatrixKit/Views/MXKView.h create mode 100644 Riot/Modules/MatrixKit/Views/MXKView.m create mode 100644 Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomBubbleTableViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomBubbleTableViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSBubbleTableViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSBubbleTableViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingBubbleTableViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingBubbleTableViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingBubbleTableViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingBubbleTableViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.xib create mode 100644 Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.h create mode 100644 Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.m create mode 100644 Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.xib create mode 100644 Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.h create mode 100644 Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.m create mode 100644 Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.xib create mode 100644 RiotTests/MatrixKitTests/Assets/test.png create mode 100644 RiotTests/MatrixKitTests/EncryptedAttachmentsTest.m create mode 100644 RiotTests/MatrixKitTests/Info.plist create mode 100644 RiotTests/MatrixKitTests/MXKEventFormatter+Tests.h create mode 100644 RiotTests/MatrixKitTests/MXKEventFormatterTests.m create mode 100644 RiotTests/MatrixKitTests/MXKRoomDataSource+Tests.h create mode 100644 RiotTests/MatrixKitTests/MXKRoomDataSource+Tests.m create mode 100644 RiotTests/MatrixKitTests/MXKRoomDataSourceTests.swift create mode 100644 RiotTests/MatrixKitTests/UTI/Files/Text.txt create mode 100644 RiotTests/MatrixKitTests/UTI/MXKUTITests.swift diff --git a/Riot/Modules/MatrixKit/Animators/MXKAttachmentAnimator.h b/Riot/Modules/MatrixKit/Animators/MXKAttachmentAnimator.h new file mode 100644 index 000000000..31588cfad --- /dev/null +++ b/Riot/Modules/MatrixKit/Animators/MXKAttachmentAnimator.h @@ -0,0 +1,45 @@ +/* + Copyright 2017 Aram Sargsyan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +typedef NS_ENUM(NSInteger, PhotoBrowserAnimationType) { + PhotoBrowserZoomInAnimation, + PhotoBrowserZoomOutAnimation +}; + +@protocol MXKSourceAttachmentAnimatorDelegate + +- (UIImageView *)originalImageView; + +- (CGRect)convertedFrameForOriginalImageView; + +@end + +@protocol MXKDestinationAttachmentAnimatorDelegate + +- (UIImageView *)finalImageView; + +@end + +@interface MXKAttachmentAnimator : NSObject + +- (instancetype)initWithAnimationType:(PhotoBrowserAnimationType)animationType sourceViewController:(UIViewController *)viewController; + ++ (CGRect)aspectFitImage:(UIImage *)image inFrame:(CGRect)targetFrame; + +@end diff --git a/Riot/Modules/MatrixKit/Animators/MXKAttachmentAnimator.m b/Riot/Modules/MatrixKit/Animators/MXKAttachmentAnimator.m new file mode 100644 index 000000000..d4ca8393f --- /dev/null +++ b/Riot/Modules/MatrixKit/Animators/MXKAttachmentAnimator.m @@ -0,0 +1,161 @@ +/* + Copyright 2017 Aram Sargsyan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAttachmentAnimator.h" +#import "MXLog.h" + +@interface MXKAttachmentAnimator () + +@property (nonatomic) PhotoBrowserAnimationType animationType; +@property (nonatomic, weak) UIViewController *sourceViewController; + +@end + +@implementation MXKAttachmentAnimator + +#pragma mark - Lifecycle + +- (instancetype)initWithAnimationType:(PhotoBrowserAnimationType)animationType sourceViewController:(UIViewController *)viewController +{ + self = [self init]; + if (self) { + self.animationType = animationType; + self.sourceViewController = viewController; + } + return self; +} + +#pragma mark - Public + ++ (CGRect)aspectFitImage:(UIImage *)image inFrame:(CGRect)targetFrame +{ + // Sanity check + if (!image) + { + MXLogDebug(@"[MXKAttachmentAnimator] aspectFitImage failed: image is nil"); + return CGRectZero; + } + + if (CGSizeEqualToSize(image.size, targetFrame.size)) + { + return targetFrame; + } + + CGFloat targetWidth = CGRectGetWidth(targetFrame); + CGFloat targetHeight = CGRectGetHeight(targetFrame); + CGFloat imageWidth = image.size.width; + CGFloat imageHeight = image.size.height; + + CGFloat factor = MIN(targetWidth/imageWidth, targetHeight/imageHeight); + + CGSize finalSize = CGSizeMake(imageWidth * factor, imageHeight * factor); + CGRect finalFrame = CGRectMake((targetWidth - finalSize.width)/2 + targetFrame.origin.x, (targetHeight - finalSize.height)/2 + targetFrame.origin.y, finalSize.width, finalSize.height); + + return finalFrame; +} + +#pragma mark - Animations + +- (void)animateZoomInAnimation:(id)transitionContext +{ + //originalImageView + UIImageView *originalImageView = [self.sourceViewController originalImageView]; + originalImageView.hidden = YES; + CGRect convertedFrame = [self.sourceViewController convertedFrameForOriginalImageView]; + + //toViewController + UIViewController *toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey]; + toViewController.view.frame = [transitionContext finalFrameForViewController:toViewController]; + [[transitionContext containerView] addSubview:toViewController.view]; + toViewController.view.alpha = 0.0; + + //destinationImageView + UIImageView *destinationImageView = [toViewController finalImageView]; + destinationImageView.hidden = YES; + + //transitioningImageView + UIImageView *transitioningImageView = [[UIImageView alloc] initWithImage:originalImageView.image]; + transitioningImageView.frame = convertedFrame; + [[transitionContext containerView] addSubview:transitioningImageView]; + CGRect finalFrameForTransitioningView = [[self class] aspectFitImage:originalImageView.image inFrame:toViewController.view.frame]; + + + //animation + [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{ + toViewController.view.alpha = 1.0; + transitioningImageView.frame = finalFrameForTransitioningView; + } completion:^(BOOL finished) { + [transitioningImageView removeFromSuperview]; + destinationImageView.hidden = NO; + originalImageView.hidden = NO; + [transitionContext completeTransition:![transitionContext transitionWasCancelled]]; + }]; +} + +- (void)animateZoomOutAnimation:(id)transitionContext +{ + //fromViewController + UIViewController *fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; + UIImageView *destinationImageView = [fromViewController finalImageView]; + destinationImageView.hidden = YES; + + //toViewController + UIViewController *toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey]; + toViewController.view.frame = [transitionContext finalFrameForViewController:toViewController]; + [[transitionContext containerView] insertSubview:toViewController.view belowSubview:fromViewController.view]; + UIImageView *originalImageView = [self.sourceViewController originalImageView]; + originalImageView.hidden = YES; + CGRect convertedFrame = [self.sourceViewController convertedFrameForOriginalImageView]; + + //transitioningImageView + UIImageView *transitioningImageView = [[UIImageView alloc] initWithImage:destinationImageView.image]; + transitioningImageView.frame = [[self class] aspectFitImage:destinationImageView.image inFrame:destinationImageView.frame]; + [[transitionContext containerView] addSubview:transitioningImageView]; + + //animation + [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{ + fromViewController.view.alpha = 0.0; + transitioningImageView.frame = convertedFrame; + } completion:^(BOOL finished) { + [transitioningImageView removeFromSuperview]; + destinationImageView.hidden = NO; + originalImageView.hidden = NO; + [transitionContext completeTransition:![transitionContext transitionWasCancelled]]; + }]; +} + +#pragma mark - UIViewControllerAnimatedTransitioning + +- (NSTimeInterval)transitionDuration:(id)transitionContext +{ + return 0.3; +} + +- (void)animateTransition:(id)transitionContext +{ + switch (self.animationType) { + case PhotoBrowserZoomInAnimation: + [self animateZoomInAnimation:transitionContext]; + break; + + case PhotoBrowserZoomOutAnimation: + [self animateZoomOutAnimation:transitionContext]; + break; + } +} + + +@end diff --git a/Riot/Modules/MatrixKit/Animators/MXKAttachmentInteractionController.h b/Riot/Modules/MatrixKit/Animators/MXKAttachmentInteractionController.h new file mode 100644 index 000000000..64b55cc22 --- /dev/null +++ b/Riot/Modules/MatrixKit/Animators/MXKAttachmentInteractionController.h @@ -0,0 +1,26 @@ +/* + Copyright 2017 Aram Sargsyan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import "MXKAttachmentAnimator.h" + +@interface MXKAttachmentInteractionController : UIPercentDrivenInteractiveTransition + +@property (nonatomic) BOOL interactionInProgress; + +- (instancetype)initWithDestinationViewController:(UIViewController *)viewController sourceViewController:(UIViewController *)sourceViewController; + +@end diff --git a/Riot/Modules/MatrixKit/Animators/MXKAttachmentInteractionController.m b/Riot/Modules/MatrixKit/Animators/MXKAttachmentInteractionController.m new file mode 100644 index 000000000..ba3d952fc --- /dev/null +++ b/Riot/Modules/MatrixKit/Animators/MXKAttachmentInteractionController.m @@ -0,0 +1,204 @@ +/* + Copyright 2017 Aram Sargsyan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAttachmentInteractionController.h" +#import "MXLog.h" + +@interface MXKAttachmentInteractionController () + +@property (nonatomic, weak) UIViewController *destinationViewController; +@property (nonatomic, weak) UIViewController *sourceViewController; + +@property (nonatomic) UIImageView *transitioningImageView; +@property (nonatomic, weak) id transitionContext; + +@property (nonatomic) CGPoint translation; +@property (nonatomic) CGPoint delta; + +@end + +@implementation MXKAttachmentInteractionController + +#pragma mark - Lifecycle + +- (instancetype)initWithDestinationViewController:(UIViewController *)viewController sourceViewController:(UIViewController *)sourceViewController +{ + self = [super init]; + if (self) { + self.destinationViewController = viewController; + self.sourceViewController = sourceViewController; + self.interactionInProgress = NO; + + [self preparePanGestureRecognizerInView:viewController.view]; + } + return self; +} + +#pragma mark - Gesture recognizer + +- (void)preparePanGestureRecognizerInView:(UIView *)view +{ + UIPanGestureRecognizer *recognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handleGesture:)]; + recognizer.minimumNumberOfTouches = 1; + recognizer.maximumNumberOfTouches = 3; + [view addGestureRecognizer:recognizer]; +} + +- (void)handleGesture:(UIPanGestureRecognizer *)recognizer +{ + CGPoint translation = [recognizer translationInView:self.destinationViewController.view]; + self.delta = CGPointMake(translation.x - self.translation.x, translation.y - self.translation.y); + self.translation = translation; + + switch (recognizer.state) { + case UIGestureRecognizerStateBegan: + + self.interactionInProgress = YES; + + if (self.destinationViewController.navigationController) { + [self.destinationViewController.navigationController popViewControllerAnimated:YES]; + } else { + [self.destinationViewController dismissViewControllerAnimated:YES completion:nil]; + } + + break; + + case UIGestureRecognizerStateChanged: + + [self updateInteractiveTransition:(ABS(translation.y) / (CGRectGetHeight(self.destinationViewController.view.frame) / 2))]; + + break; + + case UIGestureRecognizerStateCancelled: + + self.interactionInProgress = NO; + [self cancelInteractiveTransition]; + + break; + + case UIGestureRecognizerStateEnded: + + self.interactionInProgress = NO; + if (ABS(self.translation.y) < CGRectGetHeight(self.destinationViewController.view.frame)/6) { + [self cancelInteractiveTransition]; + } else { + [self finishInteractiveTransition]; + } + + break; + + default: + MXLogDebug(@"UIGestureRecognizerState not handled"); + break; + } +} + +#pragma mark - UIPercentDrivenInteractiveTransition + +- (void)startInteractiveTransition:(id )transitionContext +{ + self.transitionContext = transitionContext; + + UIViewController *fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; + UIImageView *destinationImageView = [self.destinationViewController finalImageView]; + destinationImageView.hidden = YES; + + UIViewController *toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey]; + toViewController.view.frame = [transitionContext finalFrameForViewController:toViewController]; + [[transitionContext containerView] insertSubview:toViewController.view belowSubview:fromViewController.view]; + UIImageView *originalImageView = [self.sourceViewController originalImageView]; + originalImageView.hidden = YES; + + self.transitioningImageView = [[UIImageView alloc] initWithImage:destinationImageView.image]; + self.transitioningImageView.frame = [MXKAttachmentAnimator aspectFitImage:destinationImageView.image inFrame:destinationImageView.frame]; + [[transitionContext containerView] addSubview:self.transitioningImageView]; +} + +- (void)updateInteractiveTransition:(CGFloat)percentComplete { + self.destinationViewController.view.alpha = MAX(0, (1 - percentComplete)); + + CGRect newFrame = CGRectMake(self.transitioningImageView.frame.origin.x, self.transitioningImageView.frame.origin.y + self.delta.y, CGRectGetWidth(self.transitioningImageView.frame), CGRectGetHeight(self.transitioningImageView.frame)); + self.transitioningImageView.frame = newFrame; +} + +- (void)cancelInteractiveTransition { + UIViewController *fromViewController = [self.transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; + UIImageView *destinationImageView = [self.destinationViewController finalImageView]; + UIImageView *originalImageView = [self.sourceViewController originalImageView]; + + __weak typeof(self) weakSelf = self; + + [UIView animateWithDuration:([self transitionDuration:self.transitionContext]/2) animations:^{ + if (weakSelf) + { + typeof(self) self = weakSelf; + fromViewController.view.alpha = 1; + self.transitioningImageView.frame = [MXKAttachmentAnimator aspectFitImage:destinationImageView.image inFrame:destinationImageView.frame]; + } + } completion:^(BOOL finished) { + if (weakSelf) + { + typeof(self) self = weakSelf; + destinationImageView.hidden = NO; + originalImageView.hidden = NO; + [self.transitioningImageView removeFromSuperview]; + + [self.transitionContext cancelInteractiveTransition]; + [self.transitionContext completeTransition:NO]; + } + }]; +} + +- (void)finishInteractiveTransition +{ + UIViewController *fromViewController = [self.transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey]; + UIImageView *destinationImageView = [self.destinationViewController finalImageView]; + + UIImageView *originalImageView = [self.sourceViewController originalImageView]; + CGRect originalImageViewFrame = [self.sourceViewController convertedFrameForOriginalImageView]; + + __weak typeof(self) weakSelf = self; + + [UIView animateWithDuration:[self transitionDuration:self.transitionContext] animations:^{ + if (weakSelf) + { + typeof(self) self = weakSelf; + fromViewController.view.alpha = 0.0; + self.transitioningImageView.frame = originalImageViewFrame; + } + } completion:^(BOOL finished) { + if (weakSelf) + { + typeof(self) self = weakSelf; + [self.transitioningImageView removeFromSuperview]; + destinationImageView.hidden = NO; + originalImageView.hidden = NO; + + [self.transitionContext finishInteractiveTransition]; + [self.transitionContext completeTransition:YES]; + } + }]; +} + +#pragma mark - UIViewControllerAnimatedTransitioning + +- (NSTimeInterval)transitionDuration:(id)transitionContext +{ + return 0.3; +} + + +@end diff --git a/Riot/Modules/MatrixKit/Assets/InfoPlist.strings b/Riot/Modules/MatrixKit/Assets/InfoPlist.strings new file mode 100644 index 000000000..9625bbddd --- /dev/null +++ b/Riot/Modules/MatrixKit/Assets/InfoPlist.strings @@ -0,0 +1,25 @@ +/* + Copyright 2016 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +// Permissions usage explanations +// The usage of the camera is explicit. No need of explanation. +"NSCameraUsageDescription" = ""; +"NSPhotoLibraryUsageDescription" = ""; +"NSMicrophoneUsageDescription" = ""; + +// We show a popup before accessing Contacts explaining why. No need of more explanation. +"NSContactsUsageDescription" = ""; + diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..dfef9b68d017d7f4d6754faa564d00667b85266d GIT binary patch literal 601 zcmV-f0;c_mP)Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_00007bV*G`2jK}4 z5GoR8IDzs200G%aL_t(I%bnD{Pg7wO$MNrZ?zObm`v;(f)VQ#_xwtr)xDXR3Nx+5* z_)<*_4icg)ZV*iCOMn<$FvQ7($%TnUHwG31ky?!t@p9#*y*&(lN(yq@hC4pFpPc9A z{EpD(moE6Oz)h6Z^$~AX+CdldehgsqAYW+VshR#au60#N*RT(IU=F86pbflt1Q$W96{|;m-p;-VTj?-OPHCPh~{(P6OHj9aG`kkGo#e z#xSZf3w#7ZAOSKV%H{4iesj1naxPHF0P`02fGjvwz0&p8kL+awurciIDBJ_)fC_L4 zWZn(AnwkzI5?cIEAqTt$_G_3Au2a2s;RJl7$t@lQURj(H;eE`hUQbPLOC&tpw(1x# zY4Lf4nKO>w=xV4e&C($v^>oA=(^1MLkv8-AtR5|tpda&)W@ ndwVETHq^VoFJMhhJLL`l!C1LV3d4Gs00000NkvXXu0mjf+r<9n literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..96d24fa955d39ee5612d04101a2437d3c6d5ea24 GIT binary patch literal 1244 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 AW&i*H literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon@4x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/back_icon@4x.png new file mode 100644 index 0000000000000000000000000000000000000000..c6e227a1117c702fbe92e7aacec7e8bbaeed6465 GIT binary patch literal 3058 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 literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/bubble_ios_messages_right@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/bubble_ios_messages_right@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d0bec7c3224f60d815831369e3f792650a1e17a7 GIT binary patch literal 1253 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 literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/default-profile@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/default-profile@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..6f81a3c417c21f1d9bb4434434c3470f595c8f90 GIT binary patch literal 1722 zcmV;r21WUaP)005u}0ssI21g-FT0000PbVXQnQ*UN; zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU&TuDShRCwC#oj+?LK^Vr(-a-N?u2TrO zokBp1z@~CLtu{qqOS^mv|pf0O;HmzNjT!6<0Oa8v)}Mx!wrjhICni1zdIgCMxOyHm>eSEtj_OV{(f ztE;QCv$L$%(8a~Y`}_Oj<0CCNzcz*M`>CW+TPAMpc3WyK$*6VZTq-SC3|Hu$oSftz z4kQ*lKR+**O9IYnio4zJ`T2Qa9?!Prf0toQr&GG<^cxpKH^mYtvBZuml}f+g$Iraf zybqQ+y}7wTp!;>T$*%;Q4qXYlm>vb3u|>LjX4l}1Ez;eevDD%?u|pH!xY*T_{Sola z6;TibiZc9myG_75Na(`)y>I2Z?yeg}4i~Uk1AYbauK)uIB4Dutv4)aDNm%hbkAQzR z-;!F%(u9gE7N8Mmbk1L^rpwDq0^a1GOUeZmUl1)=J76(loMj3JxLU22D1!m#{~Diw zbCv20i-4)a25dlvMZg417flf`MZgpR6EH=<6aiBNOc5|ez!U)!Fh#%=0aFA_5imu- z6af=3m9}1AUkUgdslK@Ps}_p|0e=rTiXsC56ZogFz*o>XCuIqvv z(Xi0wMfK_LPsR6VA)EvU0_^*~8FW7xBis^%0@(9BoVb;i9LFi#QKJB^)oMl-#D$-R zMFDK;sieZ63kC3OHZu}%;jJ42Cg5Cz)7v9pTJW|)xbPpS4mOYR)6)|Ho9v|#UXIa% z4PUafU;@t3p&9eSAIB$IoDs911q<+eKBs@C1=E7n0yZfO6R^ofJe^KKzPFN_?lMsr zh9D!cxc0MfLO6a1@nnPctH-fJu)5d7m;;7B;jgPNjhXpdv}o8&@J#O(_+N1(FFY(B`xg^*xo8SV6k8oxikSM7k*zFIg}^>EU6aw z769b;n+gC6UE~>*;f+Qk0^Un9gRn+C7z~gdj1IsugP1k3R7c?ZPt@Eti#d-E4-eq; zrUtOs3R=B{w*5*sV8B{a#pXawfYFYosc55W0u1G%wYtcE|4p~2{Q#XDi2X2-xHarHeKkFfBM`b8%f4YDiKk;a^m5X`veg0buFr zc^>%G{pxf&;6rRSo7dOZr>CcYW0O&dqm}}Fxm?nM6UI5;_mSI1GD7h#u<;ljFk0MI zH%@fGT3Wue;Dl1qEs7T+W_KDLu!?3vECM!U%5(`Du!>d@xU+!mf`^qtVb+^=>I)ETjwEC10ckOOzF~VzmZ$ ztYU)}k1c+?kPBss0UQTfrI}Q+=p$;k+p*uy^9+ui|DzfZXcK#^)>^_iw?xd&?b*a$ z<=OcDAZNdwIbb0wCM(Z#Al`Uazb%ViFlToONrr&M4+~l&bK}(0k=So%k#DCD_z-gA zO#QZCPVs1Ynt4%dWXXm$5?337Otv*vb6-D`Nsi{2An@lFd;c%a_%HDGvu{Wm4 zW>k9YpKsfVJ&BmC(pLhi^X*;xB=)XrisdXqzP){)#NPhXS{T2hugqlm)95|Hg1@D&wP%j}K0S!TKY>0L|2>c!UL> QfB*mh07*qoM6N<$f+o^25C8xG literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/disclosure.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/disclosure.png new file mode 100755 index 0000000000000000000000000000000000000000..501026d4971c24e6ff06d62a4e5d11427ca44624 GIT binary patch literal 211 zcmeAS@N?(olHy`uVBq!ia0vp^JRr=$1|-8uW1a&k$r9IylHmNblJdl&R0hYC{G?O` z&)mfH)S%SFl*+=BsWw1GrJgR1Ar-fh{`~)M&#c+d+33P>c>(_sSBEox0qiF{9p3zq zXX<0xC>_G1-Da5cU-^K&LLAGHS4XlM8oxW-;W#o0#5(Js!*KaP)A4Hgh6*8p^G%xq zZF=9eA8q-=z`Zp2NZTYk0dYn{YrCTgXFRelF>n|#d_E_4X33S?+&~8~c)I$ztaD0e F0su#}OzHpt literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/disclosure@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/disclosure@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..3920aba23c6389a887f96c7864a05b9cf399c7ad GIT binary patch literal 248 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gjk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5XW_h|ehE&{2`t$$4J+m&OFmo^CUEPGmTUnI;wAIyZXe@QmarfqScpxpX zoxzZQtxiMZa|an$Z|;NyBi-5wHcuWgOz8?~3$bf>nv$0F{E7V*o$idVS@#%{__HK< t4) literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/filetype-gif.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/filetype-gif.png new file mode 100644 index 0000000000000000000000000000000000000000..a4f048f7b41fa1ebc54d40dcdb94c5aed2cb680d GIT binary patch literal 1681 zcmY*a3pmqj93IWRQD_)u=F%ZHmpa;P6Xq6cVhLSm7OQMy|7?jf*Dg+p$|)-4Qn`fW zP-Dq0p%Pszd0H1~u{7yMhdTeM(>dpRp5J%*eed_a-}`&M=lKoGkJ^z78hV~*eE40J&LmnAfnMqp`Y#hJptCXf0fw0AGSb) zSmh4Z8RLZg$qgPQD6@EK0*eVMEBQ&zgn8uuazAtkSf%)XF7w^#d=|7y(k5VkmW`wx z)V?$V0)Y+$kUeSsutQ;Y0vYQKg;BqOP21Ex^?$+5GzWZ2Tq73PZhRQeac=OS1S5^ zil>+neyZT59J>V;YLQ)2M!#VnwW4j->zP?0Jh3w(j?P#xG}PNd~k5E zpyZd$*Pkk2P}L$XRH6G`@7lF%x^E1-Z{McT1x6#si%+v&n`ly(+1t0Il38rFG~*`w znIE0a#z0aU%FDYiNaSY_NkLB-h}c2ddzbq$ScPHYbniR~x*Sn^)Vvv@@jY^@)9V_Zd{P&m)34a(b1Z4f0LhP~cr4-w0& z>T7EHvQlPkY;2yUW{X{;EtYo6Z3ss~LPH}?ZXE#dCPg0JWg~SH&od7u#x&hl&uCK} zTG15_zvwL3t9Z7q?g^VurBY?yuaV~Fls9kRwxnK`OeAV-rL|nYzMc!QgjbE0m04@C zn*4@ktL|Dur%{(7lXWRsq=mD2d3o<{_4W1DYQDdjm8JHiw|6y5D7;=#TT9gwovWDb)Lkk8DBDw>+U_<(+=y1KfY-J%|Q(RQe=$;dr^+^<*|8{55r7~kai?Z$cg z?B0#{=Z=n!G8blWlai9cA|rbZJyL~U7`d)O-(IlZZ0Jr(jX2s)&YY5=vR9+J3D(P4y35{aizE~bKo`HD0#!F5hk zpEdXN^jHPA7`S(IbSS(=bb^srh5eh{i`(mWmBOW|n`>LdVuzQww0&FniHTn`Z|srF z<+pc3WStEnQP10fQr+Yjf%oqla2QOEBpD|oTq?8FwAdj=RaaE>bofuaejT0`>h|G{ zfy&TAsY5aR730&R_Q&~sV$#afebI%@A`yN3Q9az4j)+R?(rh11*6+7Q&mqqEl6^dF z48NoquC71@m@)E-bUf6bClr~Mo!EExumvn;8ebQ6chB42VcA(`UFZ!cSya`Dhy}l$d#GsvnuJ26hfSqt!weqL!88)@SC|AByo~H?d|Q}O0c;ufqP&M=q=lCU;xyqP%Yra z!_8;47jynfAm$}OByA3~i{79({HJc7SeE&p3tsmeXyhDw4-Ct&Mrb{`SSj_Ww-hj5 zVV!;%AWMGXOG>H7HABrSOx-&9SbLO%-#EjZIXHP*W2)6RSC}*QUrsQG0OAC5C?E;U z#W8>+Fs)+%sRKW=g7cw)BrxH=flLebyR>)Ic1fYNZmvuOMLrk{TmVhWDlK5L7a$Ix zw<)Lv@Y;eoNFSLTOB~?@a!9Hf@4wAC{tAdv+Z_?{nE^*y>%K3Ld$aW1iA3a1YklQo zXyrjHfE%qf>86x=0{#FfrJj=g>ELGfvHGrC^Aw-1Gg{=bvdde8Z@4NP7hL1F*E+k_ zx{)yiu00H+u*zBM!Mb=PA^=}S1oL~7nzu(lkK+$mo$nL8?#oi<~#WJFDt3@%03QAox2PjlNW2t+w?PLcJsXzX9n1Pj5K&URzUdp;%@whUVRF_fv%s0~}skyMDqEv0MpH zG|3o#zI2{bs+w+7LTcGVY)38jQ3q=o%j^nbg6B`6`oNEhEEJn-t*sCL1Cfx-T-hem QwEzGB07*qoM6N<$g3{Mr3jhEB literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_mute.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_mute.png new file mode 100755 index 0000000000000000000000000000000000000000..020c67583985adb483388899ef7cd09e3272b33d GIT binary patch literal 2324 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 U!svM4etiLy6x3doy+HW=4>A&n4gdfE literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_unmute.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_audio_unmute.png new file mode 100755 index 0000000000000000000000000000000000000000..1eb9a831bfa675227022b49ddbf128084b2695b5 GIT binary patch literal 2000 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 SwaAYL01r1W%DH8Xl)nK)@Z*#K literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_backtoapp@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_backtoapp@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ece39112e178515dfaebaf8e04ebc408a2673272 GIT binary patch literal 2479 zcmZ`*dpwhEAHRp3a*DSWiCH0)&E_!H9OwAda>~=f9_HA>Hruet`7l(DJwjtVd831r znqyQ?#4DtfV<^NTY7xmH?^JKk^S+-W2Uf81$KN9|;#Rips` zkae)Pb`g$F!WSheF8p#{cR30Nu|P|lB>+@qZe1g85w0P=_AWR8h|~grxFi5r5khfq z0U%5d0A{=a0G$s2U}|B5GX?-ay`*D!1|D|=?Mn%P5&bAW{xD_;RTu#P7$#ac4e@6X zAc$^aiOQHEg3}FT^J*b&91Oma(`~uJ}*0w*w zg)39&Nd|+8hQlKwB482vFbXXYjxaGXf$JgRNTjZip-X3x8APTonZD<{$bWRK{pr3m z5|u%skRhA8L?22R!xReLH2V2`KPQ6}@Rt*r{v$16g7D29I0B{z|EVnu#cZNzEG2|W z^QY5=`eq2sH|Bp~fBAgpJJLx0!jo>E=O_Q~*#GjKPEr^YVby3PUk5V7pC&Z>5sm!e z_Wv0_v@r0^#Q#=^?^XVW3d?LJje-BXGBatBfjD6^NStu6wmilJ&2cD4-27GB>Zu^h z;58}h9=|edj!w{Ip9m+IXM*y$fqH?w+GRvP`l@c;jpDA{K|5bnxQhc<^l7A;7~HP+ zyrhAs&?|5j5f#&b#fpu0H$~qMjDn|hfGYi%W87Cug40hvV7lqWe0oebJ%(?RlBM7g zEMf%W#NoU(q>|+1MPcr)L*w zxVgFQzLFQu+uLVRuC6fUvG>xQJ9m%>#77&tXmxdUKXulM#+eI`$%&ijEYR06H8l;H zM^DGcgB*i{XFK+-4ETi}3e9Hw-}KrbFc_bZ2NY41(KUYtgD1gn#qp((Zyycz^)2--FV_u@mAzrXP^izQS&^zb zGHKvUk^>XkiZCZqhet-DgQRuOiZc1;l3uq32RgEu3-igw_vJ9-?i-V^&ezM+pFVxM zmY<(*LXj4l-A|_4KF{Np-@V1(rqA4Kt{%C%G>6)*>R~*bV_5H5siKgnu=s^pa;Lt& z9-^r^>5bnRNTfOh1s%vM*=|3g`Xy0>CzCPgbW76b1l#*J`A?{Ghy-6uo6)M-39+qp z1+CMW5=i4w?!}Al@yjq;89SlZEKh<>)PKF6_IiMgn${d02s4sr)YR7IX=!RUsi>gA z{Pqip5Pot>75ZTN5wW)Wn%deg1r??bw?69^-F>{$bCAbc2oP^;bbIN8d!PtOoIv$e zoN!fDR#uJzaSgAKe#K}=s#T-p7&Saz)V))^*Vg~ssp?p2P#b?({XECjm{(c(ey;cW z@ejp!xdfjCU`$!G^ns@1HbL*Ke#oQN99u&m)FK$79EO+!Q=5viMC&52Wq558aP>3=9`%x z7r+k-)a6x;1?_)eQ!0tzR&gUykmdORpvyo!buJF?dhFPz$x6cA$5mHLbeEMcN*~q# z)A>z2M$6(93KCA-^M_n!guh2ES@&U}a(JPrUO+aV#|y8n*Rr}|Ft{CSMU?2U?wM*8RXBLfu`7220BT`KEs-Y%1N_UzeK!Wwi&w=Cb~`GBA$ z?cV7HvE`ESt%0hpEI@&Ag<27*(|9<~Jp*+sco^`Qz_L-WZCLTV*nz z8l-o*GKbaW{jhW?jYxnZ7F4oz!~VIer{{28_}ma9xUcmJ@`*`LPmk3{DS`jMfDXYe z%t)bna6-~qFa4f7=#5mG37m6N3v&~I_CGSKJR8`}U02%Jm6J4A<;N^{77rpmOjFYS zh5CThxwNI{?b9S(r}-I7J_#K(q}ZTX&YRs|9yuncvj%W&0WAjx zR8C8IF-!!Z8N1$1EHJHuu>=BylrQoNLqit-wxA4Aon3*++M{4Sr85QE|G~qyt*5bG zM#RSvi^T@n*x0CZFQdk)uhJ(U=icByDOy#YyS8V>8$w#btyj&IgN`R^?maNZqUYqb zi{9WpzGB+vm7!B~=5tp!*F5$sVeLBh-348b0x5+W7%@oL0cVHI zDJ_<=X%_2LvhnR_uxD1#j^y8L$hKW{*;-Dh(CJy72b-hTl~&#f F{{|y&P$~cb literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_keyboard.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_keyboard.png new file mode 100644 index 0000000000000000000000000000000000000000..8fbefd8529bf5ce0c2b8205027d2c74f564e5ea7 GIT binary patch literal 3229 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 P00000NkvXXu0mjf=R7w4 literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_keyboard@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_keyboard@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..f6a01a8f6b055971cd2093b363f16803bf50e2e7 GIT binary patch literal 3700 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 S8t literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_minus.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_minus.png new file mode 100644 index 0000000000000000000000000000000000000000..93e8cc46667518ddd8d05d545c3195d1c4b8c99a GIT binary patch literal 1337 zcmeAS@N?(olHy`uVBq!ia0vp^k|4~%1|*NXY)uAIEa{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBD8X6a5n0T@z%2~Ij105p zNH8!kMrMXYltlRYSS9D@>LsS+C#C9DVstT4fPE4;bsH1+JHo@{EISEfi{E8 zw==W>t3(ll+GC>+vK+}V5TAlYfnK%aveAbJn;n$@8;SE zf14#9PZGJkH1fRV_H@M>p=S;g8g49;J9^4C>rjtW!pWi8lK;3Qm;TkX&}S`hVKJH>wTuIkT1q>3p^3dL9zE@I6yv z(ErqLWdes@|4zG~Jx^y{?C&O> z`S*-}M}2Pe?!L8h6W2;6&Mm41DP|Mq-%PxFPWVEB)baNRg`bP^_cJ-Ef9@~g3*MrT z;Lpf(_K?xqM)kfQksk%NPn_m4^GfQ*6X9pKnf{+*zfo0RfGIaYMjLF}u_ z3M1XEdPfs`P9K=uaqsP(OGjL4tvUa6DB6U%wZCeKJi?JNOJZfog1P%&?D@au{k9FJ z>=(@HCv^n9zZhg>R9N#mIj&KDOXRUICh6IpACH`!|Fj|eapkT#x8H|9STs>DGLGG( zcjc#roqHPeyQUuu>D=|;ap(71%V;6T_A@?>yuNN~QC)&fr?~n2D>@a5-DjV?cr_{U z>%NDx4`j-Jkm&Xc5RdyYwdSgC(!H!r&Huui+4srB7sM>*ZdZ2ryyI-9R_V3Rm)vAN zet4qnDt(w~>Wws0)iS9=tCMA~zp{?{7UcAxQ(11F+WHnPVU4>P)w}$g`m+5l*UV>7 zn5np}%cEX2Y^|cG!h_QJI`hltGc4C>VVE=R=_;0OTN~oGD=*hbo&M?aO6Gfi(q!x{ zJ{*}Vdg9KWe^O2yMxW09PFIS$E>T|7z3ySkfs=L%d|TgcIuU&TW0&`}i(hg={_53j zj@>-_*Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_00007bV*G`2j2$` z5CSm}IKf{400HPpL_t(&-tC&(ZG$irhCfi>!wA`+=>}|2GC}DiAro|jkO|5NWP*g} zJ~Shc3DO5nq^Jt!W(OPbOGtSE=kvMPKF8h<(~!>$VwYYtfA1Yw0U;2`?|Yqq63FE_ z=o;A55F;V*0mMDuPl3IFjRFz@U%*n+q@I8+kUa}z3GBoYCX@r8z}aN5N5Ih_#LZQY zGL{jL4e%{NY+8YgwbAWby8*#GRH?YyV2dOpLLg%&LoE`CiEYqYmC;lnO9{Hb5|9#j ztIKA7LtNMbaZ3QZt}zlBPO(E$bJWGPel@Pxx^)s4YqUscF$8y;(ngRN`xUowt*awP z5Km>12zL~!Kvq)$38wNftn(n z-*7IzSOjrV)e}@8XUhr^F^Zl^)9yE;>LQJ;#!gn-wn9qPCmd_dX!H!fgITvDS)!GD zckLlYVx7g40=so%wCf2mqbeDhOw9)wqf>Xc&r$bGRf<__-T@Ty&5({A-9E1MNTImF z$ADl?8S%nE9n|d!fY%4Jet(5S+xf?vuQ|;iX7mUA#AL+zIZz+~0000YVP literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_pause.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_pause.png new file mode 100644 index 0000000000000000000000000000000000000000..4120c3c32ed44bc569d0693a0e49aa4f8b5dd997 GIT binary patch literal 766 zcmeAS@N?(olHy`uVBq!ia0vp^k|4~%1|*NXY)uAIjKx9jP7LeL$-D$|I14-?iy0WW zg+Z8+Vb&Z8pn}NEkcg59UmvUF{9L`nl>DSry^7odplSvNn+hu+GdHy)QK2F?C$HG5 z!d3~a!V1U+3F|8Y3;nDA{o-C@9zzrKDK}xwt{K19`Se z86_nJR{Hwo<>h+i#(Mch>H3D2mX`VkM*2oZx=P7{9O-#x!EwNQn0$BtH5O0c3d|4@L;p!@;Rg)2@GUAPZ!4!58k)I z)_zAEc((3+IN3L%Gi#*J0}&hZJrnE zHcea*ovEWLxBvOZ^f!hM69O9Nu4DK%uTG7{_{rtULOY>-%uWK&_Gq16{pQ({8Ta<9 zf3&D{IV2LXz;RdYZojw%cel$KN3q|rWA^Xk3HG|geR#*I*%nqeHgC;z*|Fqs%9EHt zyBTd|^Vd(W`|BCS?zGah;`zP>8#^SIEp2QqI-aL|ZEo=VWp*15w|sS5p|^xx>|=2G zoZD^M@9Xv-d6r~wa@p_y6DB)xWI=Z>(`Kn_aIkSG>Md`_Cl98^U5e z8h!`urgBcVlGR!}uVl_o$$h@RPKMiCzqpeg^X&Y$38hYUtA2ex%`~&``(pLEla+U` m7D$~3bhvKh0cX~-Ell?fO|lP{wcU*XB}z|MKbLh*2~7a%Z5)vR literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_pause@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_pause@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..93e8d149b4870d890a64e6957751b96e54120be2 GIT binary patch literal 308 zcmV-40n7f0P)_2<3YH{`OKDWFsqVBn}~v zwJC`rD3XZ)sg((A^0p|Fj4soMD3S?m^0p{4qAPFxK}7Z%#;%Hu_Po6GwG=Wa@9td{ z+f+#wDKZ`^1UYNYAIGTfy}R~W5HbwXbf;JoNGgFTwNPY^kJNfIq1dSruHWDHD>Xum zP$Sfc-G$L>DU9f~=(Rr5f*M&}jf}&Gr;G!L+4}bXJq;hURY?1{;e&la`&le%8vTxG zdm<2Bp@_}M*8)X+dE4Gl#Af7cfg--VZEr6&z3?+0iLWUD0000DSry^7odplSvNn+hu+GdHy)QK2F?C$HG5 z!d3~a!V1U+3F|8Y3;nDA{o-C@9zzrKDK}xwt{K19`Se z86_nJR{Hwo<>h+i#(Mch>H3D2mX`VkM*2oZx=P7{9O-#x!EwNQn0$BtH5O0c3d|4@L;p!@;Rg)2@K?XPZ!4!58k&^ z480FKh#bGa+hx7!S__VKE7$dLJ$j(^gYk~+k)!+<^%Zt3>9Q&-5}(7$E^a>e=vmhZ zN46>MtiHF@P|(!U`}Rp2i%$>Y?!CXa`@e0&sa1S`UO3E_cwuU)d`RcJYUJfc&5qYU z*-tNjpZ)mIr^{(Gi;lnjvHi@m*&_UUEk_EH+Z2VO*J|P1#S1Z;O0$a(J-jS;L$EOxk)%Hx0GYE4Vf_c8Dmj9g#ep z`7V;@UG%*AZAFjP&vHp@Y0y$MeVZ{O#2{31_EvG}-%StB#Y*L<8nPDsXLgA_abe41 z?Zh?r9yl0v#WxqL6+C{qv}|&V2**J`VF|kxlhn(TURw*+?$>@Q&M5IuuT`KiK%M=V z?7FK{)#uB5o?0s45Tzi<=(?~f13d%&ca|ibJ mmN50-owi1N>gV}Z9~dva*v@^a*I*+k6??k+xvX8H;{*7H2ybaB@RJkh;KKfNC!Hpodw`F780 zP!mK|?*CHFY^er8Aln^M34%f@K_QR|P$(o5lmf{Br9vV>C6EYEDFg}H1w_PNW;)8+ zjovqeVn{n+TeS#+0~A5-t5|#4<&_(1kxPePT P00000NkvXXu0mjfF)(wz literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_off.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_off.png new file mode 100755 index 0000000000000000000000000000000000000000..dbfdc83cc27904e3b860415605d51fcad9a36366 GIT binary patch literal 2386 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 UTa)DfU9pHDFLl&PRo;gD9|fdKsQ>@~ literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_on.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_speaker_on.png new file mode 100755 index 0000000000000000000000000000000000000000..1022e8d9882a488d1c23b7365a103c14331d12f7 GIT binary patch literal 2644 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 literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video.png new file mode 100755 index 0000000000000000000000000000000000000000..8ee589aaa4f8885beb2b5181ee329277ac57b8dd GIT binary patch literal 1438 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 kDfh1zB8?y*?NUleH!iudbT`6MN(l%^ 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 literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_unmute.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/icon_video_unmute.png new file mode 100755 index 0000000000000000000000000000000000000000..ff47d62565bcb368eda419615fb4f28e3ef8af07 GIT binary patch literal 2849 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 YnHk6OC=P-d{^s7*pKB?@6|Exw7quMQl>h($ literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/logoHighRes@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/logoHighRes@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..c4b53a848789371ebece7760855e2fa042afd981 GIT binary patch literal 4060 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 literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/matrixUser.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/matrixUser.png new file mode 100755 index 0000000000000000000000000000000000000000..09496113e87246ea1e0068b13589f0cc2f0e94d3 GIT binary patch literal 373 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85sDEfH31!Z9ZwB1};w*#}Etuvy*qPb9E4D+bZX%XeE0wKucwc zoRdw{^MKtULW`zkhze<0%{!{vw5B-i>4g(d-t1j%+xbKNzy1A~YdnVycqY8RTh{yg z?|%Cqv)=H3THBC&`+?2*-x7@JLYYSq4$+hay RTwqW!c)I$ztaD0e0sx(kkhA~* literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/matrixUser@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/matrixUser@2x.png new file mode 100755 index 0000000000000000000000000000000000000000..9282f3d608d2c04e2233e2fb614003a488b8ffc2 GIT binary patch literal 1515 zcmY*Z2~d+q7+vHHhXbe(h=mwAl@P8L2qA#bfQ1NBl!S-^A%uvKNd7=L#6Zz!6QUpln3SLrq22vy+ooWP=jjgXjT3!zGRP;mYzP)hCb$K(ZMCyi5SrWe{%;0FDg+ z^I-sBivZAPm9-wm$r3P@519?X_8xgCT;9JO0e}LT5k!JWL_aK*8EYLL$&8>`b7NUD zHUKy-RyK{LLE%Vl?5Q|5mg|bzVqj%+IgCLeww#KK}QiHV8UiT2jacsj<`+1VLmV~4S`L(3Rwc2XP^&PB(u z_kI-lN5_lCrp7Z^5Q7*H;4Tg?AqpL}o_x%qz%^Rdz`R8}fp9f$dRZFqIRyXIs76yG28 z@(3cq77Lwy%{;Wvi#Ix!6?fv?civar)$O(8(&HZEFq9!2Mktkf_OIx};l-BwGIFY_ zvL+92v-)nwi0(Dq>lzA!jEi)?b1!gyua( zm-(5Q848tp1A-u*;NZfbpdg=!h>DuGt8b*zDgu>CJ=fCSUOV`puP>{qzW&9r&`@W8 zfB&<;D4axS)^|Yt%|*w?_p;Pc4bXxrF)) z>`OU0{GuXwc5dz?;5lfh;9dT$f1lM#Mo%H=GB-DG6p8k`xVXe8C0WXz#Wz!Pb3V_H z9pzP2pok~(Zive$-jFpOe|%mdxpn)tr64}dBo&sAs;sPRY-uqoC@8RVbTnUFT;vM` zCK8FHv9q(TWn^M#C_g#;^y$MtR#u+vh9eLKZEd(!rSiKm6O)KcAv|$1du0U*eR&j< zotG!*=)fBo8C`kyEaXmm`{mJ5^61D&KA*2^0Eg$7mm8sHt*|(pU|@h~num6Ky$W*5 zTuhdhmpP$KW}Snhqc4$o;o_ZTZ8Dks!`fQX7>zH8&=$c3*6y^^P59fE!&!1#47!RG&-9ti3dVAiyX?#95 z#yCcrfT7n%VJ7Y-kNyBloD)}DbNl=K2C@VKK|cCoc9RNx@OnW>$-b17loc3e{F%6qj$!TYF}nixMsSIXr_P9fQA}kJTblYrI8Ql?JaTPfl=W=v2DtEE3Gq~ zRgPywn^ypDVt}`BaSzlM!RGe+KChvEaNnkZ@aFdJMQ>J8!I8(2YvdCVhEx)99$*vnDh~iD_wR(t7-F`b+}h{Bo)^EKIuuHOrWd zp)TL)OuSmB+6Kzj=u_>I@s%!mms=wN*7YQCVQ;QmjfCiH>Rz65W5u5Sm!3f#vHn2w zxpS!>0$AMD!0z#sG-i78Y(sXeP1m*^6Np!c9q%ppX{k8Up_*R(;|~T0X;D!GGc&VD o2IG}?GvxM_XQy~`^4)uvxkW3Gf9mN?`Ima|>qA~Y9SF<#8yGT%2mk;8 literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/network_matrix.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/network_matrix.png new file mode 100644 index 0000000000000000000000000000000000000000..cf21452bc8d7d69222714e21ed3b4ce9faf2910a GIT binary patch literal 535 zcmV+y0_gpTP)85g_B_^I-r00i;Pp zK~z}7?b*L;6hRoq@y~@Af(R*MIw6Hb0%_8PG=i7V#IK(A%@fp{!iklIC zhYh?=a&O>qXgc_aZET$uwvYJ}O4y_5V9IeH_bRb;tViyHn7>Zb#q|b!^D##w(3Q$V zw*CQGj!hh&pm~bRO+d$o*e(U`M4$K)-!Ow)m`ZdHae%kj#YAkMxkPgti+DAvSHDuT z8mpDAYYo3Enisg5XqIqP(cEpaqQjp0Jv=Ep@380infUv7hVM!24c=Ggx^kY^(@srn zKW>H6diYeM`!Wo}u%Nw~*ipqlj&Gi-9puWCK{IFuJwMP+gZS7fb4SwVeT|yki=5rR z+^tH#m%jWN%b$-bLG)YJEOT88m}t&&^fG002ovPDHLkV1ix#^FIIp literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/network_matrix@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/network_matrix@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..64d6f4891e951fd344e8415a6a55a7da51087c5d GIT binary patch literal 958 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 z%0AhdB+91d?1VWPX&oV{w^`UvrcR?&hZn}Pr3+k;MaAMfo_RC)T!VJ!YMIvJucRI2 zj*TappGnDw>;CrI{}S+N>{ymI>c}6U{z=g6izMU`sT7CEOq*LBy%*Bu3?8i8@aqm! dH)*u~+AI<-!CAHp@h=Miu!oLf%Ir^G{5Q+WXZipD literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/play@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/play@2x.png new file mode 100755 index 0000000000000000000000000000000000000000..50e34bc240efbdc14d499e65f38a227b05e0c646 GIT binary patch literal 3705 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 MxbqNRqG}QRA7UNfDF6Tf literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/shrink.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/shrink.png new file mode 100755 index 0000000000000000000000000000000000000000..ab04eedc06d910dc2987ae8bbed1c9ed4be81d9a GIT binary patch literal 226 zcmeAS@N?(olHy`uVBq!ia0vp^JRr=$1|-8uW1a&k$r9IylHmNblJdl&R0hYC{G?O` z&)mfH)S%SFl*+=BsWw1GEuJopAr-fh{`~)M&#c+d+30eBX{lx74W^F5$m2Xmt~dUW zWIo~PaE9~9@qdrm?l(qozo}WlCSb<==)Q%?ltWh(vl$*rMu@~SC*{RRr8N9tozbdZAVzI}r3K8GJY=ikMmywa0d;Q#}B XgId?V2zCLWdl)=j{an^LB{Ts573xrZ literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/shrink@2x.png b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Images/shrink@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..43021be827ff1961201fdb67f0eac946d705663c GIT binary patch literal 287 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gjk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5X4tlychE&{2`t$$4J+m&OFmvz06|5@J2D}~rgB$)cY-H@} zToBUI(%SIjZT+W%CMKT^bQm7`81dF~mnt}N-)Q(@CMlMZWXQdmRpE{AqKD5LXUr7g zcBtUy6sve(#6F)@%i+&tj>Fp@7&5PCRk-7K;KTfY_6fO+_KhjG{T%Eaxj(4hR(Ck# z%*#Dv;Yt=+6@i7*HMkvawB1PYT*s=gN8}`vbkf&}NsT5ZS9Me!`8~xN3QVskE&6KE ickLu|9-~78Bg4&*;&TZGyO#hx!{F)a=d#Wzp$P!q(P$9> literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/busy.mp3 b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/busy.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..fec27ba4c5952980bf43e83fc52203af300b02a2 GIT binary patch literal 24834 zcmZs?2{e@N1OENYVrDS*CA+Z;F@%s9`;zQrtr`1P5n51Y?8}gSFZ&u<5-Bp4WKSe( zSrXYomP#@IneXp^-t(UGzRtmzIp%zx>%Q)1p8I;P+er636rg^ISX)`?QGPQ40K^1$ z$3sa;R#8P(9*zF*)&Kn<|HY90Uw{7Zq0vo`Ta-s|%J*Xcz)k`*2wDb4RyIx^UO|+I zn56VMw1UzFwM&}Xx&}rjW){}A_72x@E*_q~ezyXH?%fZ2NQjDwPfSip&&tVvTvSqC zRZ~~r^t|;|dnc*)ZU6Adhw;g&*)I#rD{JdpJ9`I5fBv5S{{o8sf6)H-1o z=wl879weUZ^GjhDY_~HXFkcchdGTQ;EHEPZ<22YsVbJbon95566b$5OFcqFUY5v^# zLGA@TVumdK4&T+9xeWpjxV6mPDEjYx!@pEZCU`@UMLuWcfHs# zg%_erTzxUsyt3B-2xCbOuDDVEt^-Ao`0eiJ<&ibB_OEIWl&{MXK6;v6ExBDmFycxL zc^{?C-MZoNZYsjMrf#kJ!*{KLKpYraoLQ7YYVdnoe(y-s?=8v;N>9K?AuNjcO>af0 zWPK8oD5-mbDmE;gjHv#tl;M~2UDV9w(&?QJ!P7@%iU|griFjy??0;~0L=;ZWbg2@= zg!F8jYG17qWk;(R$UjTaG!>K&!=RbrH_cNq4L0EtWzf|2(e~~TcIgITu?Du-r{=Ki zk3y_|&bQ7rwT7M)9s+=*BspIDpG9OAzQ+>!b+3wDu0)7lm6Rs;b$l<(ed;fr3%$zA z%aD=!UDIEWt);3@>6@&s$A(x9t%YTuI9|B=d>jDes;b!EGDrLe7lH71m6u(r6k8jsg|PwBdTzsavl;OB+}?0U-EXn*+|=iDk`X!~xj zV`Jv}-1b2@`8aTEPS%{~=OJd1B#(}z`D)Cp!^wmzl0>LVLoJ$wf2t-~?b!K><6I7D zxHN?)qqTUZk?%grFto7;{e94RNZul!1Os3TF ze#PSp0<(*tgr*3_;7AtR^G*&?2@=Or+Q<8BcGVhuDDUO63sYoZf;7;czZYdP%X(Mv2(@ctt{yF% z&nQu+<^51%#n&{o<6&nf`H)Q32F%kyfU@Y&|D6whIN2W^)KT^Do>yF^1H)i(9_P&r zrnEfd5HNTI3Jd|ndUhQi&!Z|j|w`UGs~L zMR=Z~o91Eo)&beT8334LdhNUk(*Jip6u2rqqrf4E5@PR~SA7u@XcU*cvyJ*UPI+~1 z5E%cIbN3DpZhUc;&sS3}9xwJHkuYSEJP@D$J?32!?6dBzrVh=&o!dv`k07>SG)h&b zT2ZT5ht2do>7P8HCma!dcm}#619&M|2C|+ilhaGZPAY~62qn-2mwtxM`(|e zFq@6Y7aZebYLSanP>UKlqKB{Kx_;BCc$_x-Kw7a1uT-HOsSToyx^19Tk|U!|tcO~p zk!s%P89V=Kef?5fpYS$^QPj*<{#DQY7l}_-v?iun$rI#l@<9y%*n0%m(9}qDm~#;Y zLU%E{Cmuq6uH01NkN_Nu(@MT%fZigy8_hQ_nqI`pr)6srvvC%^{&Za8ZTRx%c)EWf z7khE~>wa&q|JB^gLP2kEgAia=&`bL9T!{yOzJHM-1A}0$;d%F_AaVv%I-eS9oXx&>q=ggr>MdVnKc4be<335xqtj_5nfNck$;KW=vGf z0$-|)qsm6nR5*4#JPfBwIgy|bh?3Q<&8q~utj3vfs!4PBXqb1n6+$AHOyNo6S< zRaoc{OrP)k{k!~4(?z0iLeixkrT2C*?0dm+m`tPH)dMZQ%et4}Rv#rsKe>8y#us=TQaC-~ehmR0EbD`bjIQC-DX)SZRq*2^z`zhX=O3YP|Tt%z@_ z%=`__7=OG)h2sMZoN&&yxLEWiQB1GBY^D2PUVr(B3p7&$&Ue}?>3R~~)+SJJIVH`! zIz}iYXHc?*=3`BL;8}u;R%uauns`WmB}bl_Yeu4t_Mle)P$;HjVL}+?cWByU}um9IKMzIAfU{`%IloX?uZrrF0otIE1|!E7|;m3=0C z-8zq7r?F5iCkE*Yy1*>6v@*66v!qp4l#{^HqrF%qXBQf_S&})n$@hZ6S#sx0RzVi? zOKhY0&5+<;QeW}6hpAb-cfsEgOPo;1NT)f8kQx-py^+xU>I-@__1Sb>%j4|Vb#CXh zrz`Giutb3&?>a8DJDC*6MeBj&@t1l|SW81|Tym~9tCzJT^d|W*crn5!#ycfU#FLqs}#nIXtN0~q6z ztT8F+AP#bd{0VLeb#X+y-(oYD=X~jvVk&+wOwrIHVxUBV)cV{`8!n4J5qkV5>x$)L zb9)<0Dx5|H%?ca)S_cZV9D&RE$Aw4@;OOQiz3jW2bjerZh7zxozg>uBU$Yp8vPw#3 z3HF+G2r(bUtf6%;u=JYnBfUmnB-$w*)8KXQ^<5>pRbSugwz`=QKT2@J)-yM7$}in( zLhJ~c@#68tmR7V{Ts%OBa)ILC>+go9%ciY@M(Sq-LTV4sUH@@w>&+(xLKwqGRqfXt zK(f6%70xsw7IldZPIM!6MJ!R^48^%chJ3)wTvhC8PQPp$dYjwUE~W(V4{NiRx^{iT zle8xyM*AEBWX@^{(_w^-x>5Nh*f9bC7V7q@+O*IrkqK_LsOxZ+yZ0vJlrVZSb)nH2 z_X;wbc@c93g_U5UWn(2o0opk1M!fd0OHGH9$FXz>1FuaMM7NqtQVKhknW484B5jYQ zTFyBVy34@snS=u7VwNyJi8)HBG^3(b4$>RG2Xsh*+wW_;pRA>n>ozAu?3~}sm}}XZ zw`sODCvK!*yB!n~)6P-P=z&pwi-yvl;k1pV2LnANy>aZ&b#KqFJlD!lV(wDcyl4NK z-`jkbYB=Fi8cRQYDAQDIn0LbzRMlyX7uKV};G@-ILLA=eO|ND9eW+l2%PFliO%*O6 z61v64?!kq@OhT402keHklGU(}C7W3Ho|v<}w6Kp-q`~b}JFVz$5N0LI(Icr!@gkpz zS1(h!&Br9=AOA{!JnOt7xXm*tbxldI7zB1A>c{SX9A^Sz)<4eC=X@T6c zLJm5|3^X8^qNJ;diZwXQNW6mREPl02=Us#1=BFz^j9Ri63OJKoro*m=u_bmW6F#wO ziPFKRHNgETmV{W?Kouf7j$gEOP3Er4W(HH95yOu#i;*uc+8#-2zNmH`u0rNs^`?$$ zHUQfaq(dTHFiH5811tiH$S@-4N`EtWUHUdzfg>5?Tz#Q&N61FT$Jd!7`Nl8hb9!S^ z?uTzJ0xNkUBDAzN#ZADp8F()l{f9h4Fs33k?6oUrP$$N!OZ@?f2Cv4KA;NKEHjeb) z+qP~qPCu--h5{&nx!l#bc>F7hlL7PC%q}Pbt-6bUc61e|8nQV;-w_~HpWH?jt_}<{ zXJdRkY@{fw|^{zkk@|cP9g3rWin;2Ro%9GDBjxb{xNKReb$c901B#2?_fPj zw298X}Mk) z=xr+IM0UBc=8ElcaiNjGFr;A7sPVE)*DA}EZmF8`kt<1t9ez(gIK&@bvU!&Fv7pP$ zw5-O*ajPP9mXI%C^tt-UXW#0;7iWzs9-BO^)|?;kyiIVAr0vl145V6U6%M!GOzBPb1~ z$sX}$8MHJ+{zlV$din`h=hb>vVAE&ctTlY|Q2Eh14?rX-0$!w0P%Y^I%G|@x|8Hu6 z8{54Gg_3qbi=;qM5a|L4`XP&`Th>owD=d)vzz(zZ>h-_2`nzsVlu1UzlA zh|tgl450OvP#LykMN?kb$N|n!UiqMUZxSZr{cH7M;3#KnYxb#W>%=#W<2xUZO_)JY z(mWrMGt#NyA{g2OX?-f6*lLqG$|{Mo?y` zbr8w@x7dDnw17N0n~U}Qt5btD%~jOsrsB`oiW9wOb+Xz+&G>Nn+f<~web;HR(zVTnn4n`jnfIs|us-d=ouhqnp+m4BZGGl({TSwR{E(f5D`^9%9jNw7=|zRi%pFwxCF zr}oFZ3!(G1clJo7{QfOEb42u`wXV!_|C9(+I48*Pd?6>(hLKhgRk*kQj%)1&2VW+$ zp3aS&Tnp~#=v>oyeCSq#M&hN>oETZOIN*rBjCqCLrK!6h5tV`d0_39gFi12XUKkAl zR4#}QON;OYOxjM=4;#w}D(@L?Z5+0E>3_>KzWu1oro)qmOE#}*&-|1H{M5(Hsc^q; zG^=K|wr6TBwDWSuLCBn;C<0WJhqa@^sYH$_Qk2`ECEzneK-)%=95GB^#a-iX#J;ow zyPBh8SaH{|&Ga!alATJ>CpyqW^9tB)+4u{0#h7^mhwd4_WrVbJjwelqe#gO7u5YnvIj{wx1MwcMEqS|?X&Gh8J|ndp|g)xX%FL&Kdj-=`HAHVt-~ zN_XBf5BgB}^w!Dszfxlcc$AGl3d)5bct@c1xe`k4-wsT0;iHTq%(?h(V_+l%4bf`QGVNx;h_r0h=BatD$3ITTWfTP@DCK8 zw(9T(;x&hJcfBr}-mdGMdwsfK41ibaKR@*c@jSRXR-_uaE9jg{h)~R21D*QE2Uiuf zD!1nHz9|+!Ay^7}LxOZDS0oC(TmT%8)1-tXsCA-OFa_?F-|wnhudaV0{|(;&z`5}+ zXD}e21h_*CFWVD7MMb&mk;FBm_74rL>^qrogPj+BDX<<9hZv4#{P7Dno5Hu+3kCIN zlpu!vu&)~dr*UTn8R!wrXbT&3yD$xhs| zMgh6`b@1jvci!a zBI5wQ@7_GH^O*yY`>SXh>>cH>c3Xm-r{jo~M{Q&PJG?e*_ z12y59b$fg)2is3QPp}Z)0cH%a(y(#`&CV<@By`bhRUTkq1X83+Y`vM@U0HfAxUu`I z${xS0ONCPa;7C`qI$R|shwxaZz#Zy5DuKJHo&AZbKMK`sc|{r7m<0t``OR+HVHx5@ z`F3{Et1ehC<#fF3`DZbZ>}Jk0VV-~lEl5w;>u4Q*xc_J{LSy6T$I1263PReP%qKV( ztgzLR84x^I;rT^6XI_1xO+@>3-(e>b0>_RV@IzZt&7HRyFpPE~li zK!sBW;QrK*gqlN0Db?z(>H*Hj>v*{#z92q%qq2YHK!jQtj9Z$c=O14UG{^kbVe8y{ z2=L0SJ@6O3{nPcl->DNVwb`Y?7$DvJ<=k07OkN_IDI!z+5aBcxms|d}`6sNzCQc~P zq4!MA3f3v((WkSbn^HHI2SjtRWf{m*uETQ36!7A;5%p8B`g!x`x%+Q^2_)KB^Euw z04z?4xnjmeI7AGxfOzj=7hy4q-XI3iKO3?9q*zA$N88-hm!D{6;$P@=97NHW72FxH zNIWsdu&=Zj0RYHH9z6=9qwVwfkr&(u6eY$nYGYLri?2-G<3a#e87rv5UBMgp;V3K2 zO7uL^Lu_7mr4?j&{`pSUyM(u{LL;5Vn5o}48~~uVs9YDtEFy>cv#Rj?{V!&vo&-D% zBg1+{^JsRUT6mEiZLAEI836^;DaMysj83ZTE9l0v>b6`xE2Nk<5Qh7KIM!t2=GNBI zQZVeK5m_8bsnYd5wlxm$aeyFe!T3~!k$Zy)`2?Jd`<<`u_0 z%>se^Tp!Kd+?hABnj!$^*7Tv#vyGw>|=~(YJwLW-O)!1hnwOy~~shkvwWJvqLtyiz(@D7ynq9vnLReifn4Ty`tu!}&dGX<=(ecJiz zQgo(O5_|5JjRdu=zR5l-l~A)1Oy`p2NKje(LgP^Qx3$fSlRcDcpz$37PqShw|6V~Rh}Q^z zt;aJG_etLdNb6Q{NDPw4pAo-jcE!+P)&79LPrhn|Sa+Gc{rbVze+Q{>aR4-qNu(4?LbZXG z%bu$Pb6*mJ(@~NS96r7IcQ9DP#kwS#d*)J(Qkt9d+1C-hI-^ZlZQxf?33$9@R67mr zIh9{=Da$up68HHm=#W+7O!J1{dw#?3ZN_J6z`p+2%uK!l<_`X`DqVkBUR6@Z+2NJI z&|kyC^69jC;nL^Eu7OXV+IZE5q|`LrE>B(1ICrL~+4q*+2d)2Zc$GxxdkHeGwQFPM zqH&mgWQwshpPC|vU5>D@;uX-ZJc0R230_u}$KyU%axL!k(uN85)+R)}ZR2W8qZOcM zj+Y!uJlh-30f90GrSSPmI2avv$|Ge&jcIYRcvCMQsW1cjCCy$c0qI(;`&}JsqcX1?O z1BL?;h`BX2*$h%r0@!8Cb8oQjPvbLlem++!>xh$58nl1r6dPUodR5MmT)GzbYa{b& z_1LYWYH%Why=v@tL5pgqsobsu|SQvf;A?5PQ$wujOU0d=gJJ+AE+6w~eP|5o0nS`gZY` z6cGqX0cw?DBL!h2XM>&1FVB}ogqNXjC`y71bZM|m8bMQ72PwZ$HIab%eoSKtq`99= zuC2bUc~#ANjx%oPvtaPxD``vJv1eBM8aq#E8_0*3{c|Sy@`s-pUrFskfB$?dR;uq! zNco6!_h;8blPg>wKU}sSaJ!t(es%T!ZfVBzR_B*vA33(9ZOZOb;npJ&STalo;Tul_ zFX}xY=CjYqrHHj=tPDX}zu;?~cgbZ0+vF~NUhD?$qizfXfgKk85;KxO8PtSe1rwnO&-j(Rs z)50rxtv*k7_m}h7dGaADSj5%ryOBZopb`0BCX^Y#Oa6Bv*@V#QiNQ>ezFnLpsQ$gA zKye7>CRUL?hG*%(HI@br9`18*7q4K$d=aJ*0b{s?;iZri6%e?|UZNU5CJ+NO!=!N+ zp$cr4)$u9V#Wy7p{BMQ594++SPz0EUi;Jqrhr_b7y(ho+8J`aXzAEH6?4(?$?P%c> zKzsNMa4vxpvKN7*^N#5@S$giE(H5gkD^HjP-}9$hZVgQ9%mI5x^o@rV;AjZ(0c>AU z9Ql_l61j4{YF?z^xhinp6~9jz#l*{JCq#t}$TpbU-4}n0Kd(4^MnUjdgp|s8ga7G$|Mq zMN$Pml9GX55gqfdMj5vpZ-w`h>kj{D*&Qs6H2K=(6*$1?YV>jO2j#Tc%=21 zE}#FKT$e;%xyBpzh2G~jnUo9*zqF>l(PmE$*`M^?e^O6gQ}UZzuO%q}TcnF1bCNMw zl=On8s@vhL?zo9Cwp)h6kPc=haRRzfK)yDKTct4%BHV3|8&!pwYm;ZFXtY=Qcdx2iu(g(cK zGaXN7Y$GB5`e-yIwAlTKj-$d=M2@^%;9^>TN~oeN^-o?%@(1!et7zPBIM37Ag1$zZ z%FNFO!rutUpvg0&MW_8!M!SgVf4R!cuf@tzXX}*`gwj?y`lfK5UUSd_5Ni4?pUJ;* zFPrANeYt)3_`{&q$^AE9f?NN5KDznz4-E}qEd%KyfI;>V2&h&}maun|C4WGS0JM$p zo8rJPV0*kN=+77$Vm!=DwcNwV5#}UHxwB5g#|tW&O>I|F7`KPrA| zrIz>8ou6JF=sEpxcjQ_z^xRi-_)azFxAzw}&uxV4%PH*FS3Yo*`tYjLf`CTlAR`F` zlur)lqkw4rpLq*T7E%5i(c04_BD)C^gB+jg;g7C@M_Rlb6N#iRJ8y+)Mz` zYrM83=QF}uNA@7}H>kqdM~<*R=VIEfCggDlQ`s>3dXT6Zx>+iR!$w$W~U`z@Ztq>*wEpp~O zy>YVAB9ZOC`Jfaz*k35X^sQR!c9yHw_6zh97Hxn?2b1*CqM#nD062_mGwc;U&xiKa^U+{mSWYo|Ng^-~RCfSHC1 z2G9~D5nS@1nM73-e|qx$1UpU!nto|{S`iI8So~3mzLjypQ42=LFNx&|ye+}1gkGYA zo0ie^5mh-9gf`Aq)Xc*GB#Cqz*wS6Rk)m11l}#Ruvvc$C9HHk2!J#9L5w+=gvoU&xA0B(dm{07{j# z@ZdkoapB?9u3FDh&1xC#%}NH>d?@jn9g>iBeb77jjggw{=uh zBzvb^Q`&HSk8g?gMh99qDKrHXKylp(20E+<_}sj`WGz4EGrulxKiwgpk{{4{@vO{l zdw~J+MY$%ggocMto|TtLe&ejH$@GYl0~A-Y@KPiq`FM$;9PeRGJX zsw^0Xy|T#~`Qnxx2IBU|m!EaC=+&Fz;yK~M4<~|@+#dj0ToCnhH9)zoveWZZ;Y9ET zqOLkg6u4#j?=L9nl8S&j%44NbMFhTx$@u^(Sn60TmY!oGsf=b+g1oiMFcmsPmq?e* z?{f|S?ca5%m^nR<6g1_4Yh*_9yr!uFbE|fpceC>HiGCdC)*g|;>kaehN5VFx=%jr9 z*CeHZMLBpKhmJZEKx|ApEV_a>(m*UiRQsnLw@*O-Y+~P_{$oonZSa4e`mzylMV#h2 z3fwBvliCk@41&|SIW)Xfj=LggH=ggZzI~C>E1IO#*o6CY0O0tAt|kitDe_450itW^ z07`#9OQh;&mN(Qv;(H)-ioPsZTOyeS0U`IeycxDP1h;SxyBHN8v6!qeNfZ zL{IlA((2Li17lrSLCa+kEXVKLM!(Xi4G3uL` zVAIl*sFI#6gK`|kt8zC6U0b-eK+!X-{=fzR^Ss}d27~^)meaz+iLO`bE>)uUk!OP` zMUvvWZ1bM=iLod6o?savuls!e2I3_PUy||f*wvk2Ls?C@(Fe@iUoW@V>VJ4XL3vAK zGhsad!eF}ufI+lKWiL}xYosiEDC%ZuWZ1F*<1CGkqz6Nr?UgeG3jhAAotrEG4Weql3s;qFT-{0&eZ9NfmDWf4Lm&;k1p_ z34HKN&&#XVig_O}S7{21jV9+Ag1GW;>z}iU6aT%_lb*y@WVbSAcOV&|{E~0i?GDL0 zt5HtZu4F`0vEa*P6>|LJ4<<|bHxaWx=bm~oydiJn0np2255rX&OU~VMlJN}+1O{&U zEdN?-a4W3LsqDldu8b}!LPefGo3EHZ3;V#BI;xg{ew^ziO0f#HhV(kDY+wCx{B612 zOxAcq4bC7WmJUQR@=L}be?J@ca8)63{2rpkTFi(-e~Dh<`yrp9ohEAF>deFUZ;y45 zU_Jg=3o4|o4b((^Y&Uz)Q>emY?D)q2N?x3W=(@M`*8ZE(y!nw{9;*>X z%-D1E^G?Zx6Z@8&2pVNpNRxWj$+`2gpH8U4UBerE#JN{~i$!-LTnHUpi$5H#hcs8J zXA6%*oDPH=cy40{c-v4w;J~Z%b`Lb99V*gqr)tt8v*QpMHkQXjlFs)8U9ys+e!XB- zVE8?#)G@3RBPA-KWh#9UQby`N3iLg0T@(>xF^XpGh9tYaXH2$oy3x{jB;AInt9c@I zsL!Z1CH*$*{(H()i@ay*C@A_}SGc*WpOXsb3xOF@0vs(-QU%0?IE*ypLfw~@gkZgO zMlZyf9~}!k^7Oq%=rnFXc=OVXSywrUM(T^(qChDbKeE(zFz&ir^%J>|EtOU;;dJ4) z(a1}e#I0IRX6laJ1hHlzlLC*ytKg}OkQ{A9R9w*$n#I7EIx`JmkpR!?{?JN+rdWhw z>&d4}N&CI^6&9S;aOJ-*L_$bl4v|JG908(Vc7ai*y_Yl>MR%o)r@Gcr5VdN*5c zM9q9T{mjq9=6O0oPS^-h4AvthI_%7ujoHk3FKT0~vzy%@Z+;!UrvkF;6t zJ(BYZN|)6#byTJZ;8z1qTZ7FF52==W6bTFCMH(z>n-5XaB?2+$vAY%xKx}x^!zR3i zNfElanwO3Pqb^ALRFuVDd@(Yvm-q6mER4mmZK4i!)^N*Y{Hy7ZNo#UPgwXI1>sJ@8pt#01&j*_`3Kzg2}G+18C?l58<4PcCvXN|#2 zSWheBx_ZT@zlpMifpR&UjQ_2s3-L5wEUX?3i$FACB?wgp{r%e1_tyiB%wL^07W)u6 z{7O*M3=92hqv{&y4vhiS;iZa+-xWISfY$)AXmOSsftGrrjWtu%*V}t15O>=*AI*k-(GSmk?I1WVRhga3ds5lT z`|19n$CYgH?}k7i*B6`d6)caCw%l4d7?af_q~{@ zMo?C3=6vFYXK|4CRK&{%qWuLyRJei2m?BC@Lew1_h2RX2kcO$~Hk}9!5P!hiRp3)> z`CeOMfSuF9YcW)<_9}x;6oz0sAuIgE^ZcK+o_`4|R@YC}3!3NC^p0X@kk#C;!-Pc! z^C$S{o-r_xahbsZH6ZX{TmarD)!%VM*ddBfZNQ9%CEI7`k2}NJ?u$RqnsQH`zh$`i z>f?iNFFgwmNLA>kr$3iYN{ORX%gq4LVK#0(A_hZ-FLp%{bDuW8dRDY^B&Y(NNr<~6 zSTkA9(@_wv5&gSo&33WDR^#!4?_~Sm_l>!KM8)1~gpmi^r0ede@2p!E&rg0; zuD#5?>>xDImks6Vo7@p0hegLS#FvF1R#V)>rHa% zi~Z@891>JAS37}q3l{lJ=W?hqS?I~_kndBnR}a+VzG(d66F^Zkoj63J_VCN-8Ot(+VVO0& zm~bF}^@_lUm=+zBtEm0JllqEC#0)Oz!>&6NQ7^%kz z7IVdUBfG3-W1q#YwD!uEJaPOF4vbKYrF%#8HsTS&zKvuET$K|nub^$X?|27YQsC;I zPm3zScUiMqosAK{z>+_^s3652K7#M`a_v0*=rFr~&|o!t%kf(<&wYo{QQjBJb2~>* z|AqFf13PyM|SNMs_y{Mz7KI1T?dbuTkB zM8X%z?mN<0nj|`ClGgA%h1&~9h(pS!NcEmCZ!az>0@Gy8lzo~jV`tEx1{S%)LdB6O z`~dR`-+ekLqNJ#4(U>7XbU1s+hszBsERI4EN*S}Fyjqh23aq7#?&nK~%7u$BM`;P& z2B#iL&3qS5fi{(A(f?;TFyaOX>4Ef(`%5GAy$6|E|Hbd`_tO#lnjQMj!O%FpHRii2ARF0UQzPxWDtR5f>_n|2^mW#q(31Q&IQ{ z+m%DFE2rTf-gMgIsc@T-bV4Ff6E0uUZUip*2^!=;>7K@cyz$H6#t0-$d`y;1K$C{( z{TN1QDq#yai?0Ey@pPan1w1LyMBRB6*H=)h7!9|66deaos=nPPYo2uWMYV&g=g4>* zkd#%5{*n8nO8AlU<$Cl!=St~m?N0ru^3GsLvtPX!0Q|wD#JPl{xudWHTt8>y57h^_ zz7fwx;cd7m^8wxa9nWAA%%S=j4|@6NZ0m&-aKs-%KkH*@@iOmraF$e6z%d+sclibh z_tV*a8}&Wa{X;HIEy(T2kCW49YlS`fQk2y#&DDpvmC|D~o6=zJ<2T0-LXeOmCKDPo z8<7?eA7*Z_Kq_h5Uda*KAr7cttg}n@g0p- z@kNt6CMav?XuHkN2}4B2-9E)Y?upZ_Q7vcm z|D;RNoUWj4C3I@BcsSTx+4Ac8{ugH%KCR=s`5BUb9-Wk!d;KsM61b-rA(5w4@nmOI zVB?nR_L`vS@&5INt!J!&`QmvHq$|oxDCZ{|;m87!vk10`Qen+6?Nez~CSi-JByT+7 zvNv9S@_DJ-_Yz!J31#ih+9Ux}au8N|sKM%YGokjX=Cw!CZ|YCpq?AyFlLXU_xuTeS zJVEkEG4FZxMeB%KC>sJuHBXUI$&r}iX^qO^QDlz9VI{NCk0oX&JD z_P6@eQ|*lhp)fwDFx~-^(>FJ6Wqf|UaP5))ta3}@Pv;rAvgSq;rt2ggfQEP$AMz)eiirB|rMY1%u#R5wYdAP9fY zEq)G!De~iuVwN@4V)VQQcYm{iYP4>KspyDwk5ETdDFU8>lcVezLH$7f*K+xt^3dsQ zKu*v^Q=R7FBu2lmikaK8rd|l+O~As|Jadrj{bqWY&H(wfD%#a&r!-1(eGOv$S*CH| z!>IT+7AFt>3s+mo zmx$)TMdQ2hX|kCKMza@ot;JF+P$0@i8Lk_s`Wvq7f4|CaABJQC_B-Dx;gqjBHyq9I z^;IJPkqwTz&H4puGch@7OI6MgZyTV_Vl^0FEfZI)_EnKE?KkqQ_%Wqy2P07rP=S!BH=}Mx$=7bVXEV7 z;^2FKFA|>2M!(gN(pbNd-?2ch0|1s(M-+&ly(^xB5_M_2|L>Z<7$O_*G}`dNBHh0E zYV*xqxPMjHl?eV@(Op!_SyA^XX`Z8O1=>eSDN!ctOaD$eP7PVXi56;nXb6aY^il2s z=e81wauO!`(-mLSVJX;)#0>PJ4##?X305dq(`)bmi69`U`>|!ko@cP;Q(A z8;gST(qrk5@%c`yQzf8&GCS*ddY;-voem+N*M~i)mvKtYe1YpeZ>_0tA8$7^;_*1p=uiJg zz0VlBWTEGMIrSwyI`j-@e1*HPMva%9t!0EwEWfX`NSZJA`+Md2#!5fL>nDe4eJu+# zyDmNX_rImS3t&*@dbzG+4ZTW~dQpI0_370ug5_7#U6%-9$MvSP!1><9D~EQjC;b$} zPJP;Bu<`MkL4%NnM~D?<&ntefh^?rmr@E~c|VP`+mwMDC6h4cVty8joi& z#Ideg%cR%jnlh(2avC4Qe@E%$ewZ${Pm(eGxId-fI5{5acj9{WP4w)OK{V^oRfbnp z9W(1TeAH3(i8N5WF2nFmLS+;#Oj9eGvxqZ7Ez<7y+5|Ai@V?YW&+F~Ae!1{zB2Ilf z#E}+k`aN~}MD5(Q3he`yDXysU$g_1NZ2M6u#ZNhVNhlUxgK8PNlEoV|X3}5&9#t2l zThhU83_my~%&_qm$cH7LA1kW^;T)NqL_M|X{G>eb9KP$myvgTTeh6{N#~=T0{_gYH zP_5uYDs@!vLf%MjkS(2MiH5kMOwsDLy?FN9ELuXbX%8YET{oI9F?snaOEtNbFP%Yv z^dRE9`SX4$fJIC>-Iy>IRvmGRyEoHkzJ&K?G4o0(bDz)Yxd&!Gw&Y92;Scze%E0P+ z%_0eZGAWyF6nY~eFcV4xr@@W%J_Z*bgUr#V@71z-5}3mf*Ly+sr!T_e>P3CPW^r42_@I@N)e4%;0b=?t$X z8;6w8*SL@*l0-`Bb)&A_h?-0gbJrHT!RxayMGlhX-cg!2^aDBK;LpZia>%bsbEmxZ zLB?$z1D?OAaOsgSECo)~jkFShi~A>vFjmW7pBnt}z$x_9?CWFqJv~B5I$Bc&<(uAf zPDmIow8^M@Ud=^5>RBMqq>t<9<~Jdw9K~_h9@kvfj%R$BMa15VhWny;fPlA@OvCa4 zRsLD*%OH!dDb-h}b<+(qxiZkpx$TPwbeoNcTiAAIeV*;DW_#O=Pm#UMQIlC|ENvf# z4!~ejs^y9S8ZIVo-RMPN4vfqAm4nckjLv5s8__MgpP?kS&hxiwtOXwsU#&!cIKXyg zV&$fkX$e-uI66Ugw`L*EhT)6V-1FhcI$?HBmi+WOtL)r%xmU+Qx^6RrG0IQ+k2hi^ zZ104={?wLuLuqU1N6ogr@$ff+ib){4R)|h~)vInR0Ow2U`6@=Rsoi~Ba5(kU@9;Q$ z-93aVTyq2r!@{^$PPvH;#>K;sw6E`6uQ&dCT+STZd8UqM>a+f>h7{2pnD)h3ovJSL zuMpMHC?UOy{53mgx9Nf8Be4zVCmDLnTR7>0g9HYyp8Ey@j`+hGv#y#6>#Vne@R|9) z%@KI5f#Od%g_VR4rb91`c=FGp6Wbedo{e`}S=s-2Wl&nij!piYF-%~`7h1fkqaB!u zn5dz`_26N>=O{%IH~c(kIj9E-RJrU&>N@YPHbi~$H_6Z%gf@v0EDtTb=ai2wa{0N7 z2-%N)FJsid&KJ|ls*PgRMyoxxFH=J9zw1Ny{?v+$*g~AS5o=tiMA_N?_7o~TxYC*S z*0)Cgxj&5!YooTN^0?Cg``-H^Sc-N+F+UpLo;sx z{Vr+2cWu3~xf3CrF3}p4=V&uS|Eu4wu2wK@LB2KK?)wL+s28KE3y=B@<%uNeJ`2@z z#IJHgVkLx;tLzR>ldeabwwX-Ds(lbp%Y71a)ik)m^R{sPi{LMEf>whAS_aN$>NPvg z6y?yp^N}$B6Qo4c<($Dt7i1UF@bSH1qtDj^t~`f!0$uv8HYeG*r<^}o=ATv)GkkqM z26==ZwFI_=k^kkbW$sL9^emhGxp22Rv443&&C900e?mL7t zLBnr~UnImlE4e0$`ADkajlR(Q>B=nuZH1J->Etti?|B#4_s2dnx%u&rg0SRBvtN7& za~@T=t;qO4++ZZiH*PZ)2R$I>J)3>b-^LxEuX9<}$na7<8=Bz658%I6kn|;$d^||e2F8lr$xI*EdV{W!cn0Ad7<%1gUkyQa2}UcN}|8YPa;NYkaCd>1-6~uz4oh zPrWU9waU@Crm`#JA=9wrL+5;yoPXH-y~FPAaVNAyIN+Ax+_KypBS|LW;$xd=2|z6375HWAnmYOx{-FGv>i(n^%u{ST6P^Sv4K0_Fih2 zR}&j$2k#yDt1Vn(-_y}wJ3G67wXEok*5i)FmE2<$i!eS*xgiGv84qn~fctcUU78Z? zgHKb+6?Jl;gP?(Iw$yK`QnMV#(qv}Vj^UwL-pZrAfyKZWEv z32FN`Ms1NnNWcyxh%?pUb|6EI86rMCwtSJfh?by%vLLvOhgrh`V~Qr+)+zRyEPj_3 z_tm)ytC%(e@RD^^t+CzA z0d|oYyJkm4G*;%&>)*+n>|vrRQXU2d6S?`i;nYpyg}H7U?|x#e3?ayyDFpALM)1n0 zF)yjK;+OB>OU@0LfAwapz~-BFDMhCcK7--e=OPmcGo>>YhiH*kVG|;-dNj`xG{yO3^3#A*hdmbUMqh(?Or&P@=glBTL^;C@7dEMe{F)^`e+zy+)=84rwf%A8b zKGwI+_d*#ew>ZzGkOVa(0F;q{9+P>6yB0x0TKIE4Z)^(9|23a(;E z`>JyW7mL8wW#Uht;x=z6@`nT*ZCXBs0uzBiJ)%g0e$lC)_i3N#sxfaU3HA~k-0if? z)x*tDS!pLrOQ$PR@9*}Odh@3Y{k?k(OoknP^IGz6^x#(g#^lSy_`&q5Kz3y_oV16y z2k8%aT8USLEeI-K%sOKIcTuH?iCi-DYMlo+YHBaR8p0csQlx2J80e(d5Bxu`kXWGN=A7UOUO& zIG@zc5}JY(xj}w#{;`L$L$WYs`#xeYmUi~BgNOqTWU+$K7X??b2EDYnG1t_VhK6_9 z`NeXGqGef>dREbepD>5RzF6I(0Qj)04wK62F1h9sQt{8Gcym1+J z&QHiFG0p8T>hq!)@`w`dY9;xLV63>g)=|3Hq@elTH^B>=onBk?$NruEYC{GPg78izQ8!_&A&{pI$C z*g)#izX(-nD{590AveP0)i2*&VnlB?)yEudhXK=ZHADpNbFt+WHqvCj{)bqfzO8LMsvPF1S_miz8*_bce z`{y^=8=bYI8bKsM1l1BN(Whx{bmk8~!pe=Gw;7}m=&rq_OB`?la9hA%LEt1B_={Dl zfLtz7U#AM}d`}y3O=UY&(5FNlbCx7fGdcHVa(C0&vQ-BA%of9I)`d#FU-{o|u1csi2lJfd+G7aQ?3Ios`q9PeiB1k@w514VTJyQMKu z_RCzcctE$-y%{YJ;t^-j?<@Xq(eZr={0PiUVqE% zn?lOF^U$prpVl$G`*m9*Bs9RBRI?q3@{RDsG&o)U#`PVf?{WW;e$Do&@!AkwZy{;*(Ea zy8lHd=M+!7tRu!E)g{mq>q9pVR8RSz<2ushfD zVUAXHVbEu<=U0L{OGp6szLWp&P@S*`@sdrdz4Rjj)2}Lzw2zynFqVbB6Mvg)f2ic} z%G=n) z>Xq%Fb(~gTW$Ua4%Fwco?)q_0{bgjvk?~ii8^2EdPK4gwJb>h`upI}$sXGc1xD2dp zju98oMI9x3ol3aEdJey!2;@b3$X{?a)kPOvZ>m1$++QCueG%5O{|!#jX}5uym5&^9YcSUUmNhB{ z>qzutPCOmh?DW~0+<3GYGQ|6y2rDPJ*y9L2a>O%w%LHRMDH|66$V6S|LMPt5*;%mk zP2VacY(HS5wXa?2R2w3Tc4LxWKhkz?nZVscks zvu$p{0+Y&d*`0%yZGGZ`RTw^S+R9EBL+dsjB(L)v7-!cRS$XwE;z`Y@yP@!it)x?$ zMe{FSS3%AmXva$A1iYC48{7Ma4dgUdApt(uC(ttx#T!0yw%@V)zK#P{%-#Ku#YhEf zE!?7~vHVlChBki#hKkm5;rM&ze2 zuyXJOYeRH4I?&1%>WDAk-ETyday`L;HC2$g6=d}+W9X%&V#dBk;hO0K~yzl+( zEiO-V-%O8RaQ2Ql2)EOH<$(r>XBPws?$~JjwSfJ&8}=TvTgFk7zv7OamY)k7oxHP0rs+&S@qv1wI`%RE3UTAyv}`3~b+eLWG@CV?7KR08L>P&Q(yNaK z`mVRMHvFi_vb}SCaWY*0g5hGr)l*L`0Rps}JJRCB>d>9iejVFbwqX)Y%Z!f8_?Y%F zX(IhzF$bI%4S7|7Fe3oqrs!oqx*hi?B&(F8-Q@+PzsBLw4-C7M$`!7%B{qcMNA;JWmuK=F)3#YgoXr{DV)_G`K%zi9LQr?~$r6wxOb@y)4Ij(Z((z+(|?+q&F_ z+%d>#NbETcM`alDe}zPn5&Cq-Dwf$A4im6?wPIkD{ z8y6+u??Ig^$28G2%8y3oS--%Clo=62i{{6ldOhz*6@6oc8m5QZxmz(rZ6cogk z=eBkuR%BD4#b}Lz=wT6EO+FPogWJ~6q{hAXTzqUmOvSqpHG+=OZ(zXB926n+x%8YVi*8Y>8FBnnQZmr(oPMse{jts+Ci?7mVozgWTg z#i87T6zjqLJnDE$)&O1(`1Ljb9g(mw8dU)i~JpCp+%W6GeLJp=gPK z%5Sw>_piOJE69qdZsERRr(-6ld22qwLuV)Sr%k4!@?m?&wKj`*c`@pF4T_cAjTkbfbxR^Yc1iQjd4__dc&)f#ykquY6 z#C;#4tg51=Y}wU=&6-coElKT0^Pl4#$xz6y70Zfs$&!fQ|7lJ;*nmgv?L@LCRrvCCg9;fnLY8!kE}EB=Y5PJ; zCK6WRw}pwWaGe89{=|5(i$M`EvFQwo0Nyc`m&CxiP(uDYD^XxS9fYHVy zejVzQ3m)cQ7k$o10~N|EEmS8qi0ASl^I5} z;u(n=ioz~D7)jsi0zPM%^r#2?&pbUP{A+&BI!WEX+^13RnFw*Qu=L0G3eVA zI|I2XbI4eANmRn%Q~xeN=K@t$xyWNDEBlh#v%AuY0$8De}zWY@bHwo2PG{*sm4^c{8Be1_qHp5+)u1 z@`=BCPUyQ^ydlo9%AnUriSjHjs2jz3zcnlPJ`Gn#7?8;Kqf*~ve7ko!hQ>BM(Y7uk zsO`O|=(Owgf0Ko7N8J$TuQRk0@_zP>)Q=uuKYRWC{Ss7r2@P%}g>Nv2VDc4!-7kQQ zBu`W{nm0qw7pf5nh*e-Y3WxFn2e$BL5t$WwU+{YT3o0RaA=+gs6V6ni-C~&Vxd~t@ zTyGvg?Xn8dcPSfKd3ezq50~_%SNu67hPqon2$u{0Ol#Z!GeIz!7VL zdSD`!kjvE+Fa$GzE>HyM8ZCocBJF&-Tf08XZp6#YsU-dW%CLOhY4!)4GI3))(w&nN z*!@`Q^?X9dH2YCq?#;JLCN|YCm1T*1a290`6UMbdz4e9}?F(x^A;>(19j48DGG*aS zR8UyT7%$;sk3Y*TZeFn1h;<^^@4*z*TuKAZV1{>zdNdYWl98Q6g=9rGU_C znjuwKsZg%-EURpHwVB26smQg!E&2~gqvTM|H^m_W5=p|Vsnk6AG)Vdy5(F{`yxCml zqg@g9*u#DSOV^%a-FnV-l}clAT}W0omy^z_CUTcv#68r*sbSls>wua{oc_lxBSG~BrSZdBqaHM+5M#k5|a+G*9fe`T!R=AgSE^*C{G^f&+~rSEI81& ztUS1jb&Dwg^|=JO;$5u`5VNnXph%&1j|Jj@LZ-9;q>&uUlkbHDoV$`&D@7G7jVU9^KwQ2`$fWS;&MP6g-|@wqRyh6`L5B)VcjA3jor{q%hRHD4+v?Vni*J4j;mH z-=jU3umFXq2N%AS4c9paaBbv`NPzbmeYK}W%oni)1AdXC^Y`Ct2HyKkXw%GF3tSps zSZ9A*W1_)4uoTA>qD9h%{FxkZnl!r}e^pM;j1E=-_RV3cvT1gLhNOR~6JRe2Vk=g>4pH!jXilO+|jxX;@#mC?xUqpjpv+ zfJriYhX&YHF6A(A0!y4zRRl0Ne?yMTWgDx)$z_zkcbP?27}?5=wOc2I5$5K_GjDa5 z>DSrwzcuj%VC|Sl-w?6~5f-sQst~s4y@2&Q)Pn-SxC`4%tI+-8G$Uix8@Bs~XR>3$ z0!^wmOib@4?-bgizP?!s&4-1Yu^jTez17xed7`>sJ~%z|87Ik;g?LgU9C6&L7VT$5 zs56tB5Vw7I=Jnm3&h5hUH|jv4;97NxE|-_W`7q4d)gwHUSX_*HRDJeaH{KTKtAc5b z1Ev2ig+vg2*8dO6-r903R1-TK?bgh#9NZ;W2wSmFPXb#lea*oDX+lO)C75n+BeGM*z=#$UXkE&&q1ioy~t0yw0e6} zL>A(VvReY}R1rg{;LnVFbopr?_D@j|Kw9`axIf@dqTmRAM%HHxhx{;d{BQ*J8ttHI zKo5=hKAF{=kt>eKzZ@z7wfUyMl>7T?Uru2SVD+8+6hKMV_s#*SP&I6oHivSSfK95e z1tgLXXR#;OA(x9P9gmp1v$v-7SEK;?5LN_gE|z_{%MZ`JS~*tt&b;k?yZ0OG&5#kc z7fD&PVH^qadk3fW1H8K;LP%EQ{(r7&cJo)228|_0Djelc%k(`a!<#ryySI^wo2aur zUCZ7)Z(0Lb){Y@%tE^675hS9;(H2r^r5{C&tG?%Locs0DU#%U_{A=iO0MRj!3;|K@ zOSWojhwf+du6VIJjc4x_Uxo<#rMpT&mA-6tt%XCsunM5zN**htvq}vFaSyYER=3*h zU06x7dlD+ftS1Fo%q|Ni+M61DZs>uCDJ@4zWt@Ew0N&|>04o1Z7H9|r@zEXP!>DB$ zrC8|`Usa#qF&qWW?6#3EmiN+-UEN1g&aaWrz3Ypcv#9#u!a3E0obcJdQmFS>sCVhs zX8``B<_ltDX|D@QrKjuM4;bDya}V%Dj&VjV^K*zvxYVD0(#268@``mq#~Ge~)ojk) ziWzy-njvL684d!lyNedcPE_zY4_+J+CH6-^ch@orSMv1g8{6)&Q6UMyTvs38^?a;k zWqQKeDD#1|>~%N%gNfQ_!d4rg)`*?}DIk~A<} zVlLUHxo+HEtd_2CvuiFYWfpKb=wKB@Dao~nzg>8zt?*z#gWFPwEUACjZOr4oiajsw ze40pSw^ZXVeA^SlGB@l`B?m|fmN>RHA8BeWJzf~PezvAQ@Kzo#?oKYtAKr5ac{}ym zY{BP1&sKgeEy`SCRY~8L42kQj8iLp3k9N7zh>3oT!?Hnu2))(jF?zQTksE yPbqxgc;#U3Xr-fRXsD@I^Z)S28C*~dGJ5y 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 YUFWf)L3S_z;CWn-{-am?|3CTv0)`V=SpWb4 literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/message.mp3 b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/message.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..942adbe85d93bf93a3fa1d41fb1b61dc487a7150 GIT binary patch literal 4712 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 ZFJBPZo+)2ITcoQ6w*HsD_5XeS{{WsRr>y`0 literal 0 HcmV?d00001 diff --git a/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/ring.mp3 b/Riot/Modules/MatrixKit/Assets/MatrixKitAssets.bundle/Sounds/ring.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..3c3cdde3f9c5695063a0eaaabf1b995ee24702bf GIT binary patch literal 19662 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 URL? + + /// Sanitize the element using the given parameters. + /// - Parameters: + /// - allowedHTMLTags: An array of tags that are allowed. All other tags will be removed. + /// - font: The default font to use when resetting the content of any unsupported tags. + /// - imageHandler: An optional image handler to be run on `img` tags (if allowed) to update the `src` attribute. + @objc func sanitize(with allowedHTMLTags: [String], bodyFont font: UIFont, imageHandler: ImageHandler?) { + if let name = name, !allowedHTMLTags.contains(name) { + + // This is an unsupported tag. + // Remove any attachments to fix rendering. + textAttachment = nil + + // If the element has plain text content show that, + // otherwise prevent the tag from displaying. + if let stringContent = attributedString()?.string, + !stringContent.isEmpty, + let element = DTTextHTMLElement(name: nil, attributes: nil) { + element.setText(stringContent) + removeAllChildNodes() + addChildNode(element) + + if let parent = parent() { + element.inheritAttributes(from: parent) + } else { + fontDescriptor = DTCoreTextFontDescriptor() + fontDescriptor.fontFamily = font.familyName + fontDescriptor.fontName = font.fontName + fontDescriptor.pointSize = font.pointSize + paragraphStyle = DTCoreTextParagraphStyle.default() + + element.inheritAttributes(from: self) + } + element.interpretAttributes() + + } else if let parent = parent() { + parent.removeChildNode(self) + } else { + didOutput = true + } + + } else { + // Process images with the handler when self is an image tag. + if name == "img", let imageHandler = imageHandler { + process(with: imageHandler) + } + + // This element is a supported tag, but it may contain children that aren't, + // so santize all child nodes to ensure correct tags. + if let childNodes = childNodes as? [DTHTMLElement] { + childNodes.forEach { $0.sanitize(with: allowedHTMLTags, bodyFont: font, imageHandler: imageHandler) } + } + } + } + + /// Process the element with the supplied image handler. + private func process(with imageHandler: ImageHandler) { + // Get the values required to pass to the image handler + guard let sourceURL = attributes["src"] as? String else { return } + + var width: CGFloat = -1 + if let widthString = attributes["width"] as? String, + let widthDouble = Double(widthString) { + width = CGFloat(widthDouble) + } + + var height: CGFloat = -1 + if let heightString = attributes["height"] as? String, + let heightDouble = Double(heightString) { + height = CGFloat(heightDouble) + } + + // If the handler returns an updated URL, update the text attachment. + guard let localSourceURL = imageHandler(sourceURL, width, height) else { return } + textAttachment.contentURL = localSourceURL + } +} diff --git a/Riot/Modules/MatrixKit/Categories/MXAggregatedReactions+MatrixKit.h b/Riot/Modules/MatrixKit/Categories/MXAggregatedReactions+MatrixKit.h new file mode 100644 index 000000000..f4d023328 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/MXAggregatedReactions+MatrixKit.h @@ -0,0 +1,30 @@ +/* + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEventFormatter.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + Define a `MXEvent` category at matrixKit level to store data related to UI handling. + */ +@interface MXAggregatedReactions (MatrixKit) + +- (nullable MXAggregatedReactions *)aggregatedReactionsWithSingleEmoji; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Categories/MXAggregatedReactions+MatrixKit.m b/Riot/Modules/MatrixKit/Categories/MXAggregatedReactions+MatrixKit.m new file mode 100644 index 000000000..4d4baee51 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/MXAggregatedReactions+MatrixKit.m @@ -0,0 +1,44 @@ +/* + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXAggregatedReactions+MatrixKit.h" + +#import "MXKTools.h" + +@implementation MXAggregatedReactions (MatrixKit) + +- (nullable MXAggregatedReactions *)aggregatedReactionsWithSingleEmoji +{ + NSMutableArray *reactions = [NSMutableArray arrayWithCapacity:self.reactions.count]; + for (MXReactionCount *reactionCount in self.reactions) + { + if ([MXKTools isSingleEmojiString:reactionCount.reaction]) + { + [reactions addObject:reactionCount]; + } + } + + MXAggregatedReactions *aggregatedReactionsWithSingleEmoji; + if (reactions.count) + { + aggregatedReactionsWithSingleEmoji = [MXAggregatedReactions new]; + aggregatedReactionsWithSingleEmoji.reactions = reactions; + } + + return aggregatedReactionsWithSingleEmoji; +} + +@end diff --git a/Riot/Modules/MatrixKit/Categories/MXEvent+MatrixKit.h b/Riot/Modules/MatrixKit/Categories/MXEvent+MatrixKit.h new file mode 100644 index 000000000..9ef0eb1b1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/MXEvent+MatrixKit.h @@ -0,0 +1,34 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEventFormatter.h" + +/** + Define a `MXEvent` category at matrixKit level to store data related to UI handling. + */ +@interface MXEvent (MatrixKit) + +/** + The potential error observed when the event formatter tried to stringify the event (MXKEventFormatterErrorNone by default). + */ +@property (nonatomic) MXKEventFormatterError mxkEventFormatterError; + +/** + Tell whether the event is highlighted or not (NO by default). + */ +@property (nonatomic) BOOL mxkIsHighlighted; + +@end diff --git a/Riot/Modules/MatrixKit/Categories/MXEvent+MatrixKit.m b/Riot/Modules/MatrixKit/Categories/MXEvent+MatrixKit.m new file mode 100644 index 000000000..53c79e6c9 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/MXEvent+MatrixKit.m @@ -0,0 +1,52 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXEvent+MatrixKit.h" +#import + +@implementation MXEvent (MatrixKit) + +- (MXKEventFormatterError)mxkEventFormatterError +{ + NSNumber *associatedError = objc_getAssociatedObject(self, @selector(mxkEventFormatterError)); + if (associatedError) + { + return [associatedError unsignedIntegerValue]; + } + return MXKEventFormatterErrorNone; +} + +- (void)setMxkEventFormatterError:(MXKEventFormatterError)mxkEventFormatterError +{ + objc_setAssociatedObject(self, @selector(mxkEventFormatterError), [NSNumber numberWithUnsignedInteger:mxkEventFormatterError], OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +- (BOOL)mxkIsHighlighted +{ + NSNumber *associatedIsHighlighted = objc_getAssociatedObject(self, @selector(mxkIsHighlighted)); + if (associatedIsHighlighted) + { + return [associatedIsHighlighted boolValue]; + } + return NO; +} + +- (void)setMxkIsHighlighted:(BOOL)mxkIsHighlighted +{ + objc_setAssociatedObject(self, @selector(mxkIsHighlighted), [NSNumber numberWithBool:mxkIsHighlighted], OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +@end diff --git a/Riot/Modules/MatrixKit/Categories/MXRoom+Sync.h b/Riot/Modules/MatrixKit/Categories/MXRoom+Sync.h new file mode 100644 index 000000000..b95389d39 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/MXRoom+Sync.h @@ -0,0 +1,34 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import + +/** + Temporary category to help in the transition from synchronous access to room.state + to asynchronous access. + */ +@interface MXRoom (Sync) + +/** + Get the room state if it has been already loaded else return nil. + + Use this method only where you are sure the room state is already mounted. + */ +@property (nonatomic, readonly) MXRoomState *dangerousSyncState; + +@end diff --git a/Riot/Modules/MatrixKit/Categories/MXRoom+Sync.m b/Riot/Modules/MatrixKit/Categories/MXRoom+Sync.m new file mode 100644 index 000000000..8866c8a49 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/MXRoom+Sync.m @@ -0,0 +1,36 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXRoom+Sync.h" + +@implementation MXRoom (Sync) + +- (MXRoomState *)dangerousSyncState +{ + __block MXRoomState *syncState; + + // If syncState is called from the right place, the following call will be + // synchronous and every thing will be fine + [self state:^(MXRoomState *roomState) { + syncState = roomState; + }]; + + NSAssert(syncState, @"[MXRoom+Sync] syncState failed. Are you sure the state of the room has been already loaded?"); + + return syncState; +} + +@end diff --git a/Riot/Modules/MatrixKit/Categories/MXSession+MatrixKit.h b/Riot/Modules/MatrixKit/Categories/MXSession+MatrixKit.h new file mode 100644 index 000000000..188b6b40b --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/MXSession+MatrixKit.h @@ -0,0 +1,28 @@ +// +// Copyright 2020 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MXSession (MatrixKit) + +/// Flag to indicate whether the session is in a suitable state to show some activity indicators on UI. +@property (nonatomic, readonly) BOOL shouldShowActivityIndicator; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Categories/MXSession+MatrixKit.m b/Riot/Modules/MatrixKit/Categories/MXSession+MatrixKit.m new file mode 100644 index 000000000..6d136c306 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/MXSession+MatrixKit.m @@ -0,0 +1,34 @@ +// +// Copyright 2020 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "MXSession+MatrixKit.h" + +@implementation MXSession (MatrixKit) + +- (BOOL)shouldShowActivityIndicator +{ + switch (self.state) + { + case MXSessionStateInitialised: + case MXSessionStateProcessingBackgroundSyncCache: + case MXSessionStateSyncInProgress: + return YES; + default: + return NO; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Categories/NSAttributedString+MatrixKit.swift b/Riot/Modules/MatrixKit/Categories/NSAttributedString+MatrixKit.swift new file mode 100644 index 000000000..c72e5c76c --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/NSAttributedString+MatrixKit.swift @@ -0,0 +1,32 @@ +// +// Copyright 2020 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public extension NSAttributedString { + /// Returns a string created by joining all ranges of the attributed string that don't have + /// the `kMXKToolsBlockquoteMarkAttribute` attribute. + @objc func mxk_unquotedString() -> NSString? { + var unquotedSubstrings = [String]() + + enumerateAttributes(in: NSRange(location: 0, length: self.length), options: []) { attributes, range, stop in + guard !attributes.keys.contains(where: { $0.rawValue == kMXKToolsBlockquoteMarkAttribute }) else { return } + unquotedSubstrings.append(self.attributedSubstring(from: range).string) + } + + return unquotedSubstrings.joined(separator: " ") as NSString + } +} diff --git a/Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.h b/Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.h new file mode 100644 index 000000000..f8e5dff5d --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.h @@ -0,0 +1,54 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +@interface NSBundle (MXKLanguage) + +/** + Set the application language independently from the device language. + + The language can be changed at runtime but the app display must be reloaded. + + @param language the ISO language code. nil lets the OS choose it according to the device language + and languages available in the app bundle. + */ ++ (void)mxk_setLanguage:(NSString *)language; + +/** + The language set by mxk_setLanguage. + + @return the ISO language code of the current language. + */ ++ (NSString *)mxk_language; + +/** + Some strings may lack a translation in a language. + Use mxk_setFallbackLanguage to define a fallback language where all the + translation is complete. + + @param language the ISO language code. + */ ++ (void)mxk_setFallbackLanguage:(NSString*)language; + +/** + The fallback language set by mxk_setFallbackLanguage. + + @return the ISO language code of the current fallback language. + */ ++ (NSString *)mxk_fallbackLanguage; + +@end diff --git a/Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.m b/Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.m new file mode 100644 index 000000000..a0112bf99 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/NSBundle+MXKLanguage.m @@ -0,0 +1,103 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "NSBundle+MXKLanguage.h" + +#import + +static const char _bundle = 0; +static const char _fallbackBundle = 0; +static const char _language = 0; +static const char _fallbackLanguage = 0; + +@interface MXKLanguageBundle : NSBundle +@end + +@implementation MXKLanguageBundle + +- (NSString*)localizedStringForKey:(NSString *)key value:(NSString *)value table:(NSString *)tableName +{ + NSBundle* bundle = objc_getAssociatedObject(self, &_bundle); + + // Check if the translation is available in the selected or default language. + // Use "_", a string that does not worth to be translated, as default value to mark + // a key that does not have a translation. + NSString *localizedString = bundle ? [bundle localizedStringForKey:key value:@"_" table:tableName] : [super localizedStringForKey:key value:@"_" table:tableName]; + + if (!localizedString || (localizedString.length == 1 && [localizedString isEqualToString:@"_"])) + { + // Use the string in the fallback language + NSBundle *fallbackBundle = objc_getAssociatedObject(self, &_fallbackBundle); + localizedString = [fallbackBundle localizedStringForKey:key value:value table:tableName]; + } + + return localizedString; +} +@end + +@implementation NSBundle (MXKLanguage) + ++ (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, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + objc_setAssociatedObject([NSBundle mainBundle], + &_language, language, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + ++ (NSString *)mxk_language +{ + return objc_getAssociatedObject([NSBundle mainBundle], &_language); +} + ++ (void)mxk_setFallbackLanguage:(NSString *)language +{ + [self setupMXKLanguageBundle]; + + objc_setAssociatedObject([NSBundle mainBundle], + &_fallbackBundle, language ? [NSBundle bundleWithPath:[[NSBundle mainBundle] pathForResource:language ofType:@"lproj"]] : nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + objc_setAssociatedObject([NSBundle mainBundle], + &_fallbackLanguage, language, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + ++ (NSString *)mxk_fallbackLanguage +{ + return objc_getAssociatedObject([NSBundle mainBundle], &_fallbackLanguage); +} + +#pragma mark - Private methods + ++ (void)setupMXKLanguageBundle +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + + // Use MXKLanguageBundle as the [NSBundle mainBundle] class + object_setClass([NSBundle mainBundle], [MXKLanguageBundle class]); + }); +} + +@end diff --git a/Riot/Modules/MatrixKit/Categories/NSBundle+MatrixKit.h b/Riot/Modules/MatrixKit/Categories/NSBundle+MatrixKit.h new file mode 100644 index 000000000..9bcc5572a --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/NSBundle+MatrixKit.h @@ -0,0 +1,61 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +/** + Define a `NSBundle` category at MatrixKit level to retrieve images and sounds from MatrixKit Assets bundle. + */ +@interface NSBundle (MatrixKit) + +/** + Retrieve an image from MatrixKit Assets bundle. + + @param name image file name without extension. + @return a UIImage instance (nil if the file does not exist). + */ ++ (UIImage *)mxk_imageFromMXKAssetsBundleWithName:(NSString *)name; + +/** + Retrieve an audio file url from MatrixKit Assets bundle. + + @param name audio file name without extension. + @return a NSURL instance. + */ ++ (NSURL *)mxk_audioURLFromMXKAssetsBundleWithName:(NSString *)name; + +/** + Customize the table used to retrieve the localized version of a string during [mxk_localizedStringForKey:] call. + If the key is not defined in this table, the localized string is retrieved from the default table "MatrixKit.strings". + + @param tableName the name of the table containing the key-value pairs. Also, the suffix for the strings file (a file with the .strings extension) to store the localized string. + */ ++ (void)mxk_customizeLocalizedStringTableName:(NSString*)tableName; + +/** + Retrieve localized string from the customized table. If none, MatrixKit Assets bundle is used. + + @param key The string key. + @return The localized string. + */ ++ (NSString *)mxk_localizedStringForKey:(NSString *)key; + +/** + An AppExtension-compatible wrapper for bundleForClass. + */ ++ (NSBundle *)mxk_bundleForClass:(Class)aClass; + +@end diff --git a/Riot/Modules/MatrixKit/Categories/NSBundle+MatrixKit.m b/Riot/Modules/MatrixKit/Categories/NSBundle+MatrixKit.m new file mode 100644 index 000000000..70a75a59b --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/NSBundle+MatrixKit.m @@ -0,0 +1,150 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "NSBundle+MatrixKit.h" +#import "NSBundle+MXKLanguage.h" +#import "MXKViewController.h" + +@implementation NSBundle (MatrixKit) + +static NSString *customLocalizedStringTableName = nil; + ++ (NSBundle*)mxk_assetsBundle +{ + // Get the bundle within MatrixKit + NSBundle *bundle = [NSBundle mxk_bundleForClass:[MXKViewController class]]; + NSURL *assetsBundleURL = [bundle URLForResource:@"MatrixKitAssets" withExtension:@"bundle"]; + + return [NSBundle bundleWithURL:assetsBundleURL]; +} + ++ (NSBundle*)mxk_languageBundle +{ + NSString *language = [NSBundle mxk_language]; + NSBundle *bundle = [NSBundle mxk_assetsBundle]; + + // If there is a runtime language (different from the legacy language chose by the OS), + // return the sub bundle for this language + if (language) + { + bundle = [NSBundle bundleWithPath:[bundle pathForResource:[NSBundle mxk_language] ofType:@"lproj"]]; + } + + return bundle; +} + ++ (NSBundle*)mxk_fallbackLanguageBundle +{ + NSString *fallbackLanguage = [NSBundle mxk_fallbackLanguage]; + NSBundle *bundle = [NSBundle mxk_assetsBundle]; + + // Return the sub bundle of the fallback language if any + if (fallbackLanguage) + { + bundle = [NSBundle bundleWithPath:[bundle pathForResource:fallbackLanguage ofType:@"lproj"]]; + } + + return bundle; +} + +// use a cache to avoid loading images from file system. +// It often triggers an UI lag. +static MXLRUCache *imagesResourceCache = nil; + ++ (UIImage *)mxk_imageFromMXKAssetsBundleWithName:(NSString *)name +{ + // use a cache to avoid loading the image at each call + if (!imagesResourceCache) + { + imagesResourceCache = [[MXLRUCache alloc] initWithCapacity:20]; + } + + NSString *imagePath = [[NSBundle mxk_assetsBundle] pathForResource:name ofType:@"png" inDirectory:@"Images"]; + UIImage* image = (UIImage*)[imagesResourceCache get:imagePath]; + + // the image does not exist + if (!image) + { + // retrieve it + image = [UIImage imageWithContentsOfFile:imagePath]; + // and store it in the cache. + [imagesResourceCache put:imagePath object:image]; + } + + return image; +} + ++ (NSURL*)mxk_audioURLFromMXKAssetsBundleWithName:(NSString *)name +{ + return [NSURL fileURLWithPath:[[NSBundle mxk_assetsBundle] pathForResource:name ofType:@"mp3" inDirectory:@"Sounds"]]; +} + ++ (void)mxk_customizeLocalizedStringTableName:(NSString*)tableName +{ + customLocalizedStringTableName = tableName; +} + ++ (NSString *)mxk_localizedStringForKey:(NSString *)key +{ + NSString *localizedString; + + // Check first customized table + // Use "_", a string that does not worth to be translated, as default value to mark + // a key that does not have a value in the customized table. + if (customLocalizedStringTableName) + { + localizedString = NSLocalizedStringWithDefaultValue(key, customLocalizedStringTableName, [NSBundle mainBundle], @"_", nil); + } + + if (!localizedString || (localizedString.length == 1 && [localizedString isEqualToString:@"_"])) + { + // Check if we need to manage a fallback language + // as we do in NSBundle+MXKLanguage + NSString *language = [NSBundle mxk_language]; + NSString *fallbackLanguage = [NSBundle mxk_fallbackLanguage]; + + BOOL manageFallbackLanguage = fallbackLanguage && ![fallbackLanguage isEqualToString:language]; + + localizedString = NSLocalizedStringWithDefaultValue(key, @"MatrixKit", + [NSBundle mxk_languageBundle], + manageFallbackLanguage ? @"_" : nil, + nil); + + if (manageFallbackLanguage + && (!localizedString || (localizedString.length == 1 && [localizedString isEqualToString:@"_"]))) + { + // The translation is not available, use the fallback language + localizedString = NSLocalizedStringFromTableInBundle(key, @"MatrixKit", + [NSBundle mxk_fallbackLanguageBundle], + nil); + } + } + + return localizedString; +} + ++ (NSBundle *)mxk_bundleForClass:(Class)aClass +{ + NSBundle *bundle = [NSBundle bundleForClass:aClass]; + if ([[bundle.bundleURL pathExtension] isEqualToString:@"appex"]) + { + // For App extensions, peel off two levels + bundle = [NSBundle bundleWithURL:[[bundle.bundleURL URLByDeletingLastPathComponent] URLByDeletingLastPathComponent]]; + } + return bundle; +} + +@end diff --git a/Riot/Modules/MatrixKit/Categories/NSString+MatrixKit.swift b/Riot/Modules/MatrixKit/Categories/NSString+MatrixKit.swift new file mode 100644 index 000000000..0a7826571 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/NSString+MatrixKit.swift @@ -0,0 +1,66 @@ +// +// Copyright 2020 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import MatrixSDK.MXLog + +public extension NSString { + /// Gets the first URL contained in the string ignoring any links to hosts defined in + /// the `firstURLDetectionIgnoredHosts` property of `MXKAppSettings`. + /// - Returns: A URL if detected, otherwise nil. + @objc func mxk_firstURLDetected() -> NSURL? { + let hosts = MXKAppSettings.standard().firstURLDetectionIgnoredHosts ?? [] + return mxk_firstURLDetected(ignoring: hosts) + } + + /// Gets the first URL contained in the string ignoring any links to the specified hosts. + /// - Returns: A URL if detected, otherwise nil. + @objc func mxk_firstURLDetected(ignoring ignoredHosts: [String]) -> NSURL? { + guard let linkDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else { + MXLog.debug("[NSString+URLDetector]: Unable to create link detector.") + return nil + } + + var detectedURL: NSURL? + + // enumerate all urls that were found in the string to ensure + // detection of a valid link if there are invalid links preceding it + linkDetector.enumerateMatches(in: self as String, + options: [], + range: NSRange(location: 0, length: self.length)) { match, flags, stop in + guard let match = match else { return } + + // check if the match is a valid url + let urlString = self.substring(with: match.range) + guard let url = NSURL(string: urlString) else { return } + + // ensure the match is a web link + guard let scheme = url.scheme?.lowercased(), + scheme == "https" || scheme == "http" + else { return } + + // discard any links to ignored hosts + guard let host = url.host?.lowercased(), + !ignoredHosts.contains(host) + else { return } + + detectedURL = url + stop.pointee = true + } + + return detectedURL + } +} diff --git a/Riot/Modules/MatrixKit/Categories/UIAlertController+MatrixKit.h b/Riot/Modules/MatrixKit/Categories/UIAlertController+MatrixKit.h new file mode 100644 index 000000000..d30b01787 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/UIAlertController+MatrixKit.h @@ -0,0 +1,31 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +/** + Define a `UIAlertController` category at MatrixKit level to handle accessibility identifiers. + */ +@interface UIAlertController (MatrixKit) + +/** + Apply an accessibility on the alert view and its items (actions and text fields). + + @param accessibilityIdentifier the identifier. + */ +- (void)mxk_setAccessibilityIdentifier:(NSString *)accessibilityIdentifier; + +@end diff --git a/Riot/Modules/MatrixKit/Categories/UIAlertController+MatrixKit.m b/Riot/Modules/MatrixKit/Categories/UIAlertController+MatrixKit.m new file mode 100644 index 000000000..716da03ce --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/UIAlertController+MatrixKit.m @@ -0,0 +1,38 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "UIAlertController+MatrixKit.h" + +@implementation UIAlertController (MatrixKit) + +- (void)mxk_setAccessibilityIdentifier:(NSString *)accessibilityIdentifier +{ + self.view.accessibilityIdentifier = accessibilityIdentifier; + + for (UIAlertAction *action in self.actions) + { + action.accessibilityLabel = [NSString stringWithFormat:@"%@Action%@", accessibilityIdentifier, action.title]; + } + + NSArray *textFieldArray = self.textFields; + for (NSUInteger index = 0; index < textFieldArray.count; index++) + { + UITextField *textField = textFieldArray[index]; + textField.accessibilityIdentifier = [NSString stringWithFormat:@"%@TextField%tu", accessibilityIdentifier, index]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.h b/Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.h new file mode 100644 index 000000000..775aa9f8b --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.h @@ -0,0 +1,33 @@ +/* + Copyright 2019 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +@import UIKit; + +NS_ASSUME_NONNULL_BEGIN + +@interface UITextView(MatrixKit) + +/** + Determine if there is a link near a location point in UITextView bounds. + + @param point The point inside the UITextView bounds + @return YES to indicate that a link has been detected near the location point. + */ +- (BOOL)isThereALinkNearPoint:(CGPoint)point; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.m b/Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.m new file mode 100644 index 000000000..b22817f60 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.m @@ -0,0 +1,54 @@ +/* + Copyright 2019 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "UITextView+MatrixKit.h" + +@implementation UITextView(MatrixKit) + +- (BOOL)isThereALinkNearPoint:(CGPoint)point +{ + if (!CGRectContainsPoint(self.bounds, point)) + { + return NO; + } + + UITextPosition *textPosition = [self closestPositionToPoint:point]; + + if (!textPosition) + { + return NO; + } + + UITextRange *textRange = [self.tokenizer rangeEnclosingPosition:textPosition + withGranularity:UITextGranularityCharacter + inDirection:UITextLayoutDirectionLeft]; + + if (!textRange) + { + return NO; + } + + NSInteger startIndex = [self offsetFromPosition:self.beginningOfDocument toPosition:textRange.start]; + + if (startIndex < 0) + { + return NO; + } + + return [self.attributedText attribute:NSLinkAttributeName atIndex:startIndex effectiveRange:NULL] != nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Categories/UIViewController+MatrixKit.h b/Riot/Modules/MatrixKit/Categories/UIViewController+MatrixKit.h new file mode 100644 index 000000000..1b1b8fde2 --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/UIViewController+MatrixKit.h @@ -0,0 +1,30 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface UIViewController (MatrixKit) + +/** + The main navigation controller if the view controller is embedded inside a split view controller. + */ +@property (nullable, nonatomic, readonly) UINavigationController *mxk_mainNavigationController; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Categories/UIViewController+MatrixKit.m b/Riot/Modules/MatrixKit/Categories/UIViewController+MatrixKit.m new file mode 100644 index 000000000..4d9f981fb --- /dev/null +++ b/Riot/Modules/MatrixKit/Categories/UIViewController+MatrixKit.m @@ -0,0 +1,45 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "UIViewController+MatrixKit.h" + +@implementation UIViewController (MatrixKit) + +- (UINavigationController *)mxk_mainNavigationController +{ + UINavigationController *mainNavigationController; + + if (self.splitViewController) + { + mainNavigationController = self.navigationController; + UIViewController *parentViewController = self.parentViewController; + while (parentViewController) + { + if (parentViewController.navigationController) + { + mainNavigationController = parentViewController.navigationController; + parentViewController = parentViewController.parentViewController; + } + else + { + break; + } + } + } + + return mainNavigationController; +} +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.h new file mode 100644 index 000000000..60b9c4044 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.h @@ -0,0 +1,124 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#import + +#import "MXKTableViewController.h" + +#import "MXKAccountManager.h" + +#import "MXK3PID.h" + +/** + */ +typedef void (^blockMXKAccountDetailsViewController_onReadyToLeave)(void); + +/** + MXKAccountDetailsViewController instance may be used to display/edit the details of a matrix account. + Only one matrix session is handled by this view controller. + */ +@interface MXKAccountDetailsViewController : MXKTableViewController +{ +@protected + + /** + Section index + */ + NSInteger linkedEmailsSection; + NSInteger notificationsSection; + NSInteger configurationSection; + + /** + The logout button + */ + UIButton *logoutButton; + + /** + Linked email + */ + MXK3PID *submittedEmail; + UIButton *emailSubmitButton; + UITextField *emailTextField; + + // Notifications + UISwitch *apnsNotificationsSwitch; + UISwitch *inAppNotificationsSwitch; + + // The table cell with "Global Notification Settings" button + UIButton *notificationSettingsButton; +} + +/** + The account displayed into the view controller. + */ +@property (nonatomic) MXKAccount *mxAccount; + +/** + The default account picture displayed when no picture is defined. + */ +@property (nonatomic) UIImage *picturePlaceholder; + +@property (nonatomic, readonly) IBOutlet UIButton *userPictureButton; +@property (nonatomic, readonly) IBOutlet UITextField *userDisplayName; +@property (nonatomic, readonly) IBOutlet UIButton *saveUserInfoButton; + +@property (nonatomic, readonly) IBOutlet UIView *profileActivityIndicatorBgView; +@property (nonatomic, readonly) IBOutlet UIActivityIndicatorView *profileActivityIndicator; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKAccountDetailsViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `accountDetailsViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKAccountDetailsViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKAccountDetailsViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)accountDetailsViewController; + +/** + Action registered on the following events: + - 'UIControlEventTouchUpInside' for each UIButton instance. + - 'UIControlEventValueChanged' for each UISwitch instance. + */ +- (IBAction)onButtonPressed:(id)sender; + +/** + Action registered to handle text field edition + */ +- (IBAction)textFieldEditingChanged:(id)sender; + +/** + Prompt user to save potential changes before leaving the view controller. + + @param handler A block object called when the changes have been saved or discarded. + + @return YES if no change is observed. NO when the user is prompted. + */ +- (BOOL)shouldLeave:(blockMXKAccountDetailsViewController_onReadyToLeave)handler; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.m new file mode 100644 index 000000000..95732b072 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.m @@ -0,0 +1,1172 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAccountDetailsViewController.h" + +@import MatrixSDK; +#import "MXK3PID.h" + +#import "MXKTools.h" + +#import "MXKTableViewCellWithButton.h" +#import "MXKTableViewCellWithTextFieldAndButton.h" +#import "MXKTableViewCellWithLabelTextFieldAndButton.h" +#import "MXKTableViewCellWithTextView.h" +#import "MXKTableViewCellWithLabelAndSwitch.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKConstants.h" + +#import "MXKSwiftHeader.h" + +NSString* const kMXKAccountDetailsLinkedEmailCellId = @"kMXKAccountDetailsLinkedEmailCellId"; + +@interface MXKAccountDetailsViewController () +{ + NSMutableArray *alertsArray; + + // User's profile + MXMediaLoader *imageLoader; + NSString *currentDisplayName; + NSString *currentPictureURL; + NSString *currentDownloadId; + NSString *uploadedPictureURL; + // Local changes + BOOL isAvatarUpdated; + BOOL isSavingInProgress; + blockMXKAccountDetailsViewController_onReadyToLeave onReadyToLeaveHandler; + + // account user's profile observer + id accountUserInfoObserver; + + // Dynamic rows in the Linked emails section + NSInteger submittedEmailRowIndex; + + // Notifications + // Dynamic rows in the Notifications section + NSInteger enablePushNotifRowIndex; + NSInteger enableInAppNotifRowIndex; + + UIImagePickerController *mediaPicker; +} + +@end + +@implementation MXKAccountDetailsViewController +@synthesize userPictureButton, userDisplayName, saveUserInfoButton; +@synthesize profileActivityIndicator, profileActivityIndicatorBgView; + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKAccountDetailsViewController class]) + bundle:[NSBundle bundleForClass:[MXKAccountDetailsViewController class]]]; +} + ++ (instancetype)accountDetailsViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKAccountDetailsViewController class]) + bundle:[NSBundle bundleForClass:[MXKAccountDetailsViewController class]]]; +} + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + alertsArray = [NSMutableArray array]; + + isAvatarUpdated = NO; + isSavingInProgress = NO; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!userPictureButton) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + self.userPictureButton.backgroundColor = [UIColor clearColor]; + [self updateUserPictureButton:self.picturePlaceholder]; + + [userPictureButton.layer setCornerRadius:userPictureButton.frame.size.width / 2]; + userPictureButton.clipsToBounds = YES; + + [saveUserInfoButton setTitle:[MatrixKitL10n accountSaveChanges] forState:UIControlStateNormal]; + [saveUserInfoButton setTitle:[MatrixKitL10n accountSaveChanges] forState:UIControlStateHighlighted]; + + // Force refresh + self.mxAccount = _mxAccount; +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; + // Dispose of any resources that can be recreated. + + if (imageLoader) + { + [imageLoader cancel]; + imageLoader = nil; + } +} + +- (void)dealloc +{ + alertsArray = nil; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onAPNSStatusUpdate) name:kMXKAccountAPNSActivityDidChangeNotification object:nil]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + [self stopProfileActivityIndicator]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXKAccountAPNSActivityDidChangeNotification object:nil]; +} + +#pragma mark - override + +- (void)onMatrixSessionChange +{ + [super onMatrixSessionChange]; + + if (self.mainSession.state != MXSessionStateRunning) + { + userPictureButton.enabled = NO; + userDisplayName.enabled = NO; + } + else if (!isSavingInProgress) + { + userPictureButton.enabled = YES; + userDisplayName.enabled = YES; + } +} + +#pragma mark - + +- (void)setMxAccount:(MXKAccount *)account +{ + // Remove observer and existing data + [self reset]; + + _mxAccount = account; + + if (account) + { + // Report matrix account session + [self addMatrixSession:account.mxSession]; + + // Set current user's information and add observers + [self updateUserPicture:_mxAccount.userAvatarUrl force:YES]; + currentDisplayName = _mxAccount.userDisplayName; + self.userDisplayName.text = currentDisplayName; + [self updateSaveUserInfoButtonStatus]; + + // Load linked emails + [self loadLinkedEmails]; + + // Add observer on user's information + accountUserInfoObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXKAccountUserInfoDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + // Ignore any refresh when saving is in progress + if (self->isSavingInProgress) + { + return; + } + + NSString *accountUserId = notif.object; + + if ([accountUserId isEqualToString:self->_mxAccount.mxCredentials.userId]) + { + // Update displayName + if (![self->currentDisplayName isEqualToString:self->_mxAccount.userDisplayName]) + { + self->currentDisplayName = self->_mxAccount.userDisplayName; + self.userDisplayName.text = self->_mxAccount.userDisplayName; + } + // Update user's avatar + [self updateUserPicture:self->_mxAccount.userAvatarUrl force:NO]; + + // Update button management + [self updateSaveUserInfoButtonStatus]; + + // Display user's presence + UIColor *presenceColor = [MXKAccount presenceColor:self->_mxAccount.userPresence]; + if (presenceColor) + { + self->userPictureButton.layer.borderWidth = 2; + self->userPictureButton.layer.borderColor = presenceColor.CGColor; + } + else + { + self->userPictureButton.layer.borderWidth = 0; + } + } + }]; + } + + [self.tableView reloadData]; +} + +- (UIImage*)picturePlaceholder +{ + return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"]; +} + +- (BOOL)shouldLeave:(blockMXKAccountDetailsViewController_onReadyToLeave)handler +{ + // Check whether some local changes have not been saved + if (saveUserInfoButton.enabled) + { + dispatch_async(dispatch_get_main_queue(), ^{ + + UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil message:[MatrixKitL10n messageUnsavedChanges] preferredStyle:UIAlertControllerStyleAlert]; + + [self->alertsArray addObject:alert]; + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n discard] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + + // Discard changes + self.userDisplayName.text = self->currentDisplayName; + [self updateUserPicture:self->_mxAccount.userAvatarUrl force:YES]; + + // Ready to leave + if (handler) + { + handler(); + } + + }]]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n save] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + + // Start saving (Report handler to leave at the end). + self->onReadyToLeaveHandler = handler; + [self saveUserInfo]; + + }]]; + + [self presentViewController:alert animated:YES completion:nil]; + }); + + return NO; + } + else if (isSavingInProgress) + { + // Report handler to leave at the end of saving + onReadyToLeaveHandler = handler; + return NO; + } + return YES; +} + +#pragma mark - Internal methods + +- (void)startProfileActivityIndicator +{ + if (profileActivityIndicatorBgView.hidden) + { + profileActivityIndicatorBgView.hidden = NO; + [profileActivityIndicator startAnimating]; + } + userPictureButton.enabled = NO; + userDisplayName.enabled = NO; + saveUserInfoButton.enabled = NO; +} + +- (void)stopProfileActivityIndicator +{ + if (!isSavingInProgress) + { + if (!profileActivityIndicatorBgView.hidden) + { + profileActivityIndicatorBgView.hidden = YES; + [profileActivityIndicator stopAnimating]; + } + userPictureButton.enabled = YES; + userDisplayName.enabled = YES; + [self updateSaveUserInfoButtonStatus]; + } +} + +- (void)reset +{ + [self dismissMediaPicker]; + + // Remove observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + // Cancel picture loader (if any) + if (imageLoader) + { + [imageLoader cancel]; + imageLoader = nil; + } + + // Cancel potential alerts + for (UIAlertController *alert in alertsArray) + { + [alert dismissViewControllerAnimated:NO completion:nil]; + } + + // Remove listener + if (accountUserInfoObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:accountUserInfoObserver]; + accountUserInfoObserver = nil; + } + + currentPictureURL = nil; + currentDownloadId = nil; + uploadedPictureURL = nil; + isAvatarUpdated = NO; + [self updateUserPictureButton:self.picturePlaceholder]; + + currentDisplayName = nil; + self.userDisplayName.text = nil; + + saveUserInfoButton.enabled = NO; + + submittedEmail = nil; + emailSubmitButton = nil; + emailTextField = nil; + + [self removeMatrixSession:self.mainSession]; + + logoutButton = nil; + + onReadyToLeaveHandler = nil; +} + +- (void)destroy +{ + if (isSavingInProgress) + { + __weak typeof(self) weakSelf = self; + onReadyToLeaveHandler = ^() + { + __strong __typeof(weakSelf)strongSelf = weakSelf; + [strongSelf destroy]; + }; + } + else + { + // Reset account to dispose all resources (Discard here potentials changes) + self.mxAccount = nil; + + if (imageLoader) + { + [imageLoader cancel]; + imageLoader = nil; + } + + // Remove listener + if (accountUserInfoObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:accountUserInfoObserver]; + accountUserInfoObserver = nil; + } + + [super destroy]; + } +} + +- (void)saveUserInfo +{ + [self startProfileActivityIndicator]; + isSavingInProgress = YES; + + // Check whether the display name has been changed + NSString *displayname = self.userDisplayName.text; + if ((displayname.length || currentDisplayName.length) && [displayname isEqualToString:currentDisplayName] == NO) + { + // Save display name + __weak typeof(self) weakSelf = self; + + [_mxAccount setUserDisplayName:displayname success:^{ + + if (weakSelf) + { + // Update the current displayname + typeof(self) self = weakSelf; + self->currentDisplayName = displayname; + + // Go to the next change saving step + [self saveUserInfo]; + } + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKAccountDetailsVC] Failed to set displayName"); + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Alert user + NSString *title = [error.userInfo valueForKey:NSLocalizedFailureReasonErrorKey]; + if (!title) + { + title = [MatrixKitL10n accountErrorDisplayNameChangeFailed]; + } + NSString *msg = [error.userInfo valueForKey:NSLocalizedDescriptionKey]; + + UIAlertController *alert = [UIAlertController alertControllerWithTitle:title message:msg preferredStyle:UIAlertControllerStyleAlert]; + + [self->alertsArray addObject:alert]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n abort] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + // Discard changes + self.userDisplayName.text = self->currentDisplayName; + [self updateUserPicture:self.mxAccount.userAvatarUrl force:YES]; + // Loop to end saving + [self saveUserInfo]; + + }]]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n retry] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + // Loop to retry saving + [self saveUserInfo]; + + }]]; + + + [self presentViewController:alert animated:YES completion:nil]; + } + + }]; + + return; + } + + // Check whether avatar has been updated + if (isAvatarUpdated) + { + if (uploadedPictureURL == nil) + { + // Retrieve the current picture and make sure its orientation is up + UIImage *updatedPicture = [MXKTools forceImageOrientationUp:[self.userPictureButton imageForState:UIControlStateNormal]]; + + MXWeakify(self); + + // Upload picture + MXMediaLoader *uploader = [MXMediaManager prepareUploaderWithMatrixSession:self.mainSession initialRange:0 andRange:1.0]; + [uploader uploadData:UIImageJPEGRepresentation(updatedPicture, 0.5) filename:nil mimeType:@"image/jpeg" success:^(NSString *url) + { + MXStrongifyAndReturnIfNil(self); + + // Store uploaded picture url and trigger picture saving + self->uploadedPictureURL = url; + [self saveUserInfo]; + } failure:^(NSError *error) + { + MXLogDebug(@"[MXKAccountDetailsVC] Failed to upload image"); + MXStrongifyAndReturnIfNil(self); + [self handleErrorDuringPictureSaving:error]; + }]; + + } + else + { + MXWeakify(self); + + [_mxAccount setUserAvatarUrl:uploadedPictureURL + success:^{ + + // uploadedPictureURL becomes the user's picture + MXStrongifyAndReturnIfNil(self); + + [self updateUserPicture:self->uploadedPictureURL force:YES]; + // Loop to end saving + [self saveUserInfo]; + + } + failure:^(NSError *error) { + MXLogDebug(@"[MXKAccountDetailsVC] Failed to set avatar url"); + MXStrongifyAndReturnIfNil(self); + [self handleErrorDuringPictureSaving:error]; + }]; + } + + return; + } + + // Backup is complete + isSavingInProgress = NO; + [self stopProfileActivityIndicator]; + + // Ready to leave + if (onReadyToLeaveHandler) + { + onReadyToLeaveHandler(); + onReadyToLeaveHandler = nil; + } +} + +- (void)handleErrorDuringPictureSaving:(NSError*)error +{ + NSString *title = [error.userInfo valueForKey:NSLocalizedFailureReasonErrorKey]; + if (!title) + { + title = [MatrixKitL10n accountErrorPictureChangeFailed]; + } + NSString *msg = [error.userInfo valueForKey:NSLocalizedDescriptionKey]; + + UIAlertController *alert = [UIAlertController alertControllerWithTitle:title message:msg preferredStyle:UIAlertControllerStyleAlert]; + + [alertsArray addObject:alert]; + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n abort] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + + // Remove change + self.userDisplayName.text = self->currentDisplayName; + [self updateUserPicture:self->_mxAccount.userAvatarUrl force:YES]; + // Loop to end saving + [self saveUserInfo]; + + }]]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n retry] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + + // Loop to retry saving + [self saveUserInfo]; + + }]]; + + [self presentViewController:alert animated:YES completion:nil]; +} + +- (void)updateUserPicture:(NSString *)avatar_url force:(BOOL)force +{ + if (force || currentPictureURL == nil || [currentPictureURL isEqualToString:avatar_url] == NO) + { + // Remove any pending observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + // Cancel previous loader (if any) + if (imageLoader) + { + [imageLoader cancel]; + imageLoader = nil; + } + // Cancel any local change + isAvatarUpdated = NO; + uploadedPictureURL = nil; + + currentPictureURL = [avatar_url isEqual:[NSNull null]] ? nil : avatar_url; + + // Check whether this url is valid + currentDownloadId = [MXMediaManager thumbnailDownloadIdForMatrixContentURI:currentPictureURL + inFolder:kMXMediaManagerAvatarThumbnailFolder + toFitViewSize:self.userPictureButton.frame.size + withMethod:MXThumbnailingMethodCrop]; + if (!currentDownloadId) + { + // Set the placeholder in case of invalid Matrix Content URI. + [self updateUserPictureButton:self.picturePlaceholder]; + } + else + { + // Check whether the image download is in progress + id loader = [MXMediaManager existingDownloaderWithIdentifier:currentDownloadId]; + if (loader) + { + // Observe this loader + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(onMediaLoaderStateChange:) + name:kMXMediaLoaderStateDidChangeNotification + object:loader]; + } + else + { + NSString *cacheFilePath = [MXMediaManager thumbnailCachePathForMatrixContentURI:currentPictureURL + andType:nil + inFolder:kMXMediaManagerAvatarThumbnailFolder + toFitViewSize:self.userPictureButton.frame.size + withMethod:MXThumbnailingMethodCrop]; + // Retrieve the image from cache + UIImage* image = [MXMediaManager loadPictureFromFilePath:cacheFilePath]; + if (image) + { + [self updateUserPictureButton:image]; + } + else + { + // Download the image, by adding download observer + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(onMediaLoaderStateChange:) + name:kMXMediaLoaderStateDidChangeNotification + object:nil]; + imageLoader = [self.mainSession.mediaManager downloadThumbnailFromMatrixContentURI:currentPictureURL + withType:nil + inFolder:kMXMediaManagerAvatarThumbnailFolder + toFitViewSize:self.userPictureButton.frame.size + withMethod:MXThumbnailingMethodCrop + success:nil + failure:nil]; + } + } + } + } +} + +- (void)updateUserPictureButton:(UIImage*)image +{ + [self.userPictureButton setImage:image forState:UIControlStateNormal]; + [self.userPictureButton setImage:image forState:UIControlStateHighlighted]; + [self.userPictureButton setImage:image forState:UIControlStateDisabled]; +} + +- (void)updateSaveUserInfoButtonStatus +{ + // Check whether display name has been changed + NSString *displayname = self.userDisplayName.text; + BOOL isDisplayNameUpdated = ((displayname.length || currentDisplayName.length) && [displayname isEqualToString:currentDisplayName] == NO); + + saveUserInfoButton.enabled = (isDisplayNameUpdated || isAvatarUpdated) && !isSavingInProgress; +} + +- (void)onMediaLoaderStateChange:(NSNotification *)notif +{ + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + if ([loader.downloadId isEqualToString:currentDownloadId]) + { + // update the image + switch (loader.state) { + case MXMediaLoaderStateDownloadCompleted: + { + UIImage *image = [MXMediaManager loadPictureFromFilePath:loader.downloadOutputFilePath]; + if (image == nil) + { + image = self.picturePlaceholder; + } + [self updateUserPictureButton:image]; + // remove the observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + imageLoader = nil; + break; + } + case MXMediaLoaderStateDownloadFailed: + case MXMediaLoaderStateCancelled: + [self updateUserPictureButton:self.picturePlaceholder]; + // remove the observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + imageLoader = nil; + // Reset picture URL in order to try next time + currentPictureURL = nil; + break; + default: + break; + } + } +} + +- (void)onAPNSStatusUpdate +{ + // Force table reload to update notifications section + apnsNotificationsSwitch = nil; + + [self.tableView reloadData]; +} + +- (void)dismissMediaPicker +{ + if (mediaPicker) + { + [self dismissViewControllerAnimated:NO completion:nil]; + mediaPicker.delegate = nil; + mediaPicker = nil; + } +} + +- (void)showValidationEmailDialogWithMessage:(NSString*)message +{ + UIAlertController *alert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n accountEmailValidationTitle] message:message preferredStyle:UIAlertControllerStyleAlert]; + + [alertsArray addObject:alert]; + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n abort] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + + self->emailSubmitButton.enabled = YES; + + }]]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n continue] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + + __weak typeof(self) weakSelf = self; + + // We do not bind anymore emails when registering, so let's do the same here + [self->submittedEmail add3PIDToUser:NO success:^{ + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Release pending email and refresh table to remove related cell + self->emailTextField.text = nil; + self->submittedEmail = nil; + + // Update linked emails + [self loadLinkedEmails]; + } + + } failure:^(NSError *error) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + MXLogDebug(@"[MXKAccountDetailsVC] Failed to bind email"); + + // Display the same popup again if the error is M_THREEPID_AUTH_FAILED + MXError *mxError = [[MXError alloc] initWithNSError:error]; + if (mxError && [mxError.errcode isEqualToString:kMXErrCodeStringThreePIDAuthFailed]) + { + [self showValidationEmailDialogWithMessage:[MatrixKitL10n accountEmailValidationError]]; + } + else + { + // Notify MatrixKit user + NSString *myUserId = self.mxAccount.mxCredentials.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + } + + // Release the pending email (even if it is Authenticated) + [self.tableView reloadData]; + } + + }]; + + }]]; + + [self presentViewController:alert animated:YES completion:nil]; +} + +- (void)loadLinkedEmails +{ + // Refresh the account 3PIDs list + [_mxAccount load3PIDs:^{ + + [self.tableView reloadData]; + + } failure:^(NSError *error) { + // Display the data that has been loaded last time + [self.tableView reloadData]; + }]; +} + +#pragma mark - Actions + +- (IBAction)onButtonPressed:(id)sender +{ + [self dismissKeyboard]; + + if (sender == saveUserInfoButton) + { + [self saveUserInfo]; + } + else if (sender == userPictureButton) + { + // Open picture gallery + mediaPicker = [[UIImagePickerController alloc] init]; + mediaPicker.delegate = self; + mediaPicker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary; + mediaPicker.allowsEditing = NO; + [self presentViewController:mediaPicker animated:YES completion:nil]; + } + else if (sender == logoutButton) + { + [[MXKAccountManager sharedManager] removeAccount:_mxAccount completion:nil]; + self.mxAccount = nil; + } + else if (sender == emailSubmitButton) + { + // Email check + if (![MXTools isEmailAddress:emailTextField.text]) + { + UIAlertController *alert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n accountErrorEmailWrongTitle] message:[MatrixKitL10n accountErrorEmailWrongDescription] preferredStyle:UIAlertControllerStyleAlert]; + + [alertsArray addObject:alert]; + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [self->alertsArray removeObject:alert]; + + }]]; + + [self presentViewController:alert animated:YES completion:nil]; + + return; + } + + if (!submittedEmail || ![submittedEmail.address isEqualToString:emailTextField.text]) + { + submittedEmail = [[MXK3PID alloc] initWithMedium:kMX3PIDMediumEmail andAddress:emailTextField.text]; + } + + emailSubmitButton.enabled = NO; + __weak typeof(self) weakSelf = self; + + [submittedEmail requestValidationTokenWithMatrixRestClient:self.mainSession.matrixRestClient isDuringRegistration:NO nextLink:nil success:^{ + + if (weakSelf) + { + typeof(self) self = weakSelf; + [self showValidationEmailDialogWithMessage:[MatrixKitL10n accountEmailValidationMessage]]; + } + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKAccountDetailsVC] Failed to request email token"); + if (weakSelf) + { + typeof(self) self = weakSelf; + // Notify MatrixKit user + NSString *myUserId = self.mxAccount.mxCredentials.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + self->emailSubmitButton.enabled = YES; + } + + }]; + } + else if (sender == apnsNotificationsSwitch) + { + [_mxAccount enablePushNotifications:apnsNotificationsSwitch.on success:nil failure:nil]; + apnsNotificationsSwitch.enabled = NO; + } + else if (sender == inAppNotificationsSwitch) + { + _mxAccount.enableInAppNotifications = inAppNotificationsSwitch.on; + [self.tableView reloadData]; + } +} + +#pragma mark - keyboard + +- (void)dismissKeyboard +{ + if ([userDisplayName isFirstResponder]) + { + // Hide the keyboard + [userDisplayName resignFirstResponder]; + [self updateSaveUserInfoButtonStatus]; + } + else if ([emailTextField isFirstResponder]) + { + [emailTextField resignFirstResponder]; + } +} + +#pragma mark - UITextField delegate + +- (BOOL)textFieldShouldReturn:(UITextField*)textField +{ + // "Done" key has been pressed + [self dismissKeyboard]; + return YES; +} + +- (IBAction)textFieldEditingChanged:(id)sender +{ + if (sender == userDisplayName) + { + [self updateSaveUserInfoButtonStatus]; + } +} + +#pragma mark - UITableView data source + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + NSInteger count = 0; + + linkedEmailsSection = notificationsSection = configurationSection = -1; + + if (!_mxAccount.disabled) + { + linkedEmailsSection = count ++; + notificationsSection = count ++; + } + + configurationSection = count ++; + + return count; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + NSInteger count = 0; + if (section == linkedEmailsSection) + { + count = _mxAccount.linkedEmails.count; + submittedEmailRowIndex = count++; + } + else if (section == notificationsSection) + { + enableInAppNotifRowIndex = enablePushNotifRowIndex = -1; + + if ([MXKAccountManager sharedManager].isAPNSAvailable) { + enablePushNotifRowIndex = count++; + } + enableInAppNotifRowIndex = count++; + } + else if (section == configurationSection) + { + count = 2; + } + + return count; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath.section == configurationSection) + { + if (indexPath.row == 0) + { + UITextView *textView = [[UITextView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, MAXFLOAT)]; + textView.font = [UIFont systemFontOfSize:14]; + textView.text = [NSString stringWithFormat:@"%@\n%@\n%@", [MatrixKitL10n settingsConfigHomeServer:_mxAccount.mxCredentials.homeServer], [MatrixKitL10n settingsConfigIdentityServer:_mxAccount.identityServerURL], [MatrixKitL10n settingsConfigUserId:_mxAccount.mxCredentials.userId]]; + + CGSize contentSize = [textView sizeThatFits:textView.frame.size]; + return contentSize.height + 1; + } + } + + return 44; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell *cell = nil; + + if (indexPath.section == linkedEmailsSection) + { + if (indexPath.row < _mxAccount.linkedEmails.count) + { + cell = [tableView dequeueReusableCellWithIdentifier:kMXKAccountDetailsLinkedEmailCellId]; + if (!cell) + { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:kMXKAccountDetailsLinkedEmailCellId]; + } + + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.textLabel.text = [_mxAccount.linkedEmails objectAtIndex:indexPath.row]; + } + else if (indexPath.row == submittedEmailRowIndex) + { + // Report the current email value (if any) + NSString *currentEmail = nil; + if (emailTextField) + { + currentEmail = emailTextField.text; + } + + MXKTableViewCellWithTextFieldAndButton *submittedEmailCell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithTextFieldAndButton defaultReuseIdentifier]]; + if (!submittedEmailCell) + { + submittedEmailCell = [[MXKTableViewCellWithTextFieldAndButton alloc] init]; + } + + submittedEmailCell.mxkTextField.text = currentEmail; + submittedEmailCell.mxkTextField.keyboardType = UIKeyboardTypeEmailAddress; + submittedEmailCell.mxkButton.enabled = (currentEmail.length != 0); + [submittedEmailCell.mxkButton setTitle:[MatrixKitL10n accountLinkEmail] forState:UIControlStateNormal]; + [submittedEmailCell.mxkButton setTitle:[MatrixKitL10n accountLinkEmail] forState:UIControlStateHighlighted]; + [submittedEmailCell.mxkButton addTarget:self action:@selector(onButtonPressed:) forControlEvents:UIControlEventTouchUpInside]; + + emailSubmitButton = submittedEmailCell.mxkButton; + emailTextField = submittedEmailCell.mxkTextField; + + cell = submittedEmailCell; + } + } + else if (indexPath.section == notificationsSection) + { + MXKTableViewCellWithLabelAndSwitch *notificationsCell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithLabelAndSwitch defaultReuseIdentifier]]; + if (!notificationsCell) + { + notificationsCell = [[MXKTableViewCellWithLabelAndSwitch alloc] init]; + } + else + { + // Force layout before reusing a cell (fix switch displayed outside the screen) + [notificationsCell layoutIfNeeded]; + } + + [notificationsCell.mxkSwitch addTarget:self action:@selector(onButtonPressed:) forControlEvents:UIControlEventValueChanged]; + + if (indexPath.row == enableInAppNotifRowIndex) + { + notificationsCell.mxkLabel.text = [MatrixKitL10n settingsEnableInappNotifications]; + notificationsCell.mxkSwitch.on = _mxAccount.enableInAppNotifications; + inAppNotificationsSwitch = notificationsCell.mxkSwitch; + } + else /* enablePushNotifRowIndex */ + { + notificationsCell.mxkLabel.text = [MatrixKitL10n settingsEnablePushNotifications]; + notificationsCell.mxkSwitch.on = _mxAccount.pushNotificationServiceIsActive; + notificationsCell.mxkSwitch.enabled = YES; + apnsNotificationsSwitch = notificationsCell.mxkSwitch; + } + + cell = notificationsCell; + } + else if (indexPath.section == configurationSection) + { + if (indexPath.row == 0) + { + MXKTableViewCellWithTextView *configCell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithTextView defaultReuseIdentifier]]; + if (!configCell) + { + configCell = [[MXKTableViewCellWithTextView alloc] init]; + } + + configCell.mxkTextView.text = [NSString stringWithFormat:@"%@\n%@\n%@", [MatrixKitL10n settingsConfigHomeServer:_mxAccount.mxCredentials.homeServer], [MatrixKitL10n settingsConfigIdentityServer:_mxAccount.identityServerURL], [MatrixKitL10n settingsConfigUserId:_mxAccount.mxCredentials.userId]]; + + cell = configCell; + } + else + { + MXKTableViewCellWithButton *logoutBtnCell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithButton defaultReuseIdentifier]]; + if (!logoutBtnCell) + { + logoutBtnCell = [[MXKTableViewCellWithButton alloc] init]; + } + [logoutBtnCell.mxkButton setTitle:[MatrixKitL10n actionLogout] forState:UIControlStateNormal]; + [logoutBtnCell.mxkButton setTitle:[MatrixKitL10n actionLogout] forState:UIControlStateHighlighted]; + [logoutBtnCell.mxkButton addTarget:self action:@selector(onButtonPressed:) forControlEvents:UIControlEventTouchUpInside]; + + logoutButton = logoutBtnCell.mxkButton; + + cell = logoutBtnCell; + } + + } + else + { + // Return a fake cell to prevent app from crashing. + cell = [[UITableViewCell alloc] init]; + } + + return cell; +} + +#pragma mark - UITableView delegate + +- (CGFloat) tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section +{ + return 30; +} +- (CGFloat) tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section +{ + return 1; +} + +- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section +{ + UIView *sectionHeader = [[UIView alloc] initWithFrame:[tableView rectForHeaderInSection:section]]; + sectionHeader.backgroundColor = [UIColor colorWithRed:0.9 green:0.9 blue:0.9 alpha:1.0]; + UILabel *sectionLabel = [[UILabel alloc] initWithFrame:CGRectMake(5, 5, sectionHeader.frame.size.width - 10, sectionHeader.frame.size.height - 10)]; + sectionLabel.font = [UIFont boldSystemFontOfSize:16]; + sectionLabel.backgroundColor = [UIColor clearColor]; + [sectionHeader addSubview:sectionLabel]; + + if (section == linkedEmailsSection) + { + sectionLabel.text = [MatrixKitL10n accountLinkedEmails]; + } + else if (section == notificationsSection) + { + sectionLabel.text = [MatrixKitL10n settingsTitleNotifications]; + } + else if (section == configurationSection) + { + sectionLabel.text = [MatrixKitL10n settingsTitleConfig]; + } + + return sectionHeader; +} + +- (void)tableView:(UITableView *)aTableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (self.tableView == aTableView) + { + [aTableView deselectRowAtIndexPath:indexPath animated:YES]; + } +} + +# pragma mark - UIImagePickerControllerDelegate + +- (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info +{ + UIImage *selectedImage = [info objectForKey:UIImagePickerControllerOriginalImage]; + if (selectedImage) + { + [self updateUserPictureButton:selectedImage]; + isAvatarUpdated = YES; + saveUserInfoButton.enabled = YES; + } + [self dismissMediaPicker]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.xib new file mode 100644 index 000000000..2d7cfd528 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAccountDetailsViewController.xib @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKActivityHandlingViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKActivityHandlingViewController.h new file mode 100644 index 000000000..400b9cc3b --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKActivityHandlingViewController.h @@ -0,0 +1,26 @@ +// +// Copyright 2020 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import +#import "MXKViewControllerActivityHandling.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MXKActivityHandlingViewController : UIViewController + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Controllers/MXKActivityHandlingViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKActivityHandlingViewController.m new file mode 100644 index 000000000..5628c7396 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKActivityHandlingViewController.m @@ -0,0 +1,83 @@ +// +// Copyright 2020 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "MXKActivityHandlingViewController.h" + +@interface MXKActivityHandlingViewController () + +@end + +@implementation MXKActivityHandlingViewController +@synthesize activityIndicator; + +#pragma mark - + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Add default activity indicator + activityIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhite]; + activityIndicator.backgroundColor = [UIColor colorWithRed:0.8 green:0.8 blue:0.8 alpha:1.0]; + activityIndicator.autoresizingMask = UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin | UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin; + activityIndicator.hidesWhenStopped = YES; + + CGRect frame = activityIndicator.frame; + frame.size.width += 30; + frame.size.height += 30; + activityIndicator.bounds = frame; + [activityIndicator.layer setCornerRadius:5]; + + activityIndicator.center = self.view.center; + [self.view addSubview:activityIndicator]; +} + +- (void)dealloc +{ + if (activityIndicator) + { + [activityIndicator removeFromSuperview]; + activityIndicator = nil; + } +} + +#pragma mark - Activity indicator + +- (void)startActivityIndicator +{ + if (activityIndicator) + { + [self.view bringSubviewToFront:activityIndicator]; + [activityIndicator startAnimating]; + + // Show the loading wheel after a delay so that if the caller calls stopActivityIndicator + // in a short future, the loading wheel will not be displayed to the end user. + activityIndicator.alpha = 0; + [UIView animateWithDuration:0.3 delay:0.3 options:UIViewAnimationOptionBeginFromCurrentState animations:^{ + self->activityIndicator.alpha = 1; + } completion:^(BOOL finished) + { + }]; + } +} + +- (void)stopActivityIndicator +{ + [activityIndicator stopAnimating]; +} + + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.h new file mode 100644 index 000000000..60bfdf732 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.h @@ -0,0 +1,123 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#import + +#import "MXKViewController.h" +#import "MXKAttachment.h" +#import "MXKAttachmentAnimator.h" + +@protocol MXKAttachmentsViewControllerDelegate; + +/** + This view controller is used to display attachments of a room. + Only one attachment is displayed at once, the user is able to swipe one by one the attachment. + */ +@interface MXKAttachmentsViewController : MXKViewController + +@property (nonatomic) IBOutlet UICollectionView *attachmentsCollection; +@property (nonatomic) IBOutlet UINavigationBar *navigationBar; +@property (unsafe_unretained, nonatomic) IBOutlet UIBarButtonItem *backButton; + +/** + The attachments array. + */ +@property (nonatomic, readonly) NSArray *attachments; + +/** + Tell whether all attachments have been retrieved from the room history (In that case no attachment can be added at the beginning of attachments array). + */ +@property (nonatomic) BOOL complete; + +/** + The delegate notified when inputs are ready. + */ +@property (nonatomic, weak) id delegate; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKAttachmentsViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `roomViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKAttachmentsViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKAttachmentsViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)attachmentsViewController; + +/** + Creates and returns a new `MXKAttachmentsViewController` object, also sets sets up environment for animated interactive transitions. + */ ++ (instancetype)animatedAttachmentsViewControllerWithSourceViewController:(UIViewController *)sourceViewController; + +/** + Display attachments of a room. + + The provided event id is used to select the attachment to display first. Use nil to unchange the current displayed attachment. + By default the first attachment is displayed. + If the back pagination spinner is currently displayed and provided event id is nil, + the viewer will display the first added attachment during back pagination. + + @param attachmentArray the array of attachments (MXKAttachment instances). + @param eventId the identifier of the attachment to display first. + + */ +- (void)displayAttachments:(NSArray*)attachmentArray focusOn:(NSString*)eventId; + +/** + Action used to handle the `backButton` in the navigation bar. + */ +- (IBAction)onButtonPressed:(id)sender; + +@end + +@protocol MXKAttachmentsViewControllerDelegate + +/** + Ask the delegate for more attachments. + This method is called only if 'complete' is NO. + + When some attachments are available, the delegate update the attachmnet list by using + [MXKAttachmentsViewController displayAttachments: focusOn:]. + When no new attachment is available, the delegate must update the property 'complete'. + + @param attachmentsViewController the attachments view controller. + @param eventId the event identifier of the current first attachment. + @return a boolean which tells whether some new attachments may be added or not. + */ +- (BOOL)attachmentsViewController:(MXKAttachmentsViewController*)attachmentsViewController paginateAttachmentBefore:(NSString*)eventId; + +@optional + +/** + Informs the delegate that a new attachment has been shown + the parameter eventId is used by the delegate to identify the attachment + */ +- (void)displayedNewAttachmentWithEventId:(NSString *)eventId; + + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.m new file mode 100644 index 000000000..a21fbc396 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.m @@ -0,0 +1,1439 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAttachmentsViewController.h" + +#import + +@import MatrixSDK.MXMediaManager; + +#import "MXKMediaCollectionViewCell.h" + +#import "MXKPieChartView.h" + +#import "MXKConstants.h" + +#import "MXKTools.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKEventFormatter.h" + +#import "MXKAttachmentInteractionController.h" + +#import "MXKSwiftHeader.h" + +@interface MXKAttachmentsViewController () +{ + /** + Current alert (if any). + */ + UIAlertController *currentAlert; + + /** + Navigation bar handling + */ + NSTimer *navigationBarDisplayTimer; + + /** + SplitViewController handling + */ + BOOL shouldRestoreBottomBar; + UISplitViewControllerDisplayMode savedSplitViewControllerDisplayMode; + + /** + Audio session handling + */ + NSString *savedAVAudioSessionCategory; + + /** + The attachments array (MXAttachment instances). + */ + NSMutableArray *attachments; + + /** + The index of the current visible collection item + */ + NSInteger currentVisibleItemIndex; + + /** + The document interaction Controller used to share attachment + */ + UIDocumentInteractionController *documentInteractionController; + MXKAttachment *currentSharedAttachment; + + /** + Tells whether back pagination is in progress. + */ + BOOL isBackPaginationInProgress; + + /** + A temporary file used to store decrypted attachments + */ + NSString *tempFile; + + /** + Path to a file containing video data for the currently selected + attachment, if it's a video attachment and the data is + available. + */ + NSString *videoFile; +} + +//animations +@property (nonatomic) MXKAttachmentInteractionController *interactionController; + +@property (nonatomic, weak) UIViewController *sourceViewController; + +@property (nonatomic) UIImageView *originalImageView; +@property (nonatomic) CGRect convertedFrame; + +@property (nonatomic) BOOL customAnimationsEnabled; + +@end + +@implementation MXKAttachmentsViewController +@synthesize attachments; + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKAttachmentsViewController class]) + bundle:[NSBundle bundleForClass:[MXKAttachmentsViewController class]]]; +} + ++ (instancetype)attachmentsViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKAttachmentsViewController class]) + bundle:[NSBundle bundleForClass:[MXKAttachmentsViewController class]]]; +} + ++ (instancetype)animatedAttachmentsViewControllerWithSourceViewController:(UIViewController *)sourceViewController +{ + MXKAttachmentsViewController *attachmentsController = [[[self class] alloc] initWithNibName:NSStringFromClass([MXKAttachmentsViewController class]) + bundle:[NSBundle bundleForClass:[MXKAttachmentsViewController class]]]; + + //create an interactionController for it to handle the gestue recognizer and control the interactions + attachmentsController.interactionController = [[MXKAttachmentInteractionController alloc] initWithDestinationViewController:attachmentsController sourceViewController:sourceViewController]; + + //we use the animationsEnabled property to enable/disable animations. Instances created not using this method should use the default animations + attachmentsController.customAnimationsEnabled = YES; + + //this properties will be needed by animationControllers in order to perform the animations + attachmentsController.sourceViewController = sourceViewController; + + //setting transitioningDelegate and navigationController.delegate so that the animations will work for present/dismiss as well as push/pop + attachmentsController.transitioningDelegate = attachmentsController; + sourceViewController.navigationController.delegate = attachmentsController; + + + return attachmentsController; +} + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + tempFile = nil; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!_attachmentsCollection) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + self.backButton.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"back_icon"]; + + // Register collection view cell class + [self.attachmentsCollection registerClass:MXKMediaCollectionViewCell.class forCellWithReuseIdentifier:[MXKMediaCollectionViewCell defaultReuseIdentifier]]; + + // Hide collection to hide first scrolling into the attachments. + _attachmentsCollection.hidden = YES; + + // Display collection cell in full screen + self.automaticallyAdjustsScrollViewInsets = NO; +} + +- (BOOL)prefersStatusBarHidden +{ + // Hide status bar. + // Caution: Enable [UIViewController prefersStatusBarHidden] use at application level + // by turning on UIViewControllerBasedStatusBarAppearance in Info.plist. + return YES; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + videoFile = nil; + + savedAVAudioSessionCategory = [[AVAudioSession sharedInstance] category]; + + // Hide navigation bar by default. + [self hideNavigationBar]; + + // Hide status bar + // TODO: remove this [UIApplication statusBarHidden] use (deprecated since iOS 9). + // Note: setting statusBarHidden does nothing if your application is using the default UIViewController-based status bar system. + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + sharedApplication.statusBarHidden = YES; + } + + // Handle here the case of splitviewcontroller use on iOS 8 and later. + if (self.splitViewController && [self.splitViewController respondsToSelector:@selector(displayMode)]) + { + if (self.hidesBottomBarWhenPushed) + { + // This screen should be displayed without tabbar, but hidesBottomBarWhenPushed flag has no effect in case of splitviewcontroller use. + // Trick: on iOS 8 and later the tabbar is hidden manually + shouldRestoreBottomBar = YES; + self.tabBarController.tabBar.hidden = YES; + } + + // Hide the primary view controller to allow full screen display + savedSplitViewControllerDisplayMode = [self.splitViewController displayMode]; + self.splitViewController.preferredDisplayMode = UISplitViewControllerDisplayModePrimaryHidden; + [self.splitViewController.view layoutIfNeeded]; + } + + [_attachmentsCollection reloadData]; +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + + // Adjust content offset and make visible the attachmnet collections + [self refreshAttachmentCollectionContentOffset]; + _attachmentsCollection.hidden = NO; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + if (tempFile) + { + NSError *err; + [[NSFileManager defaultManager] removeItemAtPath:tempFile error:&err]; + tempFile = nil; + } + + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + currentAlert = nil; + } + + // Stop playing any video + for (MXKMediaCollectionViewCell *cell in self.attachmentsCollection.visibleCells) + { + [cell.moviePlayer.player pause]; + cell.moviePlayer.player = nil; + } + + // Restore audio category + if (savedAVAudioSessionCategory) + { + [[AVAudioSession sharedInstance] setCategory:savedAVAudioSessionCategory error:nil]; + savedAVAudioSessionCategory = nil; + } + + [navigationBarDisplayTimer invalidate]; + navigationBarDisplayTimer = nil; + + // Restore status bar + // TODO: remove this [UIApplication statusBarHidden] use (deprecated since iOS 9). + // Note: setting statusBarHidden does nothing if your application is using the default UIViewController-based status bar system. + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + sharedApplication.statusBarHidden = NO; + } + + if (shouldRestoreBottomBar) + { + self.tabBarController.tabBar.hidden = NO; + } + + if (self.splitViewController && [self.splitViewController respondsToSelector:@selector(displayMode)]) + { + self.splitViewController.preferredDisplayMode = savedSplitViewControllerDisplayMode; + [self.splitViewController.view layoutIfNeeded]; + } + + [super viewWillDisappear:animated]; +} + +- (void)dealloc +{ + [self destroy]; +} + +- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id )coordinator +{ + [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(coordinator.transitionDuration * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + + // Cell width will be updated, force collection layout refresh to take into account the changes + [self->_attachmentsCollection.collectionViewLayout invalidateLayout]; + + // Refresh the current attachment display + [self refreshAttachmentCollectionContentOffset]; + + }); +} + +#pragma mark - Override MXKViewController + +- (void)destroy +{ + if (documentInteractionController) + { + [documentInteractionController dismissPreviewAnimated:NO]; + [documentInteractionController dismissMenuAnimated:NO]; + documentInteractionController = nil; + } + + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } + + if (self.sourceViewController) + { + self.sourceViewController.navigationController.delegate = nil; + self.sourceViewController = nil; + } + + [super destroy]; +} + +#pragma mark - Public API + +- (void)displayAttachments:(NSArray*)attachmentArray focusOn:(NSString*)eventId +{ + NSString *currentAttachmentEventId = eventId; + NSString *currentAttachmentOriginalFileName = nil; + + if (currentAttachmentEventId.length == 0 && attachments) + { + if (isBackPaginationInProgress && currentVisibleItemIndex == 0) + { + // Here the spinner were displayed, we update the viewer by displaying the first added attachment + // (the one just added before the first item of the current attachments array). + if (attachments.count) + { + // Retrieve the event id of the first item in the current attachments array + MXKAttachment *attachment = attachments[0]; + NSString *firstAttachmentEventId = attachment.eventId; + NSString *firstAttachmentOriginalFileName = nil; + + // The original file name is used when the attachment is a local echo. + // Indeed its event id may be replaced by the actual one in the new attachments array. + if ([firstAttachmentEventId hasPrefix:kMXEventLocalEventIdPrefix]) + { + firstAttachmentOriginalFileName = attachment.originalFileName; + } + + // Look for the attachment added before this attachment in new array. + for (attachment in attachmentArray) + { + if (firstAttachmentOriginalFileName && [attachment.originalFileName isEqualToString:firstAttachmentOriginalFileName]) + { + break; + } + else if ([attachment.eventId isEqualToString:firstAttachmentEventId]) + { + break; + } + currentAttachmentEventId = attachment.eventId; + } + } + } + else if (currentVisibleItemIndex != NSNotFound) + { + // Compute the attachment index + NSUInteger currentAttachmentIndex = (isBackPaginationInProgress ? currentVisibleItemIndex - 1 : currentVisibleItemIndex); + + if (currentAttachmentIndex < attachments.count) + { + MXKAttachment *attachment = attachments[currentAttachmentIndex]; + currentAttachmentEventId = attachment.eventId; + + // The original file name is used when the attachment is a local echo. + // Indeed its event id may be replaced by the actual one in the new attachments array. + if ([currentAttachmentEventId hasPrefix:kMXEventLocalEventIdPrefix]) + { + currentAttachmentOriginalFileName = attachment.originalFileName; + } + } + } + } + + // Stop back pagination (Do not call here 'stopBackPaginationActivity' because a full collection reload is planned at the end). + isBackPaginationInProgress = NO; + + // Set/reset the attachments array + attachments = [NSMutableArray arrayWithArray:attachmentArray]; + + // Update the index of the current displayed attachment by looking for the + // current event id (or the current original file name, if any) in the new attachments array. + currentVisibleItemIndex = 0; + if (currentAttachmentEventId) + { + for (NSUInteger index = 0; index < attachments.count; index++) + { + MXKAttachment *attachment = attachments[index]; + + // Check first the original filename if any. + if (currentAttachmentOriginalFileName && [attachment.originalFileName isEqualToString:currentAttachmentOriginalFileName]) + { + currentVisibleItemIndex = index; + break; + } + // Check the event id then + else if ([attachment.eventId isEqualToString:currentAttachmentEventId]) + { + currentVisibleItemIndex = index; + break; + } + } + } + + // Refresh + [_attachmentsCollection reloadData]; + + // Adjust content offset + [self refreshAttachmentCollectionContentOffset]; +} + +- (void)setComplete:(BOOL)complete +{ + _complete = complete; + + if (complete) + { + [self stopBackPaginationActivity]; + } +} + +- (IBAction)onButtonPressed:(id)sender +{ + if (sender == self.backButton) + { + [self withdrawViewControllerAnimated:YES completion:nil]; + } +} + +#pragma mark - Privates + +- (IBAction)hideNavigationBar +{ + self.navigationBar.hidden = YES; + + [navigationBarDisplayTimer invalidate]; + navigationBarDisplayTimer = nil; +} + +- (void)refreshCurrentVisibleItemIndex +{ + // Check whether the collection is actually rendered + if (_attachmentsCollection.contentSize.width) + { + currentVisibleItemIndex = _attachmentsCollection.contentOffset.x / [[UIScreen mainScreen] bounds].size.width; + } + else + { + currentVisibleItemIndex = NSNotFound; + } +} + +- (void)refreshAttachmentCollectionContentOffset +{ + if (currentVisibleItemIndex != NSNotFound && _attachmentsCollection) + { + // Set the content offset to display the current attachment + CGPoint contentOffset = _attachmentsCollection.contentOffset; + contentOffset.x = currentVisibleItemIndex * [[UIScreen mainScreen] bounds].size.width; + _attachmentsCollection.contentOffset = contentOffset; + } +} + +- (void)refreshCurrentVisibleCell +{ + // In case of attached image, load here the high res image. + + [self refreshCurrentVisibleItemIndex]; + + if (currentVisibleItemIndex == NSNotFound) { + // Tell the delegate that no attachment is displayed for the moment + if ([self.delegate respondsToSelector:@selector(displayedNewAttachmentWithEventId:)]) + { + [self.delegate displayedNewAttachmentWithEventId:nil]; + } + } + else + { + NSInteger item = currentVisibleItemIndex; + if (isBackPaginationInProgress) + { + if (item == 0) + { + // Tell the delegate that no attachment is displayed for the moment + if ([self.delegate respondsToSelector:@selector(displayedNewAttachmentWithEventId:)]) + { + [self.delegate displayedNewAttachmentWithEventId:nil]; + } + + return; + } + + item --; + } + + if (item < attachments.count) + { + MXKAttachment *attachment = attachments[item]; + NSString *mimeType = attachment.contentInfo[@"mimetype"]; + + // Tell the delegate which attachment has been shown using its eventId + if ([self.delegate respondsToSelector:@selector(displayedNewAttachmentWithEventId:)]) + { + [self.delegate displayedNewAttachmentWithEventId:attachment.eventId]; + } + + // Check attachment type + if (attachment.type == MXKAttachmentTypeImage && attachment.contentURL && ![mimeType isEqualToString:@"image/gif"]) + { + // Retrieve the related cell + UICollectionViewCell *cell = [_attachmentsCollection cellForItemAtIndexPath:[NSIndexPath indexPathForItem:currentVisibleItemIndex inSection:0]]; + if ([cell isKindOfClass:[MXKMediaCollectionViewCell class]]) + { + MXKMediaCollectionViewCell *mediaCollectionViewCell = (MXKMediaCollectionViewCell*)cell; + + // Load high res image + mediaCollectionViewCell.mxkImageView.stretchable = YES; + mediaCollectionViewCell.mxkImageView.enableInMemoryCache = NO; + + [mediaCollectionViewCell.mxkImageView setAttachment:attachment]; + } + } + } + } +} + +- (void)stopBackPaginationActivity +{ + if (isBackPaginationInProgress) + { + isBackPaginationInProgress = NO; + + [self.attachmentsCollection deleteItemsAtIndexPaths:@[[NSIndexPath indexPathForItem:0 inSection:0]]]; + } +} + +- (void)prepareVideoForItem:(NSInteger)item success:(void(^)(void))success failure:(void(^)(NSError *))failure +{ + MXKAttachment *attachment = attachments[item]; + if (attachment.isEncrypted) + { + [attachment decryptToTempFile:^(NSString *file) { + if (self->tempFile) + { + [[NSFileManager defaultManager] removeItemAtPath:self->tempFile error:nil]; + } + self->tempFile = file; + self->videoFile = file; + success(); + } failure:^(NSError *error) { + if (failure) failure(error); + }]; + } + else + { + if ([[NSFileManager defaultManager] fileExistsAtPath:attachment.cacheFilePath]) + { + videoFile = attachment.cacheFilePath; + success(); + } + else + { + [attachment prepare:^{ + self->videoFile = attachment.cacheFilePath; + success(); + } failure:^(NSError *error) { + if (failure) failure(error); + }]; + } + } +} + +#pragma mark - UICollectionViewDataSource + +- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section +{ + if (isBackPaginationInProgress) + { + return (attachments.count + 1); + } + + return attachments.count; +} + +- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath +{ + MXKMediaCollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:[MXKMediaCollectionViewCell defaultReuseIdentifier] + forIndexPath:indexPath]; + + NSInteger item = indexPath.item; + + if (isBackPaginationInProgress) + { + if (item == 0) + { + cell.mxkImageView.hidden = YES; + cell.customView.hidden = NO; + + // Add back pagination spinner + UIActivityIndicatorView* spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; + spinner.autoresizingMask = (UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin); + spinner.hidesWhenStopped = NO; + spinner.backgroundColor = [UIColor clearColor]; + [spinner startAnimating]; + + spinner.center = cell.customView.center; + [cell.customView addSubview:spinner]; + + return cell; + } + + item --; + } + + if (item < attachments.count) + { + MXKAttachment *attachment = attachments[item]; + NSString *mimeType = attachment.contentInfo[@"mimetype"]; + + // Use the cached thumbnail (if any) as preview + UIImage* preview = [attachment getCachedThumbnail]; + + // Check attachment type + if ((attachment.type == MXKAttachmentTypeImage || attachment.type == MXKAttachmentTypeSticker) && attachment.contentURL) + { + if ([mimeType isEqualToString:@"image/gif"]) + { + cell.mxkImageView.hidden = YES; + // Set the preview as the default image even if the image view is hidden. It will be used during zoom out animation. + cell.mxkImageView.image = preview; + + cell.customView.hidden = NO; + + // Animated gif is displayed in webview + CGFloat minSize = (cell.frame.size.width < cell.frame.size.height) ? cell.frame.size.width : cell.frame.size.height; + CGFloat width, height; + if (attachment.contentInfo[@"w"] && attachment.contentInfo[@"h"]) + { + width = [attachment.contentInfo[@"w"] integerValue]; + height = [attachment.contentInfo[@"h"] integerValue]; + if (width > minSize || height > minSize) + { + if (width > height) + { + height = (height * minSize) / width; + height = floorf(height / 2) * 2; + width = minSize; + } + else + { + width = (width * minSize) / height; + width = floorf(width / 2) * 2; + height = minSize; + } + } + else + { + width = minSize; + height = minSize; + } + } + else + { + width = minSize; + height = minSize; + } + + WKWebView *animatedGifViewer = [[WKWebView alloc] initWithFrame:CGRectMake(0, 0, width, height)]; + animatedGifViewer.center = cell.customView.center; + animatedGifViewer.opaque = NO; + animatedGifViewer.backgroundColor = cell.customView.backgroundColor; + animatedGifViewer.contentMode = UIViewContentModeScaleAspectFit; + animatedGifViewer.autoresizingMask = (UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin); + animatedGifViewer.userInteractionEnabled = NO; + [cell.customView addSubview:animatedGifViewer]; + + UIImageView *previewImage = [[UIImageView alloc] initWithFrame:animatedGifViewer.frame]; + previewImage.contentMode = animatedGifViewer.contentMode; + previewImage.autoresizingMask = animatedGifViewer.autoresizingMask; + previewImage.image = preview; + previewImage.center = cell.customView.center; + [cell.customView addSubview:previewImage]; + + MXKPieChartView *pieChartView = [[MXKPieChartView alloc] initWithFrame:CGRectMake(0, 0, 40, 40)]; + pieChartView.progress = 0; + pieChartView.progressColor = [UIColor colorWithRed:1 green:1 blue:1 alpha:0.25]; + pieChartView.unprogressColor = [UIColor clearColor]; + pieChartView.autoresizingMask = animatedGifViewer.autoresizingMask; + pieChartView.center = cell.customView.center; + [cell.customView addSubview:pieChartView]; + + // Add download progress observer + NSString *downloadId = attachment.downloadId; + cell.notificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXMediaLoaderStateDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + if ([loader.downloadId isEqualToString:downloadId]) + { + // update the image + switch (loader.state) { + case MXMediaLoaderStateDownloadInProgress: + { + NSNumber* progressNumber = [loader.statisticsDict valueForKey:kMXMediaLoaderProgressValueKey]; + if (progressNumber) + { + pieChartView.progress = progressNumber.floatValue; + } + break; + } + default: + break; + } + } + + }]; + + void (^onDownloaded)(NSData *) = ^(NSData *data){ + if (cell.notificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:cell.notificationObserver]; + cell.notificationObserver = nil; + } + + if (animatedGifViewer.superview) + { + [animatedGifViewer loadData:data MIMEType:@"image/gif" characterEncodingName:@"UTF-8" baseURL:[NSURL URLWithString:@"http://"]]; + + [pieChartView removeFromSuperview]; + [previewImage removeFromSuperview]; + } + }; + + void (^onFailure)(NSError *) = ^(NSError *error){ + if (cell.notificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:cell.notificationObserver]; + cell.notificationObserver = nil; + } + + MXLogDebug(@"[MXKAttachmentsVC] gif download failed"); + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + }; + + + [attachment getAttachmentData:^(NSData *data) { + onDownloaded(data); + } failure:^(NSError *error) { + onFailure(error); + }]; + } + else if (indexPath.item == currentVisibleItemIndex) + { + // Load high res image + cell.mxkImageView.stretchable = YES; + [cell.mxkImageView setAttachment:attachment]; + } + else + { + // Use the thumbnail here - Full res images should only be downloaded explicitly when requested (see [self refreshCurrentVisibleItemIndex]) + cell.mxkImageView.stretchable = YES; + [cell.mxkImageView setAttachmentThumb:attachment]; + } + } + else if (attachment.type == MXKAttachmentTypeVideo && attachment.contentURL) + { + cell.mxkImageView.mediaFolder = attachment.eventRoomId; + cell.mxkImageView.stretchable = NO; + cell.mxkImageView.enableInMemoryCache = YES; + // Display video thumbnail, the video is played only when user selects this cell + [cell.mxkImageView setAttachmentThumb:attachment]; + + cell.centerIcon.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"play"]; + cell.centerIcon.hidden = NO; + } + + // Add gesture recognizers on collection cell to handle tap and long press on collection cell. + // Note: tap gesture recognizer is required here because mxkImageView enables user interaction to allow image stretching. + // [collectionView:didSelectItemAtIndexPath] is not triggered when mxkImageView is displayed. + UITapGestureRecognizer *cellTapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onCollectionViewCellTap:)]; + [cellTapGesture setNumberOfTouchesRequired:1]; + [cellTapGesture setNumberOfTapsRequired:1]; + cell.tag = item; + [cell addGestureRecognizer:cellTapGesture]; + + UILongPressGestureRecognizer *cellLongPressGesture = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onCollectionViewCellLongPress:)]; + [cell addGestureRecognizer:cellLongPressGesture]; + } + + return cell; +} + +#pragma mark - UICollectionViewDelegate + +- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath +{ + NSInteger item = indexPath.item; + + BOOL navigationBarDisplayHandled = NO; + + if (isBackPaginationInProgress) + { + if (item == 0) + { + return; + } + + item --; + } + + // Check whether the selected attachment is a video + if (item < attachments.count) + { + MXKAttachment *attachment = attachments[item]; + + if (attachment.type == MXKAttachmentTypeVideo && attachment.contentURL) + { + MXKMediaCollectionViewCell *selectedCell = (MXKMediaCollectionViewCell*)[collectionView cellForItemAtIndexPath:indexPath]; + + // Add movie player if none + if (selectedCell.moviePlayer == nil) + { + [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error:nil]; + + selectedCell.moviePlayer = [[AVPlayerViewController alloc] init]; + if (selectedCell.moviePlayer != nil) + { + // Switch in custom view + selectedCell.mxkImageView.hidden = YES; + selectedCell.customView.hidden = NO; + + // Report the video preview + UIImageView *previewImage = [[UIImageView alloc] initWithFrame:selectedCell.customView.frame]; + previewImage.contentMode = UIViewContentModeScaleAspectFit; + previewImage.autoresizingMask = (UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin); + previewImage.image = selectedCell.mxkImageView.image; + previewImage.center = selectedCell.customView.center; + [selectedCell.customView addSubview:previewImage]; + + selectedCell.moviePlayer.videoGravity = AVLayerVideoGravityResizeAspect; + selectedCell.moviePlayer.view.frame = selectedCell.customView.frame; + selectedCell.moviePlayer.view.center = selectedCell.customView.center; + selectedCell.moviePlayer.view.hidden = YES; + [selectedCell.customView addSubview:selectedCell.moviePlayer.view]; + + // Force the video to stay in fullscreen + NSLayoutConstraint* topConstraint = [NSLayoutConstraint constraintWithItem:selectedCell.moviePlayer.view + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:selectedCell.customView + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:0.0f]; + + NSLayoutConstraint *leadingConstraint = [NSLayoutConstraint constraintWithItem:selectedCell.moviePlayer.view + attribute:NSLayoutAttributeLeading + relatedBy:0 + toItem:selectedCell.customView + attribute:NSLayoutAttributeLeading + multiplier:1.0 + constant:0]; + + NSLayoutConstraint *bottomConstraint = [NSLayoutConstraint constraintWithItem:selectedCell.moviePlayer.view + attribute:NSLayoutAttributeBottom + relatedBy:0 + toItem:selectedCell.customView + attribute:NSLayoutAttributeBottom + multiplier:1 + constant:0]; + + NSLayoutConstraint *trailingConstraint = [NSLayoutConstraint constraintWithItem:selectedCell.moviePlayer.view + attribute:NSLayoutAttributeTrailing + relatedBy:0 + toItem:selectedCell.customView + attribute:NSLayoutAttributeTrailing + multiplier:1.0 + constant:0]; + + selectedCell.moviePlayer.view.translatesAutoresizingMaskIntoConstraints = NO; + + [NSLayoutConstraint activateConstraints:@[topConstraint, leadingConstraint, bottomConstraint, trailingConstraint]]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(moviePlayerPlaybackDidFinishWithErrorNotification:) + name:AVPlayerItemFailedToPlayToEndTimeNotification + object:nil]; + } + } + + if (selectedCell.moviePlayer) + { + if (selectedCell.moviePlayer.player.status == AVPlayerStatusReadyToPlay) + { + // Show or hide the navigation bar + + // The video controls bar display is automatically managed by MPMoviePlayerController. + // We have no control on it and no notifications about its displays changes. + // The following code synchronizes the display of the navigation bar with the + // MPMoviePlayerController controls bar. + + // Check the MPMoviePlayerController controls bar display status by an hacky way + BOOL controlsVisible = NO; + for(id views in [[selectedCell.moviePlayer view] subviews]) + { + for(id subViews in [views subviews]) + { + for (id controlView in [subViews subviews]) + { + if ([controlView isKindOfClass:[UIView class]] && ((UIView*)controlView).tag == 1004) + { + UIView *subView = (UIView*)controlView; + + controlsVisible = (subView.alpha <= 0.0) ? NO : YES; + } + } + } + } + + // Apply the same display to the navigation bar + self.navigationBar.hidden = !controlsVisible; + + navigationBarDisplayHandled = YES; + if (!self.navigationBar.hidden) + { + // Automaticaly hide the nav bar after 5s. This is the same timer value that + // MPMoviePlayerController uses for its controls bar + [navigationBarDisplayTimer invalidate]; + navigationBarDisplayTimer = [NSTimer scheduledTimerWithTimeInterval:5 target:self selector:@selector(hideNavigationBar) userInfo:self repeats:NO]; + } + } + else + { + MXKPieChartView *pieChartView = [[MXKPieChartView alloc] initWithFrame:CGRectMake(0, 0, 40, 40)]; + pieChartView.progress = 0; + pieChartView.progressColor = [UIColor colorWithRed:1 green:1 blue:1 alpha:0.25]; + pieChartView.unprogressColor = [UIColor clearColor]; + pieChartView.autoresizingMask = (UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin); + pieChartView.center = selectedCell.customView.center; + [selectedCell.customView addSubview:pieChartView]; + + // Add download progress observer + NSString *downloadId = attachment.downloadId; + selectedCell.notificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXMediaLoaderStateDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + if ([loader.downloadId isEqualToString:downloadId]) + { + // update progress + switch (loader.state) { + case MXMediaLoaderStateDownloadInProgress: + { + NSNumber* progressNumber = [loader.statisticsDict valueForKey:kMXMediaLoaderProgressValueKey]; + if (progressNumber) + { + pieChartView.progress = progressNumber.floatValue; + } + break; + } + default: + break; + } + } + + }]; + + [self prepareVideoForItem:item success:^{ + + if (selectedCell.notificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:selectedCell.notificationObserver]; + selectedCell.notificationObserver = nil; + } + + if (selectedCell.moviePlayer.view.superview) + { + selectedCell.moviePlayer.view.hidden = NO; + selectedCell.centerIcon.hidden = YES; + selectedCell.moviePlayer.player = [AVPlayer playerWithURL:[NSURL fileURLWithPath:self->videoFile]]; + [selectedCell.moviePlayer.player play]; + + [pieChartView removeFromSuperview]; + + [self hideNavigationBar]; + } + + } failure:^(NSError *error) { + + if (selectedCell.notificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:selectedCell.notificationObserver]; + selectedCell.notificationObserver = nil; + } + + MXLogDebug(@"[MXKAttachmentsVC] video download failed"); + + [pieChartView removeFromSuperview]; + + // Display the navigation bar so that the user can leave this screen + self.navigationBar.hidden = NO; + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }]; + + // Do not animate the navigation bar on video playback preparing + return; + } + } + } + } + + // Animate navigation bar if it is has not been handled + if (!navigationBarDisplayHandled) + { + if (self.navigationBar.hidden) + { + self.navigationBar.hidden = NO; + [navigationBarDisplayTimer invalidate]; + navigationBarDisplayTimer = [NSTimer scheduledTimerWithTimeInterval:3 target:self selector:@selector(hideNavigationBar) userInfo:self repeats:NO]; + } + else + { + [self hideNavigationBar]; + } + } +} + +- (void)collectionView:(UICollectionView *)collectionView didEndDisplayingCell:(UICollectionViewCell *)cell forItemAtIndexPath:(NSIndexPath *)indexPath +{ + // Here the cell is not displayed anymore, but it may be displayed again if the user swipes on it. + if ([cell isKindOfClass:[MXKMediaCollectionViewCell class]]) + { + MXKMediaCollectionViewCell *mediaCollectionViewCell = (MXKMediaCollectionViewCell*)cell; + + // Check whether a video was playing in this cell. + if (mediaCollectionViewCell.moviePlayer) + { + // This cell concerns an attached video. + // We stop the player, and restore the default display based on the video thumbnail + [mediaCollectionViewCell.moviePlayer.player pause]; + mediaCollectionViewCell.moviePlayer.player = nil; + mediaCollectionViewCell.moviePlayer = nil; + + mediaCollectionViewCell.mxkImageView.hidden = NO; + mediaCollectionViewCell.centerIcon.hidden = NO; + mediaCollectionViewCell.customView.hidden = YES; + + // Remove potential media download observer + if (mediaCollectionViewCell.notificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:mediaCollectionViewCell.notificationObserver]; + mediaCollectionViewCell.notificationObserver = nil; + } + } + } +} + +- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset +{ + // Detect horizontal bounce at the beginning of the collection to trigger pagination + if (scrollView == self.attachmentsCollection && !isBackPaginationInProgress && !self.complete && self.delegate) + { + if (scrollView.contentOffset.x < -30) + { + isBackPaginationInProgress = YES; + [self.attachmentsCollection insertItemsAtIndexPaths:@[[NSIndexPath indexPathForItem:0 inSection:0]]]; + } + } +} + +- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView +{ + if (scrollView == self.attachmentsCollection) + { + if (isBackPaginationInProgress) + { + MXKAttachment *attachment = self.attachments.firstObject; + self.complete = ![self.delegate attachmentsViewController:self paginateAttachmentBefore:attachment.eventId]; + } + else + { + [self refreshCurrentVisibleCell]; + } + } +} + +#pragma mark - UICollectionViewDelegateFlowLayout + +- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath +{ + return [[UIScreen mainScreen] bounds].size; +} + +#pragma mark - Movie Player + +- (void)moviePlayerPlaybackDidFinishWithErrorNotification:(NSNotification *)notification +{ + NSDictionary *notificationUserInfo = [notification userInfo]; + + NSError *mediaPlayerError = [notificationUserInfo objectForKey:AVPlayerItemFailedToPlayToEndTimeErrorKey]; + if (mediaPlayerError) + { + MXLogDebug(@"[MXKAttachmentsVC] Playback failed with error description: %@", [mediaPlayerError localizedDescription]); + + // Display the navigation bar so that the user can leave this screen + self.navigationBar.hidden = NO; + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:mediaPlayerError]; + } +} + +#pragma mark - Gesture recognizer + +- (void)onCollectionViewCellTap:(UIGestureRecognizer*)gestureRecognizer +{ + MXKMediaCollectionViewCell *selectedCell; + + UIView *view = gestureRecognizer.view; + if ([view isKindOfClass:[MXKMediaCollectionViewCell class]]) + { + selectedCell = (MXKMediaCollectionViewCell*)view; + } + + // Notify the collection view delegate a cell has been selected. + if (selectedCell && selectedCell.tag < attachments.count) + { + [self collectionView:self.attachmentsCollection didSelectItemAtIndexPath:[NSIndexPath indexPathForItem:(isBackPaginationInProgress ? selectedCell.tag + 1: selectedCell.tag) inSection:0]]; + } +} + +- (void)onCollectionViewCellLongPress:(UIGestureRecognizer*)gestureRecognizer +{ + MXKMediaCollectionViewCell *selectedCell; + + if (gestureRecognizer.state == UIGestureRecognizerStateBegan) + { + UIView *view = gestureRecognizer.view; + if ([view isKindOfClass:[MXKMediaCollectionViewCell class]]) + { + selectedCell = (MXKMediaCollectionViewCell*)view; + } + } + + // Notify the collection view delegate a cell has been selected. + if (selectedCell && selectedCell.tag < attachments.count) + { + MXKAttachment *attachment = attachments[selectedCell.tag]; + + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + __weak __typeof(self) weakSelf = self; + + currentAlert = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + + if ([MXKAppSettings standardAppSettings].messageDetailsAllowSaving) + { + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n save] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + [self startActivityIndicator]; + + [attachment save:^{ + + typeof(self) self = weakSelf; + [self stopActivityIndicator]; + + } failure:^(NSError *error) { + + typeof(self) self = weakSelf; + [self stopActivityIndicator]; + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }]; + + }]]; + } + + if ([MXKAppSettings standardAppSettings].messageDetailsAllowCopyingMedia) + { + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n copyButtonName] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + [self startActivityIndicator]; + + [attachment copy:^{ + + typeof(self) self = weakSelf; + [self stopActivityIndicator]; + + } failure:^(NSError *error) { + + typeof(self) self = weakSelf; + [self stopActivityIndicator]; + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }]; + + }]]; + } + + if ([MXKAppSettings standardAppSettings].messageDetailsAllowSharing) + { + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n share] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXWeakify(self); + + self->currentAlert = nil; + + [self startActivityIndicator]; + + [attachment prepareShare:^(NSURL *fileURL) { + + MXStrongifyAndReturnIfNil(self); + + [self stopActivityIndicator]; + + self->documentInteractionController = [UIDocumentInteractionController interactionControllerWithURL:fileURL]; + [self->documentInteractionController setDelegate:self]; + self->currentSharedAttachment = attachment; + + if (![self->documentInteractionController presentOptionsMenuFromRect:self.view.frame inView:self.view animated:YES]) + { + self->documentInteractionController = nil; + [attachment onShareEnded]; + self->currentSharedAttachment = nil; + } + + } failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + + [self stopActivityIndicator]; + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }]; + + }]]; + } + + if ([MXMediaManager existingDownloaderWithIdentifier:attachment.downloadId]) + { + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancelDownload] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Get again the loader + MXMediaLoader *loader = [MXMediaManager existingDownloaderWithIdentifier:attachment.downloadId]; + if (loader) + { + [loader cancel]; + } + + }]]; + } + + if (currentAlert.actions.count) + { + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleCancel + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + }]]; + + [currentAlert popoverPresentationController].sourceView = _attachmentsCollection; + [currentAlert popoverPresentationController].sourceRect = _attachmentsCollection.bounds; + [self presentViewController:currentAlert animated:YES completion:nil]; + } + else + { + currentAlert = nil; + } + } +} + +#pragma mark - UIDocumentInteractionControllerDelegate + +- (UIViewController *)documentInteractionControllerViewControllerForPreview: (UIDocumentInteractionController *) controller +{ + return self; +} + +// Preview presented/dismissed on document. Use to set up any HI underneath. +- (void)documentInteractionControllerWillBeginPreview:(UIDocumentInteractionController *)controller +{ + documentInteractionController = controller; +} + +- (void)documentInteractionControllerDidEndPreview:(UIDocumentInteractionController *)controller +{ + documentInteractionController = nil; + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } +} + +- (void)documentInteractionControllerDidDismissOptionsMenu:(UIDocumentInteractionController *)controller +{ + documentInteractionController = nil; + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } +} + +- (void)documentInteractionControllerDidDismissOpenInMenu:(UIDocumentInteractionController *)controller +{ + documentInteractionController = nil; + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } +} + +- (UIImageView *)finalImageView +{ + MXKMediaCollectionViewCell *cell = (MXKMediaCollectionViewCell *)[self.attachmentsCollection.visibleCells firstObject]; + return cell.mxkImageView.imageView; +} + +#pragma mark - UIViewControllerTransitioningDelegate + +- (id )animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source +{ + if (self.customAnimationsEnabled) + { + return [[MXKAttachmentAnimator alloc] initWithAnimationType:PhotoBrowserZoomInAnimation sourceViewController:self.sourceViewController]; + } + return nil; +} + +- (id )animationControllerForDismissedController:(UIViewController *)dismissed +{ + [self hideNavigationBar]; + + if (self.customAnimationsEnabled) + { + return [[MXKAttachmentAnimator alloc] initWithAnimationType:PhotoBrowserZoomOutAnimation sourceViewController:self.sourceViewController]; + } + return nil; +} + +- (id)interactionControllerForDismissal:(id)animator +{ + //if there is an interaction, use the custom interaction controller to handle it + if (self.interactionController.interactionInProgress) + { + return self.interactionController; + } + return nil; +} + +#pragma mark - UINavigationControllerDelegate + +- (id )navigationController:(UINavigationController *)navigationController interactionControllerForAnimationController:(id ) animationController { + if (self.customAnimationsEnabled && self.interactionController.interactionInProgress) + { + return self.interactionController; + } + return nil; +} + +- (id )navigationController:(UINavigationController *)navigationController animationControllerForOperation:(UINavigationControllerOperation)operation + fromViewController:(UIViewController *)fromVC + toViewController:(UIViewController *)toVC +{ + + if (self.customAnimationsEnabled) + { + if (operation == UINavigationControllerOperationPush) + { + return [[MXKAttachmentAnimator alloc] initWithAnimationType:PhotoBrowserZoomInAnimation sourceViewController:self.sourceViewController]; + } + if (operation == UINavigationControllerOperationPop) + { + return [[MXKAttachmentAnimator alloc] initWithAnimationType:PhotoBrowserZoomOutAnimation sourceViewController:self.sourceViewController]; + } + return nil; + } + + return nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.xib new file mode 100644 index 000000000..28409d2de --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAttachmentsViewController.xib @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.h new file mode 100644 index 000000000..ffb64afbe --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.h @@ -0,0 +1,311 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKViewController.h" + +#import "MXKAuthInputsView.h" +#import "MXKAuthenticationFallbackWebView.h" + +@class MXKAuthenticationViewController; + +/** + `MXKAuthenticationViewController` delegate. + */ +@protocol MXKAuthenticationViewControllerDelegate + +/** + Tells the delegate the authentication process succeeded to add a new account. + + @param authenticationViewController the `MXKAuthenticationViewController` instance. + @param userId the user id of the new added account. + */ +- (void)authenticationViewController:(MXKAuthenticationViewController *)authenticationViewController didLogWithUserId:(NSString*)userId; + +@end + +/** + This view controller should be used to manage registration or login flows with matrix homeserver. + + Only the flow based on password is presently supported. Other flows should be added later. + + You may add a delegate to be notified when a new account has been added successfully. + */ +@interface MXKAuthenticationViewController : MXKViewController +{ +@protected + + /** + Reference to any opened alert view. + */ + UIAlertController *alert; + + /** + Tell whether the password has been reseted with success. + Used to return on login screen on submit button pressed. + */ + BOOL isPasswordReseted; +} + +@property (weak, nonatomic) IBOutlet UIImageView *welcomeImageView; + +@property (strong, nonatomic) IBOutlet UIScrollView *authenticationScrollView; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *authScrollViewBottomConstraint; + +@property (weak, nonatomic) IBOutlet UIView *contentView; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *contentViewHeightConstraint; + +@property (weak, nonatomic) IBOutlet UILabel *subTitleLabel; + +@property (weak, nonatomic) IBOutlet UIView *authInputsContainerView; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *authInputContainerViewHeightConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *authInputContainerViewMinHeightConstraint; + +@property (weak, nonatomic) IBOutlet UILabel *homeServerLabel; +@property (weak, nonatomic) IBOutlet UITextField *homeServerTextField; +@property (weak, nonatomic) IBOutlet UILabel *homeServerInfoLabel; +@property (weak, nonatomic) IBOutlet UIView *identityServerContainer; +@property (weak, nonatomic) IBOutlet UILabel *identityServerLabel; +@property (weak, nonatomic) IBOutlet UITextField *identityServerTextField; +@property (weak, nonatomic) IBOutlet UILabel *identityServerInfoLabel; + +@property (weak, nonatomic) IBOutlet UIButton *submitButton; +@property (weak, nonatomic) IBOutlet UIButton *authSwitchButton; + +@property (strong, nonatomic) IBOutlet UIActivityIndicatorView *authenticationActivityIndicator; +@property (weak, nonatomic) IBOutlet UIView *authenticationActivityIndicatorContainerView; +@property (weak, nonatomic) IBOutlet UILabel *noFlowLabel; +@property (weak, nonatomic) IBOutlet UIButton *retryButton; + +@property (weak, nonatomic) IBOutlet UIView *authFallbackContentView; +// WKWebView is not available to be created from xib because of NSCoding support below iOS 11. So we're using a container view. +// See this: https://stackoverflow.com/questions/46221577/xcode-9-gm-wkwebview-nscoding-support-was-broken-in-previous-versions +@property (weak, nonatomic) IBOutlet UIView *authFallbackWebViewContainer; +@property (strong, nonatomic) MXKAuthenticationFallbackWebView *authFallbackWebView; +@property (weak, nonatomic) IBOutlet UIButton *cancelAuthFallbackButton; + +/** + The current authentication type (MXKAuthenticationTypeLogin by default). + */ +@property (nonatomic) MXKAuthenticationType authType; + +/** + The view in which authentication inputs are displayed (`MXKAuthInputsView-inherited` instance). + */ +@property (nonatomic) MXKAuthInputsView *authInputsView; + +/** + The default homeserver url (nil by default). + */ +@property (nonatomic) NSString *defaultHomeServerUrl; + +/** + The default identity server url (nil by default). + */ +@property (nonatomic) NSString *defaultIdentityServerUrl; + +/** + Force a registration process based on a predefined set of parameters. + Use this property to pursue a registration from the next_link sent in an email validation email. + */ +@property (nonatomic) NSDictionary* externalRegistrationParameters; + +/** + Use a login process based on the soft logout credentials. + */ +@property (nonatomic) MXCredentials *softLogoutCredentials; + +/** + Enable/disable overall the user interaction option. + It is used during authentication process to prevent multiple requests. + */ +@property(nonatomic,getter=isUserInteractionEnabled) BOOL userInteractionEnabled; + +/** + The device name used to display it in the user's devices list (nil by default). + If nil, the device display name field is filled with a default string: "Mobile", "Tablet"... + */ +@property (nonatomic) NSString *deviceDisplayName; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +/** + current ongoing MXHTTPOperation. Nil if none. + */ +@property (nonatomic, nullable, readonly) MXHTTPOperation *currentHttpOperation; + +/** + Returns the `UINib` object initialized for a `MXKAuthenticationViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `authenticationViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKAuthenticationViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + + @return An initialized `MXKAuthenticationViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)authenticationViewController; + +/** + Register the MXKAuthInputsView class that will be used to display inputs for an authentication type. + + By default the 'MXKAuthInputsPasswordBasedView' class is registered for 'MXKAuthenticationTypeLogin' authentication. + No class is registered for 'MXKAuthenticationTypeRegister' type. + No class is registered for 'MXKAuthenticationTypeForgotPassword' type. + + @param authInputsViewClass a MXKAuthInputsView-inherited class. + @param authType the concerned authentication type + */ +- (void)registerAuthInputsViewClass:(Class)authInputsViewClass forAuthType:(MXKAuthenticationType)authType; + +/** + Refresh login/register mechanism supported by the server and the application. + */ +- (void)refreshAuthenticationSession; + +/** + Handle supported flows and associated information returned by the homeserver. + */ +- (void)handleAuthenticationSession:(MXAuthenticationSession *)authSession; + +/** + Customize the MXHTTPClientOnUnrecognizedCertificate block that will be used to handle unrecognized certificate observed during authentication challenge from a server. + By default we prompt the user by displaying a fingerprint (SHA256) of the certificate. The user is then able to trust or not the certificate. + + @param onUnrecognizedCertificateBlock the block that will be used to handle unrecognized certificate + */ +- (void)setOnUnrecognizedCertificateBlock:(MXHTTPClientOnUnrecognizedCertificate)onUnrecognizedCertificateBlock; + +/** + Check whether the current username is already in use. + + @param callback A block object called when the operation is completed. + */ +- (void)isUserNameInUse:(void (^)(BOOL isUserNameInUse))callback; + +/** + Make a ping to the registration endpoint to detect a possible registration problem earlier. + + @param callback A block object called when the operation is completed. + It provides a MXError to check to verify if the user can be registered. + */ +- (void)testUserRegistration:(void (^)(MXError *mxError))callback; + +/** + Action registered on the following events: + - 'UIControlEventTouchUpInside' for each UIButton instance. + - 'UIControlEventValueChanged' for each UISwitch instance. + */ +- (IBAction)onButtonPressed:(id)sender; + +/** + Set the homeserver url and force a new authentication session. + The default homeserver url is used when the provided url is nil. + + @param homeServerUrl the homeserver url to use + */ +- (void)setHomeServerTextFieldText:(NSString *)homeServerUrl; + +/** + Set the identity server url. + The default identity server url is used when the provided url is nil. + + @param identityServerUrl the identity server url to use + */ +- (void)setIdentityServerTextFieldText:(NSString *)identityServerUrl; + +/** + Fetch the identity server from the wellknown API of the selected homeserver. + and check if the HS requires an identity server. + */ +- (void)checkIdentityServer; + +/** + Force dismiss keyboard + */ +- (void)dismissKeyboard; + +/** + Cancel the current operation, and return to the initial step + */ +- (void)cancel; + +/** + Handle the error received during an authentication request. + + @param error the received error. + */ +- (void)onFailureDuringAuthRequest:(NSError *)error; + + +/** + Display a kMXErrCodeStringResourceLimitExceeded error received during an authentication + request. + + @param errorDict the error data. + @param onAdminContactTapped a callback indicating if the user wants to contact their admin. + */ +- (void)showResourceLimitExceededError:(NSDictionary *)errorDict onAdminContactTapped:(void (^)(NSURL *adminContact))onAdminContactTapped; + +/** + Handle the successful authentication request. + + @param credentials the user's credentials. + */ +- (void)onSuccessfulLogin:(MXCredentials*)credentials; + +/// Login with custom parameters +/// @param parameters Login parameters +- (void)loginWithParameters:(NSDictionary*)parameters; + +/// Create an account with the given credentials +/// @param credentials Account credentials +- (void)createAccountWithCredentials:(MXCredentials *)credentials; + +#pragma mark - Authentication Fallback + +/** + Display the fallback URL within a webview. + */ +- (void)showAuthenticationFallBackView; + +#pragma mark - Device rehydration + +/** + Call this method at an appropriate time to attempt rehydrating from an existing dehydrated device + @param keyData Secret key data + @param credentials Account credentials + */ + +- (void)attemptDeviceRehydrationWithKeyData:(NSData *)keyData credentials:(MXCredentials *)credentials; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.m new file mode 100644 index 000000000..91a1eda04 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.m @@ -0,0 +1,2150 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAuthenticationViewController.h" + +#import "MXKAuthInputsEmailCodeBasedView.h" +#import "MXKAuthInputsPasswordBasedView.h" + +#import "MXKAccountManager.h" + +#import "NSBundle+MatrixKit.h" + +#import +#import "MXKAppSettings.h" + +#import "MXKSwiftHeader.h" + +@interface MXKAuthenticationViewController () +{ + /** + The matrix REST client used to make matrix API requests. + */ + MXRestClient *mxRestClient; + + /** + Current request in progress. + */ + MXHTTPOperation *mxCurrentOperation; + + /** + The MXKAuthInputsView class or a sub-class used when logging in. + */ + Class loginAuthInputsViewClass; + + /** + The MXKAuthInputsView class or a sub-class used when registering. + */ + Class registerAuthInputsViewClass; + + /** + The MXKAuthInputsView class or a sub-class used to handle forgot password case. + */ + Class forgotPasswordAuthInputsViewClass; + + /** + Customized block used to handle unrecognized certificate (nil by default). + */ + MXHTTPClientOnUnrecognizedCertificate onUnrecognizedCertificateCustomBlock; + + /** + The current authentication fallback URL (if any). + */ + NSString *authenticationFallback; + + /** + The cancel button added in navigation bar when fallback page is opened. + */ + UIBarButtonItem *cancelFallbackBarButton; + + /** + The timer used to postpone the registration when the authentication is pending (for example waiting for email validation) + */ + NSTimer* registrationTimer; + + /** + Identity server discovery. + */ + MXAutoDiscovery *autoDiscovery; + + MXHTTPOperation *checkIdentityServerOperation; +} + +/** + The identity service used to make identity server API requests. + */ +@property (nonatomic) MXIdentityService *identityService; + +@end + +@implementation MXKAuthenticationViewController + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKAuthenticationViewController class]) + bundle:[NSBundle bundleForClass:[MXKAuthenticationViewController class]]]; +} + ++ (instancetype)authenticationViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKAuthenticationViewController class]) + bundle:[NSBundle bundleForClass:[MXKAuthenticationViewController class]]]; +} + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + // Set initial auth type + _authType = MXKAuthenticationTypeLogin; + + _deviceDisplayName = nil; + + // Initialize authInputs view classes + loginAuthInputsViewClass = MXKAuthInputsPasswordBasedView.class; + registerAuthInputsViewClass = nil; // No registration flow is supported yet + forgotPasswordAuthInputsViewClass = nil; +} + +#pragma mark - + +- (void)viewDidLoad +{ + [super viewDidLoad]; + // Do any additional setup after loading the view, typically from a nib. + + // Check whether the view controller has been pushed via storyboard + if (!_authenticationScrollView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + self.authFallbackWebView = [[MXKAuthenticationFallbackWebView alloc] initWithFrame:self.authFallbackWebViewContainer.bounds]; + [self.authFallbackWebViewContainer addSubview:self.authFallbackWebView]; + [self.authFallbackWebView.leadingAnchor constraintEqualToAnchor:self.authFallbackWebViewContainer.leadingAnchor constant:0].active = YES; + [self.authFallbackWebView.trailingAnchor constraintEqualToAnchor:self.authFallbackWebViewContainer.trailingAnchor constant:0].active = YES; + [self.authFallbackWebView.topAnchor constraintEqualToAnchor:self.authFallbackWebViewContainer.topAnchor constant:0].active = YES; + [self.authFallbackWebView.bottomAnchor constraintEqualToAnchor:self.authFallbackWebViewContainer.bottomAnchor constant:0].active = YES; + + // Load welcome image from MatrixKit asset bundle + self.welcomeImageView.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"logoHighRes"]; + + _authenticationScrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth; + + _subTitleLabel.numberOfLines = 0; + + _submitButton.enabled = NO; + _authSwitchButton.enabled = YES; + + _homeServerTextField.text = _defaultHomeServerUrl; + _identityServerTextField.text = _defaultIdentityServerUrl; + + // Hide the identity server by default + [self setIdentityServerHidden:YES]; + + // Create here REST client (if homeserver is defined) + [self updateRESTClient]; + + // Localize labels + _homeServerLabel.text = [MatrixKitL10n loginHomeServerTitle]; + _homeServerTextField.placeholder = [MatrixKitL10n loginServerUrlPlaceholder]; + _homeServerInfoLabel.text = [MatrixKitL10n loginHomeServerInfo]; + _identityServerLabel.text = [MatrixKitL10n loginIdentityServerTitle]; + _identityServerTextField.placeholder = [MatrixKitL10n loginServerUrlPlaceholder]; + _identityServerInfoLabel.text = [MatrixKitL10n loginIdentityServerInfo]; + [_cancelAuthFallbackButton setTitle:[MatrixKitL10n cancel] forState:UIControlStateNormal]; + [_cancelAuthFallbackButton setTitle:[MatrixKitL10n cancel] forState:UIControlStateHighlighted]; +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; + // Dispose of any resources that can be recreated. +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onTextFieldChange:) name:UITextFieldTextDidChangeNotification object:nil]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + [self dismissKeyboard]; + + // close any opened alert + if (alert) + { + [alert dismissViewControllerAnimated:NO completion:nil]; + alert = nil; + } + [[NSNotificationCenter defaultCenter] removeObserver:self name:AFNetworkingReachabilityDidChangeNotification object:nil]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:UITextFieldTextDidChangeNotification object:nil]; +} + +#pragma mark - Override MXKViewController + +- (void)onKeyboardShowAnimationComplete +{ + // Report the keyboard view in order to track keyboard frame changes + // TODO define inputAccessoryView for each text input + // and report the inputAccessoryView.superview of the firstResponder in self.keyboardView. +} + +- (void)setKeyboardHeight:(CGFloat)keyboardHeight +{ + // Deduce the bottom inset for the scroll view (Don't forget the potential tabBar) + CGFloat scrollViewInsetBottom = keyboardHeight - self.bottomLayoutGuide.length; + // Check whether the keyboard is over the tabBar + if (scrollViewInsetBottom < 0) + { + scrollViewInsetBottom = 0; + } + + UIEdgeInsets insets = self.authenticationScrollView.contentInset; + insets.bottom = scrollViewInsetBottom; + self.authenticationScrollView.contentInset = insets; +} + +- (void)destroy +{ + self.authInputsView = nil; + + if (registrationTimer) + { + [registrationTimer invalidate]; + registrationTimer = nil; + } + + if (mxCurrentOperation) + { + [mxCurrentOperation cancel]; + mxCurrentOperation = nil; + } + + [self cancelIdentityServerCheck]; + + [mxRestClient close]; + mxRestClient = nil; + + authenticationFallback = nil; + cancelFallbackBarButton = nil; + + [super destroy]; +} + +#pragma mark - Class methods + +- (void)registerAuthInputsViewClass:(Class)authInputsViewClass forAuthType:(MXKAuthenticationType)authType +{ + // Sanity check: accept only MXKAuthInputsView classes or sub-classes + NSParameterAssert([authInputsViewClass isSubclassOfClass:MXKAuthInputsView.class]); + + if (authType == MXKAuthenticationTypeLogin) + { + loginAuthInputsViewClass = authInputsViewClass; + } + else if (authType == MXKAuthenticationTypeRegister) + { + registerAuthInputsViewClass = authInputsViewClass; + } + else if (authType == MXKAuthenticationTypeForgotPassword) + { + forgotPasswordAuthInputsViewClass = authInputsViewClass; + } +} + +- (void)setAuthType:(MXKAuthenticationType)authType +{ + if (_authType != authType) + { + _authType = authType; + + // Cancel external registration parameters if any + _externalRegistrationParameters = nil; + + // Remove the current inputs view + self.authInputsView = nil; + + isPasswordReseted = NO; + + [self.authInputsContainerView bringSubviewToFront: _authenticationActivityIndicator]; + [_authenticationActivityIndicator startAnimating]; + } + + // Restore user interaction + self.userInteractionEnabled = YES; + + if (authType == MXKAuthenticationTypeLogin) + { + _subTitleLabel.hidden = YES; + [_submitButton setTitle:[MatrixKitL10n login] forState:UIControlStateNormal]; + [_submitButton setTitle:[MatrixKitL10n login] forState:UIControlStateHighlighted]; + [_authSwitchButton setTitle:[MatrixKitL10n createAccount] forState:UIControlStateNormal]; + [_authSwitchButton setTitle:[MatrixKitL10n createAccount] forState:UIControlStateHighlighted]; + + // Update supported authentication flow and associated information (defined in authentication session) + [self refreshAuthenticationSession]; + } + else if (authType == MXKAuthenticationTypeRegister) + { + _subTitleLabel.hidden = NO; + _subTitleLabel.text = [MatrixKitL10n loginCreateAccount]; + [_submitButton setTitle:[MatrixKitL10n signUp] forState:UIControlStateNormal]; + [_submitButton setTitle:[MatrixKitL10n signUp] forState:UIControlStateHighlighted]; + [_authSwitchButton setTitle:[MatrixKitL10n back] forState:UIControlStateNormal]; + [_authSwitchButton setTitle:[MatrixKitL10n back] forState:UIControlStateHighlighted]; + + // Update supported authentication flow and associated information (defined in authentication session) + [self refreshAuthenticationSession]; + } + else if (authType == MXKAuthenticationTypeForgotPassword) + { + _subTitleLabel.hidden = YES; + + if (isPasswordReseted) + { + [_submitButton setTitle:[MatrixKitL10n back] forState:UIControlStateNormal]; + [_submitButton setTitle:[MatrixKitL10n back] forState:UIControlStateHighlighted]; + } + else + { + [_submitButton setTitle:[MatrixKitL10n submit] forState:UIControlStateNormal]; + [_submitButton setTitle:[MatrixKitL10n submit] forState:UIControlStateHighlighted]; + + [self refreshForgotPasswordSession]; + } + + [_authSwitchButton setTitle:[MatrixKitL10n back] forState:UIControlStateNormal]; + [_authSwitchButton setTitle:[MatrixKitL10n back] forState:UIControlStateHighlighted]; + } + + [self checkIdentityServer]; +} + +- (void)setAuthInputsView:(MXKAuthInputsView *)authInputsView +{ + // Here a new view will be loaded, hide first subviews which depend on auth flow + _submitButton.hidden = YES; + _noFlowLabel.hidden = YES; + _retryButton.hidden = YES; + + if (_authInputsView) + { + [_authInputsView removeObserver:self forKeyPath:@"viewHeightConstraint.constant"]; + + [NSLayoutConstraint deactivateConstraints:_authInputsView.constraints]; + [_authInputsView removeFromSuperview]; + _authInputsView.delegate = nil; + [_authInputsView destroy]; + _authInputsView = nil; + } + + _authInputsView = authInputsView; + + CGFloat previousInputsContainerViewHeight = _authInputContainerViewHeightConstraint.constant; + + if (_authInputsView) + { + _authInputsView.translatesAutoresizingMaskIntoConstraints = NO; + [_authInputsContainerView addSubview:_authInputsView]; + + _authInputsView.delegate = self; + + _submitButton.hidden = NO; + _authInputsView.hidden = NO; + + _authInputContainerViewHeightConstraint.constant = _authInputsView.viewHeightConstraint.constant; + + NSLayoutConstraint* topConstraint = [NSLayoutConstraint constraintWithItem:_authInputsContainerView + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:_authInputsView + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:0.0f]; + + + NSLayoutConstraint* leadingConstraint = [NSLayoutConstraint constraintWithItem:_authInputsContainerView + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:_authInputsView + attribute:NSLayoutAttributeLeading + multiplier:1.0f + constant:0.0f]; + + NSLayoutConstraint* trailingConstraint = [NSLayoutConstraint constraintWithItem:_authInputsContainerView + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:_authInputsView + attribute:NSLayoutAttributeTrailing + multiplier:1.0f + constant:0.0f]; + + + [NSLayoutConstraint activateConstraints:@[topConstraint, leadingConstraint, trailingConstraint]]; + + [_authInputsView addObserver:self forKeyPath:@"viewHeightConstraint.constant" options:0 context:nil]; + } + else + { + // No input fields are displayed + _authInputContainerViewHeightConstraint.constant = _authInputContainerViewMinHeightConstraint.constant; + } + + [self.view layoutIfNeeded]; + + // Refresh content view height by considering the updated height of inputs container + _contentViewHeightConstraint.constant += (_authInputContainerViewHeightConstraint.constant - previousInputsContainerViewHeight); +} + +- (void)setDefaultHomeServerUrl:(NSString *)defaultHomeServerUrl +{ + _defaultHomeServerUrl = defaultHomeServerUrl; + + if (!_homeServerTextField.text.length) + { + [self setHomeServerTextFieldText:defaultHomeServerUrl]; + } +} + +- (void)setDefaultIdentityServerUrl:(NSString *)defaultIdentityServerUrl +{ + _defaultIdentityServerUrl = defaultIdentityServerUrl; + + if (!_identityServerTextField.text.length) + { + [self setIdentityServerTextFieldText:defaultIdentityServerUrl]; + } +} + +- (void)setHomeServerTextFieldText:(NSString *)homeServerUrl +{ + if (!homeServerUrl.length) + { + // Force refresh with default value + homeServerUrl = _defaultHomeServerUrl; + } + + _homeServerTextField.text = homeServerUrl; + + if (!mxRestClient || ![mxRestClient.homeserver isEqualToString:homeServerUrl]) + { + [self updateRESTClient]; + + if (_authType == MXKAuthenticationTypeLogin || _authType == MXKAuthenticationTypeRegister) + { + // Restore default UI + self.authType = _authType; + } + else + { + // Refresh the IS anyway + [self checkIdentityServer]; + } + } +} + +- (void)setIdentityServerTextFieldText:(NSString *)identityServerUrl +{ + _identityServerTextField.text = identityServerUrl; + + [self updateIdentityServerURL:identityServerUrl]; +} + +- (void)updateIdentityServerURL:(NSString*)url +{ + if (![self.identityService.identityServer isEqualToString:url]) + { + if (url.length) + { + self.identityService = [[MXIdentityService alloc] initWithIdentityServer:url accessToken:nil andHomeserverRestClient:mxRestClient]; + } + else + { + self.identityService = nil; + } + } + + [mxRestClient setIdentityServer:url.length ? url : nil]; +} + +- (void)setIdentityServerHidden:(BOOL)hidden +{ + _identityServerContainer.hidden = hidden; +} + +- (void)checkIdentityServer +{ + [self cancelIdentityServerCheck]; + + // Hide the field while checking data + [self setIdentityServerHidden:YES]; + + NSString *homeserver = mxRestClient.homeserver; + + // First, fetch the IS advertised by the HS + if (homeserver) + { + MXLogDebug(@"[MXKAuthenticationVC] checkIdentityServer for homeserver %@", homeserver); + + autoDiscovery = [[MXAutoDiscovery alloc] initWithUrl:homeserver]; + + MXWeakify(self); + checkIdentityServerOperation = [autoDiscovery findClientConfig:^(MXDiscoveredClientConfig * _Nonnull discoveredClientConfig) { + MXStrongifyAndReturnIfNil(self); + + NSString *identityServer = discoveredClientConfig.wellKnown.identityServer.baseUrl; + MXLogDebug(@"[MXKAuthenticationVC] checkIdentityServer: Identity server: %@", identityServer); + + if (identityServer) + { + // Apply the provided IS + [self setIdentityServerTextFieldText:identityServer]; + } + + // Then, check if the HS needs an IS for running + MXWeakify(self); + MXHTTPOperation *operation = [self checkIdentityServerRequirementWithCompletion:^(BOOL identityServerRequired) { + + MXStrongifyAndReturnIfNil(self); + + self->checkIdentityServerOperation = nil; + + // Show the field only if an IS is required so that the user can customise it + [self setIdentityServerHidden:!identityServerRequired]; + }]; + + if (operation) + { + [self->checkIdentityServerOperation mutateTo:operation]; + } + else + { + self->checkIdentityServerOperation = nil; + } + + self->autoDiscovery = nil; + + } failure:^(NSError *error) { + MXStrongifyAndReturnIfNil(self); + + // No need to report this error to the end user + // There will be already an error about failing to get the auth flow from the HS + MXLogDebug(@"[MXKAuthenticationVC] checkIdentityServer. Error: %@", error); + + self->autoDiscovery = nil; + }]; + } +} + +- (void)cancelIdentityServerCheck +{ + if (checkIdentityServerOperation) + { + [checkIdentityServerOperation cancel]; + checkIdentityServerOperation = nil; + } +} + +- (MXHTTPOperation*)checkIdentityServerRequirementWithCompletion:(void (^)(BOOL identityServerRequired))completion +{ + MXHTTPOperation *operation; + + if (_authType == MXKAuthenticationTypeLogin) + { + // The identity server is only required for registration and password reset + // It is then stored in the user account data + completion(NO); + } + else + { + operation = [mxRestClient supportedMatrixVersions:^(MXMatrixVersions *matrixVersions) { + + MXLogDebug(@"[MXKAuthenticationVC] checkIdentityServerRequirement: %@", matrixVersions.doesServerRequireIdentityServerParam ? @"YES": @"NO"); + completion(matrixVersions.doesServerRequireIdentityServerParam); + + } failure:^(NSError *error) { + // No need to report this error to the end user + // There will be already an error about failing to get the auth flow from the HS + MXLogDebug(@"[MXKAuthenticationVC] checkIdentityServerRequirement. Error: %@", error); + }]; + } + + return operation; +} + +- (void)setUserInteractionEnabled:(BOOL)userInteractionEnabled +{ + _submitButton.enabled = (userInteractionEnabled && _authInputsView.areAllRequiredFieldsSet); + _authSwitchButton.enabled = userInteractionEnabled; + + _homeServerTextField.enabled = userInteractionEnabled; + _identityServerTextField.enabled = userInteractionEnabled; + + _userInteractionEnabled = userInteractionEnabled; +} + +- (void)refreshAuthenticationSession +{ + // Remove reachability observer + [[NSNotificationCenter defaultCenter] removeObserver:self name:AFNetworkingReachabilityDidChangeNotification object:nil]; + + // Cancel potential request in progress + [mxCurrentOperation cancel]; + mxCurrentOperation = nil; + + // Reset potential authentication fallback url + authenticationFallback = nil; + + if (mxRestClient) + { + if (_authType == MXKAuthenticationTypeLogin) + { + mxCurrentOperation = [mxRestClient getLoginSession:^(MXAuthenticationSession* authSession) { + + [self handleAuthenticationSession:authSession]; + + } failure:^(NSError *error) { + + [self onFailureDuringMXOperation:error]; + + }]; + } + else if (_authType == MXKAuthenticationTypeRegister) + { + mxCurrentOperation = [mxRestClient getRegisterSession:^(MXAuthenticationSession* authSession){ + + [self handleAuthenticationSession:authSession]; + + } failure:^(NSError *error){ + + [self onFailureDuringMXOperation:error]; + + }]; + } + else + { + // Not supported for other types + MXLogDebug(@"[MXKAuthenticationVC] refreshAuthenticationSession is ignored"); + } + } +} + +- (void)handleAuthenticationSession:(MXAuthenticationSession *)authSession +{ + mxCurrentOperation = nil; + + [_authenticationActivityIndicator stopAnimating]; + + // Check whether fallback is defined, and retrieve the right input view class. + Class authInputsViewClass; + if (_authType == MXKAuthenticationTypeLogin) + { + authenticationFallback = [mxRestClient loginFallback]; + authInputsViewClass = loginAuthInputsViewClass; + + } + else if (_authType == MXKAuthenticationTypeRegister) + { + authenticationFallback = [mxRestClient registerFallback]; + authInputsViewClass = registerAuthInputsViewClass; + } + else + { + // Not supported for other types + MXLogDebug(@"[MXKAuthenticationVC] handleAuthenticationSession is ignored"); + return; + } + + MXKAuthInputsView *authInputsView = nil; + if (authInputsViewClass) + { + // Instantiate a new auth inputs view, except if the current one is already an instance of this class. + if (self.authInputsView && self.authInputsView.class == authInputsViewClass) + { + // Use the current view + authInputsView = self.authInputsView; + } + else + { + authInputsView = [authInputsViewClass authInputsView]; + } + } + + if (authInputsView) + { + // Apply authentication session on inputs view + if ([authInputsView setAuthSession:authSession withAuthType:_authType] == NO) + { + MXLogDebug(@"[MXKAuthenticationVC] Received authentication settings are not supported"); + authInputsView = nil; + } + else if (!_softLogoutCredentials) + { + // If all listed flows in this authentication session are not supported we suggest using the fallback page. + if (authenticationFallback.length && authInputsView.authSession.flows.count == 0) + { + MXLogDebug(@"[MXKAuthenticationVC] No supported flow, suggest using fallback page"); + authInputsView = nil; + } + else if (authInputsView.authSession.flows.count != authSession.flows.count) + { + MXLogDebug(@"[MXKAuthenticationVC] The authentication session contains at least one unsupported flow"); + } + } + } + + if (authInputsView) + { + // Check whether the current view must be replaced + if (self.authInputsView != authInputsView) + { + // Refresh layout + self.authInputsView = authInputsView; + } + + // Refresh user interaction + self.userInteractionEnabled = _userInteractionEnabled; + + // Check whether an external set of parameters have been defined to pursue a registration + if (self.externalRegistrationParameters) + { + if ([authInputsView setExternalRegistrationParameters:self.externalRegistrationParameters]) + { + // Launch authentication now + [self onButtonPressed:_submitButton]; + } + else + { + [self onFailureDuringAuthRequest:[NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n notSupportedYet]}]]; + + _externalRegistrationParameters = nil; + + // Restore login screen on failure + self.authType = MXKAuthenticationTypeLogin; + } + } + + if (_softLogoutCredentials) + { + [authInputsView setSoftLogoutCredentials:_softLogoutCredentials]; + } + } + else + { + // Remove the potential auth inputs view + self.authInputsView = nil; + + // Cancel external registration parameters if any + _externalRegistrationParameters = nil; + + // Notify user that no flow is supported + if (_authType == MXKAuthenticationTypeLogin) + { + _noFlowLabel.text = [MatrixKitL10n loginErrorDoNotSupportLoginFlows]; + } + else + { + _noFlowLabel.text = [MatrixKitL10n loginErrorRegistrationIsNotSupported]; + } + MXLogDebug(@"[MXKAuthenticationVC] Warning: %@", _noFlowLabel.text); + + if (authenticationFallback.length) + { + [_retryButton setTitle:[MatrixKitL10n loginUseFallback] forState:UIControlStateNormal]; + [_retryButton setTitle:[MatrixKitL10n loginUseFallback] forState:UIControlStateNormal]; + } + else + { + [_retryButton setTitle:[MatrixKitL10n retry] forState:UIControlStateNormal]; + [_retryButton setTitle:[MatrixKitL10n retry] forState:UIControlStateNormal]; + } + + _noFlowLabel.hidden = NO; + _retryButton.hidden = NO; + } +} + +- (void)setExternalRegistrationParameters:(NSDictionary*)parameters +{ + if (parameters.count) + { + MXLogDebug(@"[MXKAuthenticationVC] setExternalRegistrationParameters"); + + // Cancel the current operation if any. + [self cancel]; + + // Load the view controller’s view if it has not yet been loaded. + // This is required before updating view's textfields (homeserver url...) + [self loadViewIfNeeded]; + + // Force register mode + self.authType = MXKAuthenticationTypeRegister; + + // Apply provided homeserver if any + id hs_url = parameters[@"hs_url"]; + NSString *homeserverURL = nil; + if (hs_url && [hs_url isKindOfClass:NSString.class]) + { + homeserverURL = hs_url; + } + [self setHomeServerTextFieldText:homeserverURL]; + + // Apply provided identity server if any + id is_url = parameters[@"is_url"]; + NSString *identityURL = nil; + if (is_url && [is_url isKindOfClass:NSString.class]) + { + identityURL = is_url; + } + [self setIdentityServerTextFieldText:identityURL]; + + // Disable user interaction + self.userInteractionEnabled = NO; + + // Cancel potential request in progress + [mxCurrentOperation cancel]; + mxCurrentOperation = nil; + + // Remove the current auth inputs view + self.authInputsView = nil; + + // Set external parameters and trigger a refresh (the parameters will be taken into account during [handleAuthenticationSession:]) + _externalRegistrationParameters = parameters; + [self refreshAuthenticationSession]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] reset externalRegistrationParameters"); + _externalRegistrationParameters = nil; + + // Restore default UI + self.authType = _authType; + } +} + +- (void)setSoftLogoutCredentials:(MXCredentials *)softLogoutCredentials +{ + MXLogDebug(@"[MXKAuthenticationVC] setSoftLogoutCredentials"); + + // Cancel the current operation if any. + [self cancel]; + + // Load the view controller’s view if it has not yet been loaded. + // This is required before updating view's textfields (homeserver url...) + [self loadViewIfNeeded]; + + // Force register mode + self.authType = MXKAuthenticationTypeLogin; + + [self setHomeServerTextFieldText:softLogoutCredentials.homeServer]; + [self setIdentityServerTextFieldText:softLogoutCredentials.identityServer]; + + // Cancel potential request in progress + [mxCurrentOperation cancel]; + mxCurrentOperation = nil; + + // Remove the current auth inputs view + self.authInputsView = nil; + + // Set parameters and trigger a refresh (the parameters will be taken into account during [handleAuthenticationSession:]) + _softLogoutCredentials = softLogoutCredentials; + [self refreshAuthenticationSession]; +} + +- (void)setOnUnrecognizedCertificateBlock:(MXHTTPClientOnUnrecognizedCertificate)onUnrecognizedCertificateBlock +{ + onUnrecognizedCertificateCustomBlock = onUnrecognizedCertificateBlock; +} + +- (void)isUserNameInUse:(void (^)(BOOL isUserNameInUse))callback +{ + mxCurrentOperation = [mxRestClient isUserNameInUse:self.authInputsView.userId callback:^(BOOL isUserNameInUse) { + + self->mxCurrentOperation = nil; + + if (callback) + { + callback (isUserNameInUse); + } + + }]; +} + +- (void)testUserRegistration:(void (^)(MXError *mxError))callback +{ + mxCurrentOperation = [mxRestClient testUserRegistration:self.authInputsView.userId callback:callback]; +} + +- (IBAction)onButtonPressed:(id)sender +{ + [self dismissKeyboard]; + + if (sender == _submitButton) + { + // Disable user interaction to prevent multiple requests + self.userInteractionEnabled = NO; + + // Check parameters validity + NSString *errorMsg = [self.authInputsView validateParameters]; + if (errorMsg) + { + [self onFailureDuringAuthRequest:[NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:errorMsg}]]; + } + else + { + [self.authInputsContainerView bringSubviewToFront: _authenticationActivityIndicator]; + + // Launch the authentication according to its type + if (_authType == MXKAuthenticationTypeLogin) + { + // Prepare the parameters dict + [self.authInputsView prepareParameters:^(NSDictionary *parameters, NSError *error) { + + if (parameters && self->mxRestClient) + { + [self->_authenticationActivityIndicator startAnimating]; + [self loginWithParameters:parameters]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Failed to prepare parameters"); + [self onFailureDuringAuthRequest:error]; + } + + }]; + } + else if (_authType == MXKAuthenticationTypeRegister) + { + // Check here the availability of the userId + if (self.authInputsView.userId.length) + { + [_authenticationActivityIndicator startAnimating]; + + if (self.authInputsView.password.length) + { + // Trigger here a register request in order to associate the filled userId and password to the current session id + // This will check the availability of the userId at the same time + NSDictionary *parameters = @{@"auth": @{}, + @"username": self.authInputsView.userId, + @"password": self.authInputsView.password, + @"bind_email": @(NO), + @"initial_device_display_name":self.deviceDisplayName + }; + + mxCurrentOperation = [mxRestClient registerWithParameters:parameters success:^(NSDictionary *JSONResponse) { + + // Unexpected case where the registration succeeds without any other stages + MXLoginResponse *loginResponse; + MXJSONModelSetMXJSONModel(loginResponse, MXLoginResponse, JSONResponse); + + MXCredentials *credentials = [[MXCredentials alloc] initWithLoginResponse:loginResponse + andDefaultCredentials:self->mxRestClient.credentials]; + + // Sanity check + if (!credentials.userId || !credentials.accessToken) + { + [self onFailureDuringAuthRequest:[NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n notSupportedYet]}]]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Registration succeeded"); + + // Report the certificate trusted by user (if any) + credentials.allowedCertificate = self->mxRestClient.allowedCertificate; + + [self onSuccessfulLogin:credentials]; + } + + } failure:^(NSError *error) { + + self->mxCurrentOperation = nil; + + // An updated authentication session should be available in response data in case of unauthorized request. + NSDictionary *JSONResponse = nil; + if (error.userInfo[MXHTTPClientErrorResponseDataKey]) + { + JSONResponse = error.userInfo[MXHTTPClientErrorResponseDataKey]; + } + + if (JSONResponse) + { + MXAuthenticationSession *authSession = [MXAuthenticationSession modelFromJSON:JSONResponse]; + + [self->_authenticationActivityIndicator stopAnimating]; + + // Update session identifier + self.authInputsView.authSession.session = authSession.session; + + // Launch registration by preparing parameters dict + [self.authInputsView prepareParameters:^(NSDictionary *parameters, NSError *error) { + + if (parameters && self->mxRestClient) + { + [self->_authenticationActivityIndicator startAnimating]; + [self registerWithParameters:parameters]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Failed to prepare parameters"); + [self onFailureDuringAuthRequest:error]; + } + + }]; + } + else + { + [self onFailureDuringAuthRequest:error]; + } + }]; + } + else + { + [self isUserNameInUse:^(BOOL isUserNameInUse) { + + if (isUserNameInUse) + { + MXLogDebug(@"[MXKAuthenticationVC] User name is already use"); + [self onFailureDuringAuthRequest:[NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n authUsernameInUse]}]]; + } + else + { + [self->_authenticationActivityIndicator stopAnimating]; + + // Launch registration by preparing parameters dict + [self.authInputsView prepareParameters:^(NSDictionary *parameters, NSError *error) { + + if (parameters && self->mxRestClient) + { + [self->_authenticationActivityIndicator startAnimating]; + [self registerWithParameters:parameters]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Failed to prepare parameters"); + [self onFailureDuringAuthRequest:error]; + } + + }]; + } + + }]; + } + } + else if (self.externalRegistrationParameters) + { + // Launch registration by preparing parameters dict + [self.authInputsView prepareParameters:^(NSDictionary *parameters, NSError *error) { + + if (parameters && self->mxRestClient) + { + [self->_authenticationActivityIndicator startAnimating]; + [self registerWithParameters:parameters]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Failed to prepare parameters"); + [self onFailureDuringAuthRequest:error]; + } + + }]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] User name is missing"); + [self onFailureDuringAuthRequest:[NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n authInvalidUserName]}]]; + } + } + else if (_authType == MXKAuthenticationTypeForgotPassword) + { + // Check whether the password has been reseted + if (isPasswordReseted) + { + // Return to login screen + self.authType = MXKAuthenticationTypeLogin; + } + else + { + // Prepare the parameters dict + [self.authInputsView prepareParameters:^(NSDictionary *parameters, NSError *error) { + + if (parameters && self->mxRestClient) + { + [self->_authenticationActivityIndicator startAnimating]; + [self resetPasswordWithParameters:parameters]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Failed to prepare parameters"); + [self onFailureDuringAuthRequest:error]; + } + + }]; + } + } + } + } + else if (sender == _authSwitchButton) + { + if (_authType == MXKAuthenticationTypeLogin) + { + self.authType = MXKAuthenticationTypeRegister; + } + else + { + self.authType = MXKAuthenticationTypeLogin; + } + } + else if (sender == _retryButton) + { + if (authenticationFallback) + { + [self showAuthenticationFallBackView:authenticationFallback]; + } + else + { + [self refreshAuthenticationSession]; + } + } + else if (sender == _cancelAuthFallbackButton) + { + // Hide fallback webview + [self hideRegistrationFallbackView]; + } +} + +- (void)cancel +{ + MXLogDebug(@"[MXKAuthenticationVC] cancel"); + + // Cancel external registration parameters if any + _externalRegistrationParameters = nil; + + if (registrationTimer) + { + [registrationTimer invalidate]; + registrationTimer = nil; + } + + // Cancel request in progress + if (mxCurrentOperation) + { + [mxCurrentOperation cancel]; + mxCurrentOperation = nil; + } + + [_authenticationActivityIndicator stopAnimating]; + self.userInteractionEnabled = YES; + + // Reset potential completed stages + self.authInputsView.authSession.completed = nil; + + // Update authentication inputs view to return in initial step + [self.authInputsView setAuthSession:self.authInputsView.authSession withAuthType:_authType]; +} + +- (void)onFailureDuringAuthRequest:(NSError *)error +{ + mxCurrentOperation = nil; + [_authenticationActivityIndicator stopAnimating]; + self.userInteractionEnabled = YES; + + // Ignore connection cancellation error + if (([error.domain isEqualToString:NSURLErrorDomain] && error.code == NSURLErrorCancelled)) + { + MXLogDebug(@"[MXKAuthenticationVC] Auth request cancelled"); + return; + } + + MXLogDebug(@"[MXKAuthenticationVC] Auth request failed: %@", error); + + // Cancel external registration parameters if any + _externalRegistrationParameters = nil; + + // Translate the error code to a human message + NSString *title = error.localizedFailureReason; + if (!title) + { + if (self.authType == MXKAuthenticationTypeLogin) + { + title = [MatrixKitL10n loginErrorTitle]; + } + else if (self.authType == MXKAuthenticationTypeRegister) + { + title = [MatrixKitL10n registerErrorTitle]; + } + else + { + title = [MatrixKitL10n error]; + } + } + NSString* message = error.localizedDescription; + NSDictionary* dict = error.userInfo; + + // detect if it is a Matrix SDK issue + if (dict) + { + NSString* localizedError = [dict valueForKey:@"error"]; + NSString* errCode = [dict valueForKey:@"errcode"]; + + if (localizedError.length > 0) + { + message = localizedError; + } + + if (errCode) + { + if ([errCode isEqualToString:kMXErrCodeStringForbidden]) + { + message = [MatrixKitL10n loginErrorForbidden]; + } + else if ([errCode isEqualToString:kMXErrCodeStringUnknownToken]) + { + message = [MatrixKitL10n loginErrorUnknownToken]; + } + else if ([errCode isEqualToString:kMXErrCodeStringBadJSON]) + { + message = [MatrixKitL10n loginErrorBadJson]; + } + else if ([errCode isEqualToString:kMXErrCodeStringNotJSON]) + { + message = [MatrixKitL10n loginErrorNotJson]; + } + else if ([errCode isEqualToString:kMXErrCodeStringLimitExceeded]) + { + message = [MatrixKitL10n loginErrorLimitExceeded]; + } + else if ([errCode isEqualToString:kMXErrCodeStringUserInUse]) + { + message = [MatrixKitL10n loginErrorUserInUse]; + } + else if ([errCode isEqualToString:kMXErrCodeStringLoginEmailURLNotYet]) + { + message = [MatrixKitL10n loginErrorLoginEmailNotYet]; + } + else if ([errCode isEqualToString:kMXErrCodeStringResourceLimitExceeded]) + { + [self showResourceLimitExceededError:dict onAdminContactTapped:nil]; + return; + } + else if (!message.length) + { + message = errCode; + } + } + } + + // Alert user + if (alert) + { + [alert dismissViewControllerAnimated:NO completion:nil]; + } + + alert = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + self->alert = nil; + + }]]; + + + [self presentViewController:alert animated:YES completion:nil]; + + // Update authentication inputs view to return in initial step + [self.authInputsView setAuthSession:self.authInputsView.authSession withAuthType:_authType]; + if (self.softLogoutCredentials) + { + self.authInputsView.softLogoutCredentials = self.softLogoutCredentials; + } +} + +- (void)showResourceLimitExceededError:(NSDictionary *)errorDict onAdminContactTapped:(void (^)(NSURL *adminContact))onAdminContactTapped +{ + mxCurrentOperation = nil; + [_authenticationActivityIndicator stopAnimating]; + self.userInteractionEnabled = YES; + + // Alert user + if (alert) + { + [alert dismissViewControllerAnimated:NO completion:nil]; + } + + // Parse error data + NSString *limitType, *adminContactString; + NSURL *adminContact; + + MXJSONModelSetString(limitType, errorDict[kMXErrorResourceLimitExceededLimitTypeKey]); + MXJSONModelSetString(adminContactString, errorDict[kMXErrorResourceLimitExceededAdminContactKey]); + + if (adminContactString) + { + adminContact = [NSURL URLWithString:adminContactString]; + } + + NSString *title = [MatrixKitL10n loginErrorResourceLimitExceededTitle]; + + // Build the message content + NSMutableString *message = [NSMutableString new]; + if ([limitType isEqualToString:kMXErrorResourceLimitExceededLimitTypeMonthlyActiveUserValue]) + { + [message appendString:[MatrixKitL10n loginErrorResourceLimitExceededMessageMonthlyActiveUser]]; + } + else + { + [message appendString:[MatrixKitL10n loginErrorResourceLimitExceededMessageDefault]]; + } + + [message appendString:[MatrixKitL10n loginErrorResourceLimitExceededMessageContact]]; + + // Build the alert + alert = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert]; + + MXWeakify(self); + if (adminContact && onAdminContactTapped) + { + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n loginErrorResourceLimitExceededContactButton] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) + { + MXStrongifyAndReturnIfNil(self); + self->alert = nil; + + // Let the system handle the URI + // It could be something like "mailto: server.admin@example.com" + onAdminContactTapped(adminContact); + }]]; + } + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) + { + MXStrongifyAndReturnIfNil(self); + self->alert = nil; + }]]; + + [self presentViewController:alert animated:YES completion:nil]; + + // Update authentication inputs view to return in initial step + [self.authInputsView setAuthSession:self.authInputsView.authSession withAuthType:_authType]; +} + +- (void)onSuccessfulLogin:(MXCredentials*)credentials +{ + mxCurrentOperation = nil; + [_authenticationActivityIndicator stopAnimating]; + self.userInteractionEnabled = YES; + + if (self.softLogoutCredentials) + { + // Hydrate the account with the new access token + MXKAccount *account = [[MXKAccountManager sharedManager] accountForUserId:self.softLogoutCredentials.userId]; + [[MXKAccountManager sharedManager] hydrateAccount:account withCredentials:credentials]; + + if (_delegate) + { + [_delegate authenticationViewController:self didLogWithUserId:credentials.userId]; + } + } + // Sanity check: check whether the user is not already logged in with this id + else if ([[MXKAccountManager sharedManager] accountForUserId:credentials.userId]) + { + //Alert user + __weak typeof(self) weakSelf = self; + + if (alert) + { + [alert dismissViewControllerAnimated:NO completion:nil]; + } + + alert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n loginErrorAlreadyLoggedIn] message:nil preferredStyle:UIAlertControllerStyleAlert]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + // We remove the authentication view controller. + typeof(self) self = weakSelf; + self->alert = nil; + [self withdrawViewControllerAnimated:YES completion:nil]; + + }]]; + + + [self presentViewController:alert animated:YES completion:nil]; + } + else + { + // Report the new account in account manager + if (!credentials.identityServer) + { + credentials.identityServer = _identityServerTextField.text; + } + + [self createAccountWithCredentials:credentials]; + } +} + +- (MXHTTPOperation *)currentHttpOperation +{ + return mxCurrentOperation; +} + +#pragma mark - Privates + +// Hook point for triggering device rehydration in subclasses +// Avoid cycles by using a separate private method do to the actual work +- (void)createAccountWithCredentials:(MXCredentials *)credentials +{ + [self _createAccountWithCredentials:credentials]; +} + +- (void)attemptDeviceRehydrationWithKeyData:(NSData *)keyData + credentials:(MXCredentials *)credentials +{ + [self attemptDeviceRehydrationWithKeyData:keyData + credentials:credentials + retry:YES]; +} + +- (void)attemptDeviceRehydrationWithKeyData:(NSData *)keyData + credentials:(MXCredentials *)credentials + retry:(BOOL)retry +{ + MXLogDebug(@"[MXKAuthenticationViewController] attemptDeviceRehydration: starting device rehydration"); + + if (keyData == nil) + { + MXLogError(@"[MXKAuthenticationViewController] attemptDeviceRehydration: no key provided for device rehydration"); + [self _createAccountWithCredentials:credentials]; + return; + } + + MXRestClient *mxRestClient = [[MXRestClient alloc] initWithCredentials:credentials andOnUnrecognizedCertificateBlock:^BOOL(NSData *certificate) { + return NO; + }]; + + MXWeakify(self); + [[MXKAccountManager sharedManager].dehydrationService rehydrateDeviceWithMatrixRestClient:mxRestClient dehydrationKey:keyData success:^(NSString * deviceId) { + MXStrongifyAndReturnIfNil(self); + + if (deviceId) + { + MXLogDebug(@"[MXKAuthenticationViewController] attemptDeviceRehydration: device %@ rehydrated successfully.", deviceId); + credentials.deviceId = deviceId; + } + else + { + MXLogDebug(@"[MXKAuthenticationViewController] attemptDeviceRehydration: device rehydration has been canceled."); + } + + [self _createAccountWithCredentials:credentials]; + } failure:^(NSError *error) { + MXStrongifyAndReturnIfNil(self); + + if (retry) + { + MXLogError(@"[MXKAuthenticationViewController] attemptDeviceRehydration: device rehydration failed due to error: %@. Retrying", error); + [self attemptDeviceRehydrationWithKeyData:keyData credentials:credentials retry:NO]; + return; + } + + MXLogError(@"[MXKAuthenticationViewController] attemptDeviceRehydration: device rehydration failed due to error: %@", error); + + [self _createAccountWithCredentials:credentials]; + }]; +} + +- (void)_createAccountWithCredentials:(MXCredentials *)credentials +{ + MXKAccount *account = [[MXKAccount alloc] initWithCredentials:credentials]; + account.identityServerURL = credentials.identityServer; + + [[MXKAccountManager sharedManager] addAccount:account andOpenSession:YES]; + + if (_delegate) + { + [_delegate authenticationViewController:self didLogWithUserId:credentials.userId]; + } +} + +- (NSString *)deviceDisplayName +{ + if (_deviceDisplayName) + { + return _deviceDisplayName; + } + +#if TARGET_OS_IPHONE + NSString *deviceName = [[UIDevice currentDevice].model isEqualToString:@"iPad"] ? [MatrixKitL10n loginTabletDevice] : [MatrixKitL10n loginMobileDevice]; +#elif TARGET_OS_OSX + NSString *deviceName = [MatrixKitL10n loginDesktopDevice]; +#endif + + return deviceName; +} + +- (void)refreshForgotPasswordSession +{ + [_authenticationActivityIndicator stopAnimating]; + + MXKAuthInputsView *authInputsView = nil; + if (forgotPasswordAuthInputsViewClass) + { + // Instantiate a new auth inputs view, except if the current one is already an instance of this class. + if (self.authInputsView && self.authInputsView.class == forgotPasswordAuthInputsViewClass) + { + // Use the current view + authInputsView = self.authInputsView; + } + else + { + authInputsView = [forgotPasswordAuthInputsViewClass authInputsView]; + } + } + + if (authInputsView) + { + // Update authentication inputs view to return in initial step + [authInputsView setAuthSession:nil withAuthType:MXKAuthenticationTypeForgotPassword]; + + // Check whether the current view must be replaced + if (self.authInputsView != authInputsView) + { + // Refresh layout + self.authInputsView = authInputsView; + } + + // Refresh user interaction + self.userInteractionEnabled = _userInteractionEnabled; + } + else + { + // Remove the potential auth inputs view + self.authInputsView = nil; + + _noFlowLabel.text = [MatrixKitL10n loginErrorForgotPasswordIsNotSupported]; + + MXLogDebug(@"[MXKAuthenticationVC] Warning: %@", _noFlowLabel.text); + + _noFlowLabel.hidden = NO; + } +} + +- (void)updateRESTClient +{ + NSString *homeserverURL = _homeServerTextField.text; + + if (homeserverURL.length) + { + // Check change + if ([homeserverURL isEqualToString:mxRestClient.homeserver] == NO) + { + mxRestClient = [[MXRestClient alloc] initWithHomeServer:homeserverURL andOnUnrecognizedCertificateBlock:^BOOL(NSData *certificate) { + + // Check first if the app developer provided its own certificate handler. + if (self->onUnrecognizedCertificateCustomBlock) + { + return self->onUnrecognizedCertificateCustomBlock (certificate); + } + + // Else prompt the user by displaying a fingerprint (SHA256) of the certificate. + __block BOOL isTrusted; + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + + NSString *title = [MatrixKitL10n sslCouldNotVerify]; + NSString *homeserverURLStr = [MatrixKitL10n sslHomeserverUrl:homeserverURL]; + NSString *fingerprint = [MatrixKitL10n sslFingerprintHash:@"SHA256"]; + NSString *certFingerprint = [certificate mx_SHA256AsHexString]; + + NSString *msg = [NSString stringWithFormat:@"%@\n\n%@\n\n%@\n\n%@\n\n%@\n\n%@", [MatrixKitL10n sslCertNotTrust], [MatrixKitL10n sslCertNewAccountExpl], homeserverURLStr, fingerprint, certFingerprint, [MatrixKitL10n sslOnlyAccept]]; + + if (self->alert) + { + [self->alert dismissViewControllerAnimated:NO completion:nil]; + } + + self->alert = [UIAlertController alertControllerWithTitle:title message:msg preferredStyle:UIAlertControllerStyleAlert]; + + [self->alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + self->alert = nil; + isTrusted = NO; + dispatch_semaphore_signal(semaphore); + + }]]; + + [self->alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n sslTrust] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + self->alert = nil; + isTrusted = YES; + dispatch_semaphore_signal(semaphore); + + }]]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [self presentViewController:self->alert animated:YES completion:nil]; + }); + + dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); + + if (!isTrusted) + { + // Cancel request in progress + [self->mxCurrentOperation cancel]; + self->mxCurrentOperation = nil; + [[NSNotificationCenter defaultCenter] removeObserver:self name:AFNetworkingReachabilityDidChangeNotification object:nil]; + + [self->_authenticationActivityIndicator stopAnimating]; + } + + return isTrusted; + }]; + + if (_identityServerTextField.text.length) + { + [self updateIdentityServerURL:self.identityServerTextField.text]; + } + } + } + else + { + [mxRestClient close]; + mxRestClient = nil; + } +} + +- (void)loginWithParameters:(NSDictionary*)parameters +{ + // Add the device name + NSMutableDictionary *theParameters = [NSMutableDictionary dictionaryWithDictionary:parameters]; + theParameters[@"initial_device_display_name"] = self.deviceDisplayName; + + mxCurrentOperation = [mxRestClient login:theParameters success:^(NSDictionary *JSONResponse) { + + MXLoginResponse *loginResponse; + MXJSONModelSetMXJSONModel(loginResponse, MXLoginResponse, JSONResponse); + + MXCredentials *credentials = [[MXCredentials alloc] initWithLoginResponse:loginResponse + andDefaultCredentials:self->mxRestClient.credentials]; + + // Sanity check + if (!credentials.userId || !credentials.accessToken) + { + [self onFailureDuringAuthRequest:[NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n notSupportedYet]}]]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Login process succeeded"); + + // Report the certificate trusted by user (if any) + credentials.allowedCertificate = self->mxRestClient.allowedCertificate; + + [self onSuccessfulLogin:credentials]; + } + + } failure:^(NSError *error) { + + [self onFailureDuringAuthRequest:error]; + + }]; +} + +- (void)registerWithParameters:(NSDictionary*)parameters +{ + if (registrationTimer) + { + [registrationTimer invalidate]; + registrationTimer = nil; + } + + // Add the device name + NSMutableDictionary *theParameters = [NSMutableDictionary dictionaryWithDictionary:parameters]; + theParameters[@"initial_device_display_name"] = self.deviceDisplayName; + + mxCurrentOperation = [mxRestClient registerWithParameters:theParameters success:^(NSDictionary *JSONResponse) { + + MXLoginResponse *loginResponse; + MXJSONModelSetMXJSONModel(loginResponse, MXLoginResponse, JSONResponse); + + MXCredentials *credentials = [[MXCredentials alloc] initWithLoginResponse:loginResponse + andDefaultCredentials:self->mxRestClient.credentials]; + + // Sanity check + if (!credentials.userId || !credentials.accessToken) + { + [self onFailureDuringAuthRequest:[NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n notSupportedYet]}]]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Registration succeeded"); + + // Report the certificate trusted by user (if any) + credentials.allowedCertificate = self->mxRestClient.allowedCertificate; + + [self onSuccessfulLogin:credentials]; + } + + } failure:^(NSError *error) { + + self->mxCurrentOperation = nil; + + // Check whether the authentication is pending (for example waiting for email validation) + MXError *mxError = [[MXError alloc] initWithNSError:error]; + if (mxError && [mxError.errcode isEqualToString:kMXErrCodeStringUnauthorized]) + { + MXLogDebug(@"[MXKAuthenticationVC] Wait for email validation"); + + // Postpone a new attempt in 10 sec + self->registrationTimer = [NSTimer scheduledTimerWithTimeInterval:10 target:self selector:@selector(registrationTimerFireMethod:) userInfo:parameters repeats:NO]; + } + else + { + // The completed stages should be available in response data in case of unauthorized request. + NSDictionary *JSONResponse = nil; + if (error.userInfo[MXHTTPClientErrorResponseDataKey]) + { + JSONResponse = error.userInfo[MXHTTPClientErrorResponseDataKey]; + } + + if (JSONResponse) + { + MXAuthenticationSession *authSession = [MXAuthenticationSession modelFromJSON:JSONResponse]; + + if (authSession.completed) + { + [self->_authenticationActivityIndicator stopAnimating]; + + // Update session identifier in case of change + self.authInputsView.authSession.session = authSession.session; + + [self.authInputsView updateAuthSessionWithCompletedStages:authSession.completed didUpdateParameters:^(NSDictionary *parameters, NSError *error) { + + if (parameters) + { + MXLogDebug(@"[MXKAuthenticationVC] Pursue registration"); + + [self->_authenticationActivityIndicator startAnimating]; + [self registerWithParameters:parameters]; + } + else + { + MXLogDebug(@"[MXKAuthenticationVC] Failed to update parameters"); + + [self onFailureDuringAuthRequest:error]; + } + + }]; + + return; + } + + [self onFailureDuringAuthRequest:[NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n notSupportedYet]}]]; + } + else + { + [self onFailureDuringAuthRequest:error]; + } + } + }]; +} + +- (void)registrationTimerFireMethod:(NSTimer *)timer +{ + if (timer == registrationTimer && timer.isValid) + { + MXLogDebug(@"[MXKAuthenticationVC] Retry registration"); + [self registerWithParameters:registrationTimer.userInfo]; + } +} + +- (void)resetPasswordWithParameters:(NSDictionary*)parameters +{ + mxCurrentOperation = [mxRestClient resetPasswordWithParameters:parameters success:^() { + + MXLogDebug(@"[MXKAuthenticationVC] Reset password succeeded"); + + self->mxCurrentOperation = nil; + [self->_authenticationActivityIndicator stopAnimating]; + + self->isPasswordReseted = YES; + + // Force UI update to refresh submit button title. + self.authType = self->_authType; + + // Refresh the authentication inputs view on success. + [self.authInputsView nextStep]; + + } failure:^(NSError *error) { + + MXError *mxError = [[MXError alloc] initWithNSError:error]; + if (mxError && [mxError.errcode isEqualToString:kMXErrCodeStringUnauthorized]) + { + MXLogDebug(@"[MXKAuthenticationVC] Forgot Password: wait for email validation"); + + self->mxCurrentOperation = nil; + [self->_authenticationActivityIndicator stopAnimating]; + + if (self->alert) + { + [self->alert dismissViewControllerAnimated:NO completion:nil]; + } + + self->alert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n error] message:[MatrixKitL10n authResetPasswordErrorUnauthorized] preferredStyle:UIAlertControllerStyleAlert]; + + [self->alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + self->alert = nil; + + }]]; + + + [self presentViewController:self->alert animated:YES completion:nil]; + } + else if (mxError && [mxError.errcode isEqualToString:kMXErrCodeStringNotFound]) + { + MXLogDebug(@"[MXKAuthenticationVC] Forgot Password: not found"); + + NSMutableDictionary *userInfo; + if (error.userInfo) + { + userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo]; + } + else + { + userInfo = [NSMutableDictionary dictionary]; + } + userInfo[NSLocalizedDescriptionKey] = [MatrixKitL10n authResetPasswordErrorNotFound]; + + [self onFailureDuringAuthRequest:[NSError errorWithDomain:kMXNSErrorDomain code:0 userInfo:userInfo]]; + } + else + { + [self onFailureDuringAuthRequest:error]; + } + + }]; +} + +- (void)onFailureDuringMXOperation:(NSError*)error +{ + mxCurrentOperation = nil; + + [_authenticationActivityIndicator stopAnimating]; + + if ([error.domain isEqualToString:NSURLErrorDomain] && error.code == NSURLErrorCancelled) + { + // Ignore this error + MXLogDebug(@"[MXKAuthenticationVC] flows request cancelled"); + return; + } + + MXLogDebug(@"[MXKAuthenticationVC] Failed to get %@ flows: %@", (_authType == MXKAuthenticationTypeLogin ? @"Login" : @"Register"), error); + + // Cancel external registration parameters if any + _externalRegistrationParameters = nil; + + // Alert user + NSString *title = [error.userInfo valueForKey:NSLocalizedFailureReasonErrorKey]; + if (!title) + { + title = [MatrixKitL10n error]; + } + NSString *msg = [error.userInfo valueForKey:NSLocalizedDescriptionKey]; + + if (alert) + { + [alert dismissViewControllerAnimated:NO completion:nil]; + } + + alert = [UIAlertController alertControllerWithTitle:title message:msg preferredStyle:UIAlertControllerStyleAlert]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n dismiss] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + self->alert = nil; + + }]]; + + + [self presentViewController:alert animated:YES completion:nil]; + + // Handle specific error code here + if ([error.domain isEqualToString:NSURLErrorDomain]) + { + // Check network reachability + if (error.code == NSURLErrorNotConnectedToInternet) + { + // Add reachability observer in order to launch a new request when network will be available + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onReachabilityStatusChange:) name:AFNetworkingReachabilityDidChangeNotification object:nil]; + } + else if (error.code == kCFURLErrorTimedOut) + { + // Send a new request in 2 sec + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self refreshAuthenticationSession]; + }); + } + else + { + // Remove the potential auth inputs view + self.authInputsView = nil; + } + } + else + { + // Remove the potential auth inputs view + self.authInputsView = nil; + } + + if (!_authInputsView) + { + // Display failure reason + _noFlowLabel.hidden = NO; + _noFlowLabel.text = [error.userInfo valueForKey:NSLocalizedDescriptionKey]; + if (!_noFlowLabel.text.length) + { + _noFlowLabel.text = [MatrixKitL10n loginErrorNoLoginFlow]; + } + [_retryButton setTitle:[MatrixKitL10n retry] forState:UIControlStateNormal]; + [_retryButton setTitle:[MatrixKitL10n retry] forState:UIControlStateNormal]; + _retryButton.hidden = NO; + } +} + +- (void)onReachabilityStatusChange:(NSNotification *)notif +{ + AFNetworkReachabilityManager *reachabilityManager = [AFNetworkReachabilityManager sharedManager]; + AFNetworkReachabilityStatus status = reachabilityManager.networkReachabilityStatus; + + if (status == AFNetworkReachabilityStatusReachableViaWiFi || status == AFNetworkReachabilityStatusReachableViaWWAN) + { + dispatch_async(dispatch_get_main_queue(), ^{ + [self refreshAuthenticationSession]; + }); + } + else if (status == AFNetworkReachabilityStatusNotReachable) + { + _noFlowLabel.text = [MatrixKitL10n networkErrorNotReachable]; + } +} + +#pragma mark - Keyboard handling + +- (void)dismissKeyboard +{ + // Hide the keyboard + [_authInputsView dismissKeyboard]; + [_homeServerTextField resignFirstResponder]; + [_identityServerTextField resignFirstResponder]; +} + +#pragma mark - UITextField delegate + +- (void)onTextFieldChange:(NSNotification *)notif +{ + _submitButton.enabled = _authInputsView.areAllRequiredFieldsSet; + + if (notif.object == _homeServerTextField) + { + // If any, the current request is obsolete + [self cancelIdentityServerCheck]; + + [self setIdentityServerHidden:YES]; + } +} + +- (BOOL)textFieldShouldBeginEditing:(UITextField *)textField +{ + if (textField == _homeServerTextField) + { + // Cancel supported AuthFlow refresh if a request is in progress + [[NSNotificationCenter defaultCenter] removeObserver:self name:AFNetworkingReachabilityDidChangeNotification object:nil]; + + if (mxCurrentOperation) + { + // Cancel potential request in progress + [mxCurrentOperation cancel]; + mxCurrentOperation = nil; + } + } + + return YES; +} + +- (void)textFieldDidEndEditing:(UITextField *)textField +{ + if (textField == _homeServerTextField) + { + [self setHomeServerTextFieldText:textField.text]; + } + else if (textField == _identityServerTextField) + { + [self setIdentityServerTextFieldText:textField.text]; + } +} + +- (BOOL)textFieldShouldReturn:(UITextField*)textField +{ + if (textField.returnKeyType == UIReturnKeyDone) + { + // "Done" key has been pressed + [textField resignFirstResponder]; + } + return YES; +} + +#pragma mark - AuthInputsViewDelegate delegate + +- (void)authInputsView:(MXKAuthInputsView*)authInputsView presentAlertController:(UIAlertController*)inputsAlert +{ + [self dismissKeyboard]; + [self presentViewController:inputsAlert animated:YES completion:nil]; +} + +- (void)authInputsViewDidPressDoneKey:(MXKAuthInputsView *)authInputsView +{ + if (_submitButton.isEnabled) + { + // Launch authentication now + [self onButtonPressed:_submitButton]; + } +} + +- (MXRestClient *)authInputsViewThirdPartyIdValidationRestClient:(MXKAuthInputsView *)authInputsView +{ + return mxRestClient; +} + +- (MXIdentityService *)authInputsViewThirdPartyIdValidationIdentityService:(MXIdentityService *)authInputsView +{ + return self.identityService; +} + +#pragma mark - Authentication Fallback + +- (void)showAuthenticationFallBackView +{ + [self showAuthenticationFallBackView:authenticationFallback]; +} + +- (void)showAuthenticationFallBackView:(NSString*)fallbackPage +{ + _authenticationScrollView.hidden = YES; + _authFallbackContentView.hidden = NO; + + // Add a cancel button in case of navigation controller use. + if (self.navigationController) + { + if (!cancelFallbackBarButton) + { + cancelFallbackBarButton = [[UIBarButtonItem alloc] initWithTitle:[MatrixKitL10n loginLeaveFallback] style:UIBarButtonItemStylePlain target:self action:@selector(hideRegistrationFallbackView)]; + } + + // Add cancel button in right bar items + NSArray *rightBarButtonItems = self.navigationItem.rightBarButtonItems; + self.navigationItem.rightBarButtonItems = rightBarButtonItems ? [rightBarButtonItems arrayByAddingObject:cancelFallbackBarButton] : @[cancelFallbackBarButton]; + } + + if (self.softLogoutCredentials) + { + // Add device_id as query param of the fallback + NSURLComponents *components = [[NSURLComponents alloc] initWithString:fallbackPage]; + + NSMutableArray *queryItems = [components.queryItems mutableCopy]; + if (!queryItems) + { + queryItems = [NSMutableArray array]; + } + + [queryItems addObject:[NSURLQueryItem queryItemWithName:@"device_id" + value:self.softLogoutCredentials.deviceId]]; + + components.queryItems = queryItems; + + fallbackPage = components.URL.absoluteString; + } + + [_authFallbackWebView openFallbackPage:fallbackPage success:^(MXLoginResponse *loginResponse) { + + MXCredentials *credentials = [[MXCredentials alloc] initWithLoginResponse:loginResponse andDefaultCredentials:self->mxRestClient.credentials]; + + // TODO handle unrecognized certificate (if any) during registration through fallback webview. + + [self onSuccessfulLogin:credentials]; + }]; +} + +- (void)hideRegistrationFallbackView +{ + if (cancelFallbackBarButton) + { + NSMutableArray *rightBarButtonItems = [NSMutableArray arrayWithArray: self.navigationItem.rightBarButtonItems]; + [rightBarButtonItems removeObject:cancelFallbackBarButton]; + self.navigationItem.rightBarButtonItems = rightBarButtonItems; + } + + [_authFallbackWebView stopLoading]; + _authenticationScrollView.hidden = NO; + _authFallbackContentView.hidden = YES; +} + +#pragma mark - KVO + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +{ + if ([@"viewHeightConstraint.constant" isEqualToString:keyPath]) + { + // Refresh the height of the auth inputs view container. + CGFloat previousInputsContainerViewHeight = _authInputContainerViewHeightConstraint.constant; + _authInputContainerViewHeightConstraint.constant = _authInputsView.viewHeightConstraint.constant; + + // Force to render the view + [self.view layoutIfNeeded]; + + // Refresh content view height by considering the updated height of inputs container + _contentViewHeightConstraint.constant += (_authInputContainerViewHeightConstraint.constant - previousInputsContainerViewHeight); + } + else + { + [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.xib new file mode 100644 index 000000000..807f6382d --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKAuthenticationViewController.xib @@ -0,0 +1,298 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.h new file mode 100644 index 000000000..7e8e8ad92 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.h @@ -0,0 +1,243 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import + +#import "MXKViewController.h" + +#import "MXKImageView.h" + +@class MXKCallViewController; + +/** + Delegate for `MXKCallViewController` object + */ +@protocol MXKCallViewControllerDelegate + +/** + Tells the delegate to dismiss the call view controller. + This callback is called when the user wants to go back into the app during a call or when the call is ended. + The delegate should check the state of the associated call to know the actual reason. + + @param callViewController the call view controller. + @param completion the block to execute at the end of the operation. + */ +- (void)dismissCallViewController:(MXKCallViewController *)callViewController completion:(void (^)(void))completion; + +/** + Tells the delegate that user tapped on hold call. + @param callViewController the call view controller. + */ +- (void)callViewControllerDidTapOnHoldCall:(MXKCallViewController *)callViewController; + +@end + +extern NSString *const kMXKCallViewControllerWillAppearNotification; +extern NSString *const kMXKCallViewControllerAppearedNotification; +extern NSString *const kMXKCallViewControllerWillDisappearNotification; +extern NSString *const kMXKCallViewControllerDisappearedNotification; +extern NSString *const kMXKCallViewControllerBackToAppNotification; + +/** + 'MXKCallViewController' instance displays a call. Only one matrix session is supported by this view controller. + */ +@interface MXKCallViewController : MXKViewController + +@property (weak, nonatomic) IBOutlet MXKImageView *backgroundImageView; + +@property (weak, nonatomic, readonly) IBOutlet UIView *localPreviewContainerView; +@property (weak, nonatomic, readonly) IBOutlet UIView *localPreviewVideoView; +@property (weak, nonatomic) IBOutlet UIActivityIndicatorView *localPreviewActivityView; + +@property (weak, nonatomic, readonly) IBOutlet UIView *onHoldCallContainerView; +@property (weak, nonatomic) IBOutlet MXKImageView *onHoldCallerImageView; + +@property (weak, nonatomic, readonly) IBOutlet UIView *remotePreviewContainerView; + +@property (weak, nonatomic) IBOutlet UIView *overlayContainerView; +@property (weak, nonatomic) IBOutlet UIView *callContainerView; +@property (weak, nonatomic) IBOutlet MXKImageView *callerImageView; +@property (weak, nonatomic) IBOutlet UIImageView *pausedIcon; +@property (weak, nonatomic) IBOutlet UILabel *callerNameLabel; +@property (weak, nonatomic) IBOutlet UILabel *callStatusLabel; +@property (weak, nonatomic) IBOutlet UIButton *resumeButton; + +@property (weak, nonatomic) IBOutlet UIView *callToolBar; +@property (weak, nonatomic) IBOutlet UIButton *rejectCallButton; +@property (weak, nonatomic) IBOutlet UIButton *answerCallButton; +@property (weak, nonatomic) IBOutlet UIButton *endCallButton; + +@property (weak, nonatomic) IBOutlet UIView *callControlContainerView; +@property (weak, nonatomic) IBOutlet UIButton *speakerButton; +@property (weak, nonatomic) IBOutlet UIButton *audioMuteButton; +@property (weak, nonatomic) IBOutlet UIButton *videoMuteButton; +@property (weak, nonatomic) IBOutlet UIButton *moreButtonForVoice; +@property (weak, nonatomic) IBOutlet UIButton *moreButtonForVideo; + +@property (weak, nonatomic) IBOutlet UIButton *backToAppButton; +@property (weak, nonatomic) IBOutlet UIButton *cameraSwitchButton; + +@property (unsafe_unretained, nonatomic) IBOutlet NSLayoutConstraint *localPreviewContainerViewLeadingConstraint; +@property (unsafe_unretained, nonatomic) IBOutlet NSLayoutConstraint *localPreviewContainerViewTopConstraint; +@property (unsafe_unretained, nonatomic) IBOutlet NSLayoutConstraint *localPreviewContainerViewHeightConstraint; +@property (unsafe_unretained, nonatomic) IBOutlet NSLayoutConstraint *localPreviewContainerViewWidthConstraint; + +@property (weak, nonatomic) IBOutlet UIButton *transferButton; + +/** + The default picture displayed when no picture is available. + */ +@property (nonatomic) UIImage *picturePlaceholder; + +/** + The call status bar displayed on the top of the app during a call. + */ +@property (nonatomic, readonly) UIWindow *backToAppStatusWindow; + +/** + Flag whether this call screen is displaying an alert right now. + */ +@property (nonatomic, readonly, getter=isDisplayingAlert) BOOL displayingAlert; + +/** + The current call + */ +@property (nonatomic) MXCall *mxCall; + +/** + The current call on hold + */ +@property (nonatomic) MXCall *mxCallOnHold; + +/** + The current peer + */ +@property (nonatomic, readonly) MXUser *peer; + +/** + The current peer of the call on hold + */ +@property (nonatomic, readonly) MXUser *peerOnHold; + +/** + The delegate. + */ +@property (nonatomic, weak) id delegate; + +/* + Specifies whether a ringtone must be played on incoming call. + It's important to set this value before you will set `mxCall` otherwise value of this property can has no effect. + + Defaults to YES. + */ +@property (nonatomic) BOOL playRingtone; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKCallViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `roomViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKCallViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + + @param call a MXCall instance. + @return An initialized `MXKRoomViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)callViewController:(MXCall *)call; + +/** + Return an audio file url based on the provided name. + + @param soundName audio file name without extension. + @return a NSURL instance. + */ +- (NSURL*)audioURLWithName:(NSString *)soundName; + +/** + Refresh the peer information in the call viewcontroller's view. + */ +- (void)updatePeerInfoDisplay; + +/** + Adjust the layout of the preview container. + */ +- (void)updateLocalPreviewLayout; + +/** + Show/Hide the overlay view. + + @param isShown tell whether the overlay is shown or not. + */ +- (void)showOverlayContainer:(BOOL)isShown; + +/** + Set up or teardown the promixity monitoring and enable/disable the idle timer according to call type, state & audio route. + */ +- (void)updateProximityAndSleep; + +/** + Prepare and return the optional view displayed during incoming call notification. + Return nil by default + + Subclasses may override this method to provide appropriate for their app view. + When this method is called peer and mxCall are valid so you can use them. + */ +- (UIView *)createIncomingCallView; + +/** + Action registered on the event 'UIControlEventTouchUpInside' for each UIButton instance. + */ +- (IBAction)onButtonPressed:(id)sender; + +/** + Default implementation presents an action sheet with proper options. Override to change the user interface. + */ +- (void)showAudioDeviceOptions; + +/** + Default implementation makes the button selected for loud speakers and external device options, non-selected for built-in device. + */ +- (void)configureSpeakerButton; + +#pragma mark - DTMF + +/** + Default implementation does nothing. Override to show a dial pad and then use MXCall methods to send DTMF tones. + */ +- (void)openDialpad; + +#pragma mark - Call Transfer + +/** + Default implementation does nothing. Override to show a contact selection screen and then use MXCallManager methods to start the transfer. + */ +- (void)openCallTransfer; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.m new file mode 100644 index 000000000..55781b106 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.m @@ -0,0 +1,1547 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKCallViewController.h" + +@import MatrixSDK; + +#import "MXKAppSettings.h" +#import "MXKSoundPlayer.h" +#import "MXKTools.h" +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +NSString *const kMXKCallViewControllerWillAppearNotification = @"kMXKCallViewControllerWillAppearNotification"; +NSString *const kMXKCallViewControllerAppearedNotification = @"kMXKCallViewControllerAppearedNotification"; +NSString *const kMXKCallViewControllerWillDisappearNotification = @"kMXKCallViewControllerWillDisappearNotification"; +NSString *const kMXKCallViewControllerDisappearedNotification = @"kMXKCallViewControllerDisappearedNotification"; +NSString *const kMXKCallViewControllerBackToAppNotification = @"kMXKCallViewControllerBackToAppNotification"; + +static const CGFloat kLocalPreviewMargin = 20; + +@interface MXKCallViewController () +{ + NSTimer *hideOverlayTimer; + NSTimer *updateStatusTimer; + + Boolean isMovingLocalPreview; + Boolean isSelectingLocalPreview; + + CGPoint startNewLocalMove; + + /** + The popup showed in case of call stack error. + */ + UIAlertController *errorAlert; + + // the room events listener + id roomListener; + + // Observe kMXRoomDidFlushDataNotification to take into account the updated room members when the room history is flushed. + id roomDidFlushDataNotificationObserver; + + // Observe AVAudioSessionRouteChangeNotification + id audioSessionRouteChangeNotificationObserver; + + // Current alert (if any). + UIAlertController *currentAlert; + + // Current peer display name + NSString *peerDisplayName; +} + +@property (nonatomic, assign) Boolean isRinging; + +@property (nonatomic, nullable) UIView *incomingCallView; + +@property (nonatomic, strong) UITapGestureRecognizer *onHoldCallContainerTapRecognizer; + +@end + +@implementation MXKCallViewController +@synthesize backgroundImageView; +@synthesize localPreviewContainerView, localPreviewVideoView, localPreviewActivityView, remotePreviewContainerView; +@synthesize overlayContainerView, callContainerView, callerImageView, callerNameLabel, callStatusLabel; +@synthesize callToolBar, rejectCallButton, answerCallButton, endCallButton; +@synthesize callControlContainerView, speakerButton, audioMuteButton, videoMuteButton; +@synthesize backToAppButton, cameraSwitchButton; +@synthesize backToAppStatusWindow; +@synthesize mxCall; +@synthesize mxCallOnHold; +@synthesize onHoldCallerImageView; +@synthesize onHoldCallContainerView; + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass(self.class) + bundle:[NSBundle bundleForClass:self.class]]; +} + ++ (instancetype)callViewController:(MXCall*)call +{ + MXKCallViewController *instance = [[[self class] alloc] initWithNibName:NSStringFromClass(self.class) + bundle:[NSBundle bundleForClass:self.class]]; + + // Load the view controller's view now (buttons and views will then be available). + if ([instance respondsToSelector:@selector(loadViewIfNeeded)]) + { + // iOS 9 and later + [instance loadViewIfNeeded]; + } + else if (instance.view) + { + // Patch: on iOS < 9.0, we load the view by calling its getter. + } + + instance.mxCall = call; + + return instance; +} + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + _playRingtone = YES; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + updateStatusTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(updateTimeStatusLabel) userInfo:nil repeats:YES]; + + self.callerImageView.defaultBackgroundColor = [UIColor clearColor]; + self.backToAppButton.backgroundColor = [UIColor clearColor]; + self.audioMuteButton.backgroundColor = [UIColor clearColor]; + self.videoMuteButton.backgroundColor = [UIColor clearColor]; + self.resumeButton.backgroundColor = [UIColor clearColor]; + self.moreButton.backgroundColor = [UIColor clearColor]; + self.speakerButton.backgroundColor = [UIColor clearColor]; + self.transferButton.backgroundColor = [UIColor clearColor]; + + [self.backToAppButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_backtoapp"] forState:UIControlStateNormal]; + [self.backToAppButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_backtoapp"] forState:UIControlStateHighlighted]; + [self.audioMuteButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_audio_unmute"] forState:UIControlStateNormal]; + [self.audioMuteButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_audio_mute"] forState:UIControlStateSelected]; + [self.videoMuteButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_video_unmute"] forState:UIControlStateNormal]; + [self.videoMuteButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_video_mute"] forState:UIControlStateSelected]; + [self.moreButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_call_more"] forState:UIControlStateNormal]; + [self.moreButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_call_more"] forState:UIControlStateSelected]; + [self.speakerButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_speaker_off"] forState:UIControlStateNormal]; + [self.speakerButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_speaker_on"] forState:UIControlStateSelected]; + + // Localize string + [answerCallButton setTitle:[MatrixKitL10n answerCall] forState:UIControlStateNormal]; + [answerCallButton setTitle:[MatrixKitL10n answerCall] forState:UIControlStateHighlighted]; + [rejectCallButton setTitle:[MatrixKitL10n rejectCall] forState:UIControlStateNormal]; + [rejectCallButton setTitle:[MatrixKitL10n rejectCall] forState:UIControlStateHighlighted]; + [endCallButton setTitle:[MatrixKitL10n endCall] forState:UIControlStateNormal]; + [endCallButton setTitle:[MatrixKitL10n endCall] forState:UIControlStateHighlighted]; + [_resumeButton setTitle:[MatrixKitL10n resumeCall] forState:UIControlStateNormal]; + [_resumeButton setTitle:[MatrixKitL10n resumeCall] forState:UIControlStateHighlighted]; + + // Refresh call information + self.mxCall = mxCall; + + // Listen to AVAudioSession activation notification if CallKit is available and enabled + BOOL isCallKitAvailable = [MXCallKitAdapter callKitAvailable] && [MXKAppSettings standardAppSettings].isCallKitEnabled; + if (isCallKitAvailable) + { + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleAudioSessionActivationNotification) + name:kMXCallKitAdapterAudioSessionDidActive + object:nil]; + } +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; +} + +- (void)dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXCallKitAdapterAudioSessionDidActive object:nil]; + + [self removeObservers]; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKCallViewControllerWillAppearNotification object:nil]; + + [self updateLocalPreviewLayout]; + [self showOverlayContainer:YES]; + + if (mxCall) + { + // Refresh call display according to the call room state. + [self callRoomStateDidChange:^{ + // Refresh call status + [self call:self->mxCall stateDidChange:self->mxCall.state reason:nil]; + }]; + + } + + if (_delegate) + { + backToAppButton.hidden = NO; + } + else + { + backToAppButton.hidden = YES; + } +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKCallViewControllerAppearedNotification object:nil]; + + // trick to hide the volume at launch + // as the mininum volume is forced by the application + // the volume popup can be displayed + // volumeView = [[MPVolumeView alloc] initWithFrame: CGRectMake(5000, 5000, 0, 0)]; + // [self.view addSubview: volumeView]; + // + // dispatch_after(dispatch_walltime(DISPATCH_TIME_NOW, 0.5 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ + // [volumeView removeFromSuperview]; + // }); +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKCallViewControllerWillDisappearNotification object:nil]; +} + +- (void)viewDidDisappear:(BOOL)animated +{ + [super viewDidDisappear:animated]; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKCallViewControllerDisappearedNotification object:nil]; +} + +- (void)dismiss +{ + if (_delegate) + { + [_delegate dismissCallViewController:self completion:nil]; + } + else + { + // Auto dismiss after few seconds + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self dismissViewControllerAnimated:YES completion:nil]; + }); + } +} + +#pragma mark - override MXKViewController + +- (void)destroy +{ + self.peer = nil; + + self.mxCall = nil; + + _delegate = nil; + + self.isRinging = NO; + + [hideOverlayTimer invalidate]; + [updateStatusTimer invalidate]; + + _incomingCallView = nil; + + _onHoldCallContainerTapRecognizer = nil; + + [super destroy]; +} + +#pragma mark - Properties + +- (UIImage *)picturePlaceholder +{ + return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"]; +} + +- (void)setMxCall:(MXCall *)call +{ + // Remove previous call (if any) + if (mxCall) + { + mxCall.delegate = nil; + mxCall.selfVideoView = nil; + mxCall.remoteVideoView = nil; + [self removeMatrixSession:self.mainSession]; + + [self removeObservers]; + + mxCall = nil; + } + + if (call && call.room) + { + mxCall = call; + + [self addMatrixSession:mxCall.room.mxSession]; + + MXWeakify(self); + + // Register a listener to handle messages related to room name, members... + roomListener = [mxCall.room listenToEventsOfTypes:@[kMXEventTypeStringRoomName, kMXEventTypeStringRoomTopic, kMXEventTypeStringRoomAliases, kMXEventTypeStringRoomAvatar, kMXEventTypeStringRoomCanonicalAlias, kMXEventTypeStringRoomMember] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + MXStrongifyAndReturnIfNil(self); + + // Consider only live events + if (self->mxCall && direction == MXTimelineDirectionForwards) + { + // The room state has been changed + [self callRoomStateDidChange:nil]; + } + }]; + + // Observe room history flush (sync with limited timeline, or state event redaction) + roomDidFlushDataNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXRoomDidFlushDataNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + MXStrongifyAndReturnIfNil(self); + + MXRoom *room = notif.object; + if (self->mxCall && self.mainSession == room.mxSession && [self->mxCall.room.roomId isEqualToString:room.roomId]) + { + // The existing room history has been flushed during server sync. + // Take into account the updated room state + [self callRoomStateDidChange:nil]; + } + + }]; + + audioSessionRouteChangeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AVAudioSessionRouteChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + [self updateProximityAndSleep]; + + }]; + + // Hide video mute on voice call + self.videoMuteButton.hidden = !call.isVideoCall; + + // Hide camera switch on voice call + self.cameraSwitchButton.hidden = !call.isVideoCall; + + _moreButtonForVideo.hidden = !call.isVideoCall; + _moreButtonForVoice.hidden = call.isVideoCall; + + // Observe call state change + call.delegate = self; + + // Display room call information + [self callRoomStateDidChange:^{ + [self call:call stateDidChange:call.state reason:nil]; + }]; + + if (call.isVideoCall && localPreviewContainerView) + { + // Access to the camera is mandatory to display the self view + // Check the permission right now + NSString *appDisplayName = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleDisplayName"]; + [MXKTools checkAccessForMediaType:AVMediaTypeVideo + manualChangeMessage:[MatrixKitL10n cameraAccessNotGrantedForCall:appDisplayName] + + showPopUpInViewController:self completionHandler:^(BOOL granted) { + + if (granted) + { + self->localPreviewContainerView.hidden = NO; + self->remotePreviewContainerView.hidden = NO; + + call.selfVideoView = self->localPreviewVideoView; + call.remoteVideoView = self->remotePreviewContainerView; + [self applyDeviceOrientation:YES]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(deviceOrientationDidChange) + name:UIDeviceOrientationDidChangeNotification + object:nil]; + } + }]; + } + else + { + localPreviewContainerView.hidden = YES; + remotePreviewContainerView.hidden = YES; + } + } +} + +- (void)setMxCallOnHold:(MXCall *)callOnHold +{ + if (mxCallOnHold == callOnHold) + { + // setting same property, return + return; + } + + mxCallOnHold = callOnHold; + + if (mxCallOnHold) + { + self.onHoldCallContainerView.hidden = NO; + [self.onHoldCallContainerView addGestureRecognizer:self.onHoldCallContainerTapRecognizer]; + [self.onHoldCallContainerView setUserInteractionEnabled:YES]; + + // Handle peer here + if (mxCallOnHold.isIncoming) + { + self.peerOnHold = [mxCallOnHold.room.mxSession getOrCreateUser:mxCallOnHold.callerId]; + } + else + { + // For 1:1 call, find the other peer + // Else, the room information will be used to display information about the call + MXWeakify(self); + [mxCallOnHold.room state:^(MXRoomState *roomState) { + MXStrongifyAndReturnIfNil(self); + + MXUser *theMember = nil; + NSArray *members = roomState.members.joinedMembers; + for (MXUser *member in members) + { + if (![member.userId isEqualToString:self->mxCallOnHold.callerId]) + { + theMember = member; + break; + } + } + + self.peerOnHold = theMember; + }]; + } + } + else + { + [self.onHoldCallContainerView removeGestureRecognizer:self.onHoldCallContainerTapRecognizer]; + [self.onHoldCallContainerView setUserInteractionEnabled:NO]; + self.onHoldCallContainerView.hidden = YES; + self.peerOnHold = nil; + } +} + +- (void)setPeer:(MXUser *)peer +{ + _peer = peer; + + [self updatePeerInfoDisplay]; +} + +- (void)setPeerOnHold:(MXUser *)peerOnHold +{ + _peerOnHold = peerOnHold; + + NSString *peerAvatarURL; + + if (_peerOnHold) + { + peerAvatarURL = _peerOnHold.avatarUrl; + } + else if (mxCall.isConferenceCall) + { + peerAvatarURL = mxCallOnHold.room.summary.avatar; + } + + onHoldCallerImageView.imageView.contentMode = UIViewContentModeScaleAspectFill; + + if (peerAvatarURL) + { + // Suppose avatar url is a matrix content uri, we use SDK to get the well adapted thumbnail from server + onHoldCallerImageView.mediaFolder = kMXMediaManagerAvatarThumbnailFolder; + onHoldCallerImageView.enableInMemoryCache = YES; + [onHoldCallerImageView setImageURI:peerAvatarURL + withType:nil + andImageOrientation:UIImageOrientationUp + toFitViewSize:onHoldCallerImageView.frame.size + withMethod:MXThumbnailingMethodCrop + previewImage:self.picturePlaceholder + mediaManager:self.mainSession.mediaManager]; + } + else + { + onHoldCallerImageView.image = self.picturePlaceholder; + } +} + +- (void)updatePeerInfoDisplay +{ + NSString *peerAvatarURL; + + if (_peer) + { + peerDisplayName = [_peer displayname]; + if (!peerDisplayName.length) + { + peerDisplayName = _peer.userId; + } + peerAvatarURL = _peer.avatarUrl; + } + else if (mxCall.isConferenceCall) + { + peerDisplayName = mxCall.room.summary.displayname; + peerAvatarURL = mxCall.room.summary.avatar; + } + + if (mxCall.isConsulting) + { + callerNameLabel.text = [MatrixKitL10n callConsultingWithUser:peerDisplayName]; + } + else + { + if (mxCall.isVideoCall) + { + callerNameLabel.text = [MatrixKitL10n callVideoWithUser:peerDisplayName]; + } + else + { + callerNameLabel.text = [MatrixKitL10n callVoiceWithUser:peerDisplayName]; + } + } + + if (peerAvatarURL) + { + // Suppose avatar url is a matrix content uri, we use SDK to get the well adapted thumbnail from server + callerImageView.mediaFolder = kMXMediaManagerAvatarThumbnailFolder; + callerImageView.enableInMemoryCache = YES; + [callerImageView setImageURI:peerAvatarURL + withType:nil + andImageOrientation:UIImageOrientationUp + toFitViewSize:callerImageView.frame.size + withMethod:MXThumbnailingMethodCrop + previewImage:self.picturePlaceholder + mediaManager:self.mainSession.mediaManager]; + } + else + { + callerImageView.image = self.picturePlaceholder; + } + + // Round caller image view + [callerImageView.layer setCornerRadius:callerImageView.frame.size.width / 2]; + callerImageView.clipsToBounds = YES; +} + +- (void)setIsRinging:(Boolean)isRinging +{ + if (_isRinging != isRinging) + { + if (isRinging) + { + NSURL *audioUrl; + if (mxCall.isIncoming) + { + if (self.playRingtone) + audioUrl = [self audioURLWithName:@"ring"]; + } + else + { + audioUrl = [self audioURLWithName:@"ringback"]; + } + + if (audioUrl) + { + [[MXKSoundPlayer sharedInstance] playSoundAt:audioUrl repeat:YES vibrate:mxCall.isIncoming routeToBuiltInReceiver:!mxCall.isIncoming]; + } + } + else + { + [[MXKSoundPlayer sharedInstance] stopPlayingWithAudioSessionDeactivation:NO]; + } + + _isRinging = isRinging; + } +} + +- (void)setDelegate:(id)delegate +{ + _delegate = delegate; + + if (_delegate) + { + backToAppButton.hidden = NO; + } + else + { + backToAppButton.hidden = YES; + } +} + +- (UITapGestureRecognizer *)onHoldCallContainerTapRecognizer +{ + if (_onHoldCallContainerTapRecognizer == nil) + { + _onHoldCallContainerTapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(onHoldCallContainerTapped:)]; + } + return _onHoldCallContainerTapRecognizer; +} + +- (BOOL)isDisplayingAlert +{ + return errorAlert != nil; +} + +- (UIButton *)moreButton +{ + if (mxCall.isVideoCall) + { + return _moreButtonForVideo; + } + return _moreButtonForVoice; +} + +#pragma mark - Sounds + +- (NSURL *)audioURLWithName:(NSString *)soundName +{ + return [NSBundle mxk_audioURLFromMXKAssetsBundleWithName:soundName]; +} + +#pragma mark - Actions + +- (void)onHoldCallContainerTapped:(UITapGestureRecognizer *)recognizer +{ + if ([self.delegate respondsToSelector:@selector(callViewControllerDidTapOnHoldCall:)]) + { + [self.delegate callViewControllerDidTapOnHoldCall:self]; + } +} + +- (IBAction)onButtonPressed:(id)sender +{ + if (sender == answerCallButton) + { + // If we are here, we have access to the camera + // The following check is mainly to check microphone access permission + NSString *appDisplayName = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleDisplayName"]; + + [MXKTools checkAccessForCall:mxCall.isVideoCall + manualChangeMessageForAudio:[MatrixKitL10n microphoneAccessNotGrantedForCall:appDisplayName] + manualChangeMessageForVideo:[MatrixKitL10n cameraAccessNotGrantedForCall:appDisplayName] + showPopUpInViewController:self completionHandler:^(BOOL granted) { + + if (granted) + { + [self->mxCall answer]; + } + }]; + } + else if (sender == rejectCallButton || sender == endCallButton) + { + if (mxCall.state != MXCallStateEnded) + { + [mxCall hangup]; + } + else + { + [self dismiss]; + } + } + else if (sender == audioMuteButton) + { + mxCall.audioMuted = !mxCall.audioMuted; + audioMuteButton.selected = mxCall.audioMuted; + } + else if (sender == videoMuteButton) + { + mxCall.videoMuted = !mxCall.videoMuted; + videoMuteButton.selected = mxCall.videoMuted; + } + else if (sender == _resumeButton) + { + [mxCall hold:NO]; + } + else if (sender == self.moreButton) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + + MXWeakify(self); + + NSMutableArray *actions = [NSMutableArray arrayWithCapacity:4]; + + if (self.speakerButton == nil) + { + // audio device action + UIAlertAction *audioDeviceAction = [UIAlertAction actionWithTitle:[MatrixKitL10n callMoreActionsChangeAudioDevice] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + [self showAudioDeviceOptions]; + + }]; + + [actions addObject:audioDeviceAction]; + } + + // check the call can be up/downgraded + + // check the call can send DTMF tones + if (self.mxCall.supportsDTMF) + { + UIAlertAction *dialpadAction = [UIAlertAction actionWithTitle:[MatrixKitL10n callMoreActionsDialpad] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + [self openDialpad]; + + }]; + + [actions addObject:dialpadAction]; + } + + // check the call be holded/unholded + if (mxCall.supportsHolding) + { + NSString *actionLocKey = (mxCall.state == MXCallStateOnHold) ? [MatrixKitL10n callMoreActionsUnhold] : [MatrixKitL10n callMoreActionsHold]; + + UIAlertAction *holdAction = [UIAlertAction actionWithTitle:actionLocKey + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + [self->mxCall hold:(self.mxCall.state != MXCallStateOnHold)]; + + }]; + + [actions addObject:holdAction]; + } + + // check the call be transferred + if (mxCall.supportsTransferring && self.peer) + { + UIAlertAction *transferAction = [UIAlertAction actionWithTitle:[MatrixKitL10n callMoreActionsTransfer] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + + [self openCallTransfer]; + }]; + + [actions addObject:transferAction]; + } + + if (actions.count > 0) + { + // create the alert + currentAlert = [UIAlertController alertControllerWithTitle:nil + message:nil + preferredStyle:UIAlertControllerStyleActionSheet]; + + // add actions + [actions enumerateObjectsUsingBlock:^(UIAlertAction * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + [currentAlert addAction:obj]; + }]; + + // add cancel action always + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleCancel + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + + }]]; + + [currentAlert popoverPresentationController].sourceView = self.moreButton; + [currentAlert popoverPresentationController].sourceRect = self.moreButton.bounds; + [self presentViewController:currentAlert animated:YES completion:nil]; + } + } + else if (sender == speakerButton) + { + [self showAudioDeviceOptions]; + } + else if (sender == cameraSwitchButton) + { + switch (mxCall.cameraPosition) + { + case AVCaptureDevicePositionFront: + mxCall.cameraPosition = AVCaptureDevicePositionBack; + break; + + default: + mxCall.cameraPosition = AVCaptureDevicePositionFront; + break; + } + } + else if (sender == backToAppButton) + { + if (_delegate) + { + // Dismiss the view controller whereas the call is still running + [_delegate dismissCallViewController:self completion:nil]; + } + } + else if (sender == _transferButton) + { + // actually transfer the call without consulting + [self.mainSession.callManager transferCall:mxCall.callWithTransferee + to:mxCall.transferTarget + withTransferee:mxCall.transferee + consultFirst:NO + success:^(NSString * _Nullable newCallId) { + + } + failure:^(NSError * _Nullable error) { + + }]; + } + + [self updateProximityAndSleep]; +} + +- (void)showAudioDeviceOptions +{ + NSMutableArray *actions = [NSMutableArray new]; + NSArray *availableRoutes = mxCall.audioOutputRouter.availableOutputRoutes; + + for (MXiOSAudioOutputRoute *route in availableRoutes) + { + // route action + NSString *name = route.name; + if (route.routeType == MXiOSAudioOutputRouteTypeLoudSpeakers) + { + name = [MatrixKitL10n callMoreActionsAudioUseDevice]; + } + MXWeakify(self); + UIAlertAction *routeAction = [UIAlertAction actionWithTitle:name + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + [self->mxCall.audioOutputRouter changeCurrentRouteTo:route]; + + }]; + + [actions addObject:routeAction]; + } + + if (actions.count > 0) + { + // create the alert + currentAlert = [UIAlertController alertControllerWithTitle:nil + message:nil + preferredStyle:UIAlertControllerStyleActionSheet]; + + for (UIAlertAction *action in actions) + { + [currentAlert addAction:action]; + } + + // add cancel action + MXWeakify(self); + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleCancel + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + + }]]; + + [currentAlert popoverPresentationController].sourceView = self.moreButton; + [currentAlert popoverPresentationController].sourceRect = self.moreButton.bounds; + [self presentViewController:currentAlert animated:YES completion:nil]; + } +} + +#pragma mark - DTMF + +- (void)openDialpad +{ + // no-op +} + +#pragma mark - Call Transfer + +- (void)openCallTransfer +{ + // no-op +} + +#pragma mark - MXCallDelegate + +- (void)call:(MXCall *)call stateDidChange:(MXCallState)state reason:(MXEvent *)event +{ + // Set default configuration of bottom bar + endCallButton.hidden = NO; + rejectCallButton.hidden = YES; + answerCallButton.hidden = YES; + self.moreButton.enabled = YES; + _resumeButton.hidden = state != MXCallStateOnHold; + _pausedIcon.hidden = state != MXCallStateOnHold && state != MXCallStateRemotelyOnHold; + _transferButton.hidden = YES; + + [localPreviewActivityView stopAnimating]; + + switch (state) + { + case MXCallStateFledgling: + self.isRinging = NO; + callStatusLabel.text = [MatrixKitL10n callConnecting]; + break; + case MXCallStateWaitLocalMedia: + self.isRinging = NO; + [self configureSpeakerButton]; + [localPreviewActivityView startAnimating]; + + // Try to show a special view for incoming view + [self configureIncomingCallViewIfRequiredWith:call]; + + break; + case MXCallStateCreateOffer: + { + // When CallKit is enabled and we have an outgoing call, we need to start playing ringback sound + // only after AVAudioSession will be activated by the system otherwise the sound will be gone. + // We always receive signal about MXCallStateCreateOffer earlier than the system activates AVAudioSession + // so we start playing ringback sound only on AVAudioSession activation in handleAudioSessionActivationNotification + BOOL isCallKitAvailable = [MXCallKitAdapter callKitAvailable] && [MXKAppSettings standardAppSettings].isCallKitEnabled; + if (!isCallKitAvailable) + { + self.isRinging = YES; + } + + callStatusLabel.text = [MatrixKitL10n callConnecting]; + break; + } + case MXCallStateInviteSent: + { + callStatusLabel.text = [MatrixKitL10n callRinging]; + break; + } + case MXCallStateRinging: + self.isRinging = YES; + [self configureSpeakerButton]; + if (call.isVideoCall) + { + callStatusLabel.text = [MatrixKitL10n incomingVideoCall]; + } + else + { + callStatusLabel.text = [MatrixKitL10n incomingVoiceCall]; + } + // Update bottom bar + endCallButton.hidden = YES; + rejectCallButton.hidden = NO; + answerCallButton.hidden = NO; + + // Try to show a special view for incoming view + [self configureIncomingCallViewIfRequiredWith:call]; + + break; + case MXCallStateConnecting: + self.isRinging = NO; + + // User has accepted the call and we can remove incomingCallView + if (self.incomingCallView) + { + [UIView transitionWithView:self.view + duration:0.33 + options:UIViewAnimationOptionTransitionCrossDissolve | UIViewAnimationOptionCurveEaseOut + animations:^{ + [self.incomingCallView removeFromSuperview]; + } + completion:^(BOOL finished) { + self.incomingCallView = nil; + }]; + } + + break; + case MXCallStateConnected: + self.isRinging = NO; + [self updateTimeStatusLabel]; + + if (call.isVideoCall) + { + self.callerImageView.hidden = YES; + + if (call.isConferenceCall) + { + // Do not show self view anymore because it is returned by the conference bridge + self.localPreviewContainerView.hidden = YES; + + // Well, hide does not work. So, shrink the view to nil + self.localPreviewContainerView.frame = CGRectZero; + } + } + audioMuteButton.enabled = YES; + videoMuteButton.enabled = YES; + speakerButton.enabled = YES; + cameraSwitchButton.enabled = YES; + if (call.isConsulting) + { + _transferButton.hidden = NO; + } + + break; + case MXCallStateOnHold: + callStatusLabel.text = [MatrixKitL10n callHolded]; + + break; + case MXCallStateRemotelyOnHold: + audioMuteButton.enabled = NO; + videoMuteButton.enabled = NO; + speakerButton.enabled = NO; + cameraSwitchButton.enabled = NO; + self.moreButton.enabled = NO; + callStatusLabel.text = [MatrixKitL10n callRemoteHolded:peerDisplayName]; + + break; + case MXCallStateInviteExpired: + // MXCallStateInviteExpired state is sent as an notification + // MXCall will move quickly to the MXCallStateEnded state + self.isRinging = NO; + callStatusLabel.text = [MatrixKitL10n callInviteExpired]; + + break; + case MXCallStateEnded: + { + self.isRinging = NO; + callStatusLabel.text = [MatrixKitL10n callEnded]; + + NSString *soundName = [self soundNameForCallEnding]; + if (soundName) + { + NSURL *audioUrl = [self audioURLWithName:soundName]; + [[MXKSoundPlayer sharedInstance] playSoundAt:audioUrl repeat:NO vibrate:NO routeToBuiltInReceiver:YES]; + } + else + { + [[MXKSoundPlayer sharedInstance] stopPlayingWithAudioSessionDeactivation:YES]; + } + + // Except in case of call error, quit the screen right now + if (!errorAlert) + { + [self dismiss]; + } + + break; + } + default: + break; + } + + [self updateProximityAndSleep]; +} + +- (void)call:(MXCall *)call didEncounterError:(NSError *)error reason:(MXCallHangupReason)reason +{ + MXLogDebug(@"[MXKCallViewController] didEncounterError. mxCall.state: %tu. Stop call due to error: %@", mxCall.state, error); + + if (mxCall.state != MXCallStateEnded) + { + // Popup the error to the user + NSString *title = [error.userInfo valueForKey:NSLocalizedFailureReasonErrorKey]; + if (!title) + { + title = [MatrixKitL10n error]; + } + NSString *msg = [error.userInfo valueForKey:NSLocalizedDescriptionKey]; + if (!msg) + { + msg = [MatrixKitL10n errorCommonMessage]; + } + + MXWeakify(self); + errorAlert = [UIAlertController alertControllerWithTitle:title message:msg preferredStyle:UIAlertControllerStyleAlert]; + + [errorAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->errorAlert = nil; + [self dismiss]; + + }]]; + + [self presentViewController:errorAlert animated:YES completion:nil]; + + // And interrupt the call + [mxCall hangupWithReason:reason]; + } +} + +- (void)callConsultingStatusDidChange:(MXCall *)call +{ + [self updatePeerInfoDisplay]; + + if (call.isConsulting) + { + NSString *title = [MatrixKitL10n callTransferToUser:call.transferee.displayname]; + [_transferButton setTitle:title forState:UIControlStateNormal]; + _transferButton.hidden = call.state != MXCallStateConnected; + } + else + { + _transferButton.hidden = YES; + } +} + +- (void)callAssertedIdentityDidChange:(MXCall *)call +{ + MXAssertedIdentityModel *assertedIdentity = call.assertedIdentity; + + if (assertedIdentity) + { + // update caller display name and avatar with the asserted identity + NSString *peerAvatarURL = assertedIdentity.avatarUrl; + + if (assertedIdentity.displayname) + { + peerDisplayName = assertedIdentity.displayname; + } + else if (assertedIdentity.userId) + { + peerDisplayName = assertedIdentity.userId; + } + + if (mxCall.isVideoCall) + { + callerNameLabel.text = [MatrixKitL10n callVideoWithUser:peerDisplayName]; + } + else + { + callerNameLabel.text = [MatrixKitL10n callVoiceWithUser:peerDisplayName]; + } + + if (peerAvatarURL) + { + // Suppose avatar url is a matrix content uri, we use SDK to get the well adapted thumbnail from server + callerImageView.mediaFolder = kMXMediaManagerAvatarThumbnailFolder; + callerImageView.enableInMemoryCache = YES; + [callerImageView setImageURI:peerAvatarURL + withType:nil + andImageOrientation:UIImageOrientationUp + toFitViewSize:callerImageView.frame.size + withMethod:MXThumbnailingMethodCrop + previewImage:self.picturePlaceholder + mediaManager:self.mainSession.mediaManager]; + } + else + { + callerImageView.image = self.picturePlaceholder; + } + + [updateStatusTimer fire]; + } + else + { + // go back to the original display name and avatar + [self updatePeerInfoDisplay]; + } +} + +- (void)callAudioOutputRouteTypeDidChange:(MXCall *)call +{ + [self configureSpeakerButton]; +} + +- (void)callAvailableAudioOutputsDidChange:(MXCall *)call +{ + +} + +#pragma mark - Internal + +- (void)removeObservers +{ + if (roomDidFlushDataNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:roomDidFlushDataNotificationObserver]; + roomDidFlushDataNotificationObserver = nil; + } + + if (audioSessionRouteChangeNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:audioSessionRouteChangeNotificationObserver]; + audioSessionRouteChangeNotificationObserver = nil; + } + + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + if (roomListener && mxCall.room) + { + MXWeakify(self); + [mxCall.room liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + [liveTimeline removeListener:self->roomListener]; + self->roomListener = nil; + }]; + } +} + +- (void)callRoomStateDidChange:(dispatch_block_t)onComplete +{ + // Handle peer here + if (mxCall.isIncoming) + { + self.peer = [mxCall.room.mxSession getOrCreateUser:mxCall.callerId]; + if (onComplete) + { + onComplete(); + } + } + else + { + // For 1:1 call, find the other peer + // Else, the room information will be used to display information about the call + if (!mxCall.isConferenceCall) + { + MXWeakify(self); + [mxCall.room state:^(MXRoomState *roomState) { + MXStrongifyAndReturnIfNil(self); + + MXUser *theMember = nil; + NSArray *members = roomState.members.joinedMembers; + for (MXUser *member in members) + { + if (![member.userId isEqualToString:self->mxCall.callerId]) + { + theMember = member; + break; + } + } + + self.peer = theMember; + if (onComplete) + { + onComplete(); + } + }]; + } + else + { + self.peer = nil; + if (onComplete) + { + onComplete(); + } + } + } +} + +- (BOOL)isBuiltInReceiverAudioOuput +{ +#if TARGET_IPHONE_SIMULATOR + return YES; +#endif + BOOL isBuiltInReceiverUsed = NO; + + // Check whether the audio output is the built-in receiver + AVAudioSessionRouteDescription *audioRoute = [[AVAudioSession sharedInstance] currentRoute]; + if (audioRoute.outputs.count) + { + // TODO: handle the case where multiple outputs are returned + AVAudioSessionPortDescription *audioOutputs = audioRoute.outputs.firstObject; + isBuiltInReceiverUsed = ([audioOutputs.portType isEqualToString:AVAudioSessionPortBuiltInReceiver]); + } + + return isBuiltInReceiverUsed; +} + +- (NSString *)soundNameForCallEnding +{ + if (mxCall.endReason == MXCallEndReasonUnknown) + return nil; + + if (mxCall.isEstablished) + return @"callend"; + + if (mxCall.endReason == MXCallEndReasonBusy || (!mxCall.isIncoming && mxCall.endReason == MXCallEndReasonMissed)) + return @"busy"; + + return nil; +} + +- (void)handleAudioSessionActivationNotification +{ + // It's only relevant for outgoing calls which aren't in connected state + if (self.mxCall.state >= MXCallStateCreateOffer && self.mxCall.state != MXCallStateConnected && self.mxCall.state != MXCallStateEnded) + { + self.isRinging = YES; + } +} + +#pragma mark - UI methods + +- (void)configureSpeakerButton +{ + switch (mxCall.audioOutputRouter.currentRoute.routeType) + { + case MXiOSAudioOutputRouteTypeBuiltIn: + self.speakerButton.selected = NO; + break; + case MXiOSAudioOutputRouteTypeLoudSpeakers: + case MXiOSAudioOutputRouteTypeExternalWired: + case MXiOSAudioOutputRouteTypeExternalBluetooth: + case MXiOSAudioOutputRouteTypeExternalCar: + self.speakerButton.selected = YES; + break; + } +} + +- (void)configureIncomingCallViewIfRequiredWith:(MXCall *)call +{ + if (call.isIncoming && !self.incomingCallView) + { + UIView *incomingCallView = [self createIncomingCallView]; + if (incomingCallView) + { + self.incomingCallView = incomingCallView; + [self.view addSubview:incomingCallView]; + + incomingCallView.translatesAutoresizingMaskIntoConstraints = NO; + [incomingCallView.topAnchor constraintEqualToAnchor:self.view.topAnchor constant:0].active = YES; + [incomingCallView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:0].active = YES; + [incomingCallView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor constant:0].active = YES; + [incomingCallView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:0].active = YES; + } + } +} + +- (void)updateLocalPreviewLayout +{ + // On IOS 8 and later, the screen size is oriented. + CGRect bounds = [[UIScreen mainScreen] bounds]; + BOOL isLandscapeOriented = (bounds.size.width > bounds.size.height); + + CGFloat maxPreviewFrameSize, minPreviewFrameSize; + + if (_localPreviewContainerViewWidthConstraint.constant < _localPreviewContainerViewHeightConstraint.constant) + { + maxPreviewFrameSize = _localPreviewContainerViewHeightConstraint.constant; + minPreviewFrameSize = _localPreviewContainerViewWidthConstraint.constant; + } + else + { + minPreviewFrameSize = _localPreviewContainerViewHeightConstraint.constant; + maxPreviewFrameSize = _localPreviewContainerViewWidthConstraint.constant; + } + + if (isLandscapeOriented) + { + _localPreviewContainerViewHeightConstraint.constant = minPreviewFrameSize; + _localPreviewContainerViewWidthConstraint.constant = maxPreviewFrameSize; + } + else + { + _localPreviewContainerViewHeightConstraint.constant = maxPreviewFrameSize; + _localPreviewContainerViewWidthConstraint.constant = minPreviewFrameSize; + } + + CGPoint previewOrigin = self.localPreviewContainerView.frame.origin; + + if (previewOrigin.x != (bounds.size.width - _localPreviewContainerViewWidthConstraint.constant - kLocalPreviewMargin)) + { + CGFloat posX = (bounds.size.width - _localPreviewContainerViewWidthConstraint.constant - kLocalPreviewMargin); + _localPreviewContainerViewLeadingConstraint.constant = posX; + } + + if (previewOrigin.y != kLocalPreviewMargin) + { + CGFloat posY = (bounds.size.height - _localPreviewContainerViewHeightConstraint.constant - kLocalPreviewMargin); + _localPreviewContainerViewTopConstraint.constant = posY; + } +} + +- (void)showOverlayContainer:(BOOL)isShown +{ + if (mxCall && !mxCall.isVideoCall) isShown = YES; + if (mxCall.state != MXCallStateConnected) isShown = YES; + + if (isShown) + { + overlayContainerView.hidden = NO; + if (mxCall && mxCall.isVideoCall) + { + [hideOverlayTimer invalidate]; + hideOverlayTimer = [NSTimer scheduledTimerWithTimeInterval:5.0 target:self selector:@selector(hideOverlay:) userInfo:nil repeats:NO]; + } + } + else + { + overlayContainerView.hidden = YES; + } +} + +- (void)toggleOverlay +{ + [self showOverlayContainer:overlayContainerView.isHidden]; +} + +- (void)hideOverlay:(NSTimer*)theTimer +{ + [self showOverlayContainer:NO]; + hideOverlayTimer = nil; +} + +- (void)updateTimeStatusLabel +{ + if (mxCall.state == MXCallStateConnected) + { + NSUInteger duration = mxCall.duration / 1000; + NSUInteger secs = duration % 60; + NSUInteger mins = (duration - secs) / 60; + callStatusLabel.text = [NSString stringWithFormat:@"%02tu:%02tu", mins, secs]; + } +} + +- (void)updateProximityAndSleep +{ + BOOL inCall = (mxCall.state == MXCallStateConnected || mxCall.state == MXCallStateRinging || mxCall.state == MXCallStateInviteSent || mxCall.state == MXCallStateConnecting || mxCall.state == MXCallStateCreateOffer || mxCall.state == MXCallStateCreateAnswer); + + if (inCall) + { + BOOL isBuiltInReceiverUsed = self.isBuiltInReceiverAudioOuput; + + // Enable the proximity monitoring when the built in receiver is used as the audio output. + BOOL enableProxMonitoring = isBuiltInReceiverUsed; + [[UIDevice currentDevice] setProximityMonitoringEnabled:enableProxMonitoring]; + + // Disable the idle timer during a video call, or during a voice call which is performed with the built-in receiver. + // Note: if the device is locked, VoIP calling get dropped if an incoming GSM call is received. + BOOL disableIdleTimer = mxCall.isVideoCall || isBuiltInReceiverUsed; + + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + sharedApplication.idleTimerDisabled = disableIdleTimer; + } + } +} + +- (UIView *)createIncomingCallView +{ + return nil; +} + +#pragma mark - UIResponder Touch Events + +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event +{ + UITouch *touch = [touches anyObject]; + CGPoint point = [touch locationInView:self.view]; + if ((!self.localPreviewContainerView.hidden) && CGRectContainsPoint(self.localPreviewContainerView.frame, point)) + { + // Starting to move the local preview view + if (mxCallOnHold) + { + // if there is a call on hold, do not move local preview for now + // TODO: Instead of wholly avoiding mobility of local preview, just avoid the on hold call's corner here + return; + } + isSelectingLocalPreview = YES; + } +} + +- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event +{ + isMovingLocalPreview = NO; + isSelectingLocalPreview = NO; +} + +- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event +{ + if (isMovingLocalPreview) + { + UITouch *touch = [touches anyObject]; + CGPoint point = [touch locationInView:self.view]; + + CGRect bounds = self.view.bounds; + CGFloat midX = bounds.size.width / 2.0; + CGFloat midY = bounds.size.height / 2.0; + + CGFloat posX = (point.x < midX) ? 20.0 : (bounds.size.width - _localPreviewContainerViewWidthConstraint.constant - 20.0); + CGFloat posY = (point.y < midY) ? 20.0 : (bounds.size.height - _localPreviewContainerViewHeightConstraint.constant - 20.0); + + _localPreviewContainerViewLeadingConstraint.constant = posX; + _localPreviewContainerViewTopConstraint.constant = posY; + + [self.view setNeedsUpdateConstraints]; + } + else + { + [self toggleOverlay]; + } + isMovingLocalPreview = NO; + isSelectingLocalPreview = NO; +} + +- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event +{ + UITouch *touch = [touches anyObject]; + CGPoint point = [touch locationInView:self.view]; + + if (isSelectingLocalPreview) + { + isMovingLocalPreview = YES; + self.localPreviewContainerView.center = point; + } +} + +#pragma mark - UIDeviceOrientationDidChangeNotification + +- (void)deviceOrientationDidChange +{ + [self applyDeviceOrientation:NO]; + + [self showOverlayContainer:YES]; +} + +- (void)applyDeviceOrientation:(BOOL)forcePortrait +{ + if (mxCall) + { + UIDeviceOrientation deviceOrientation = [[UIDevice currentDevice] orientation]; + + // Set the camera orientation according to the orientation supported by the app + if (UIDeviceOrientationPortrait == deviceOrientation || UIDeviceOrientationLandscapeLeft == deviceOrientation || UIDeviceOrientationLandscapeRight == deviceOrientation) + { + mxCall.selfOrientation = deviceOrientation; + [self updateLocalPreviewLayout]; + } + else if (forcePortrait) + { + mxCall.selfOrientation = UIDeviceOrientationPortrait; + [self updateLocalPreviewLayout]; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.xib new file mode 100644 index 000000000..52d4fc0ed --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.xib @@ -0,0 +1,423 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.h new file mode 100644 index 000000000..417f586a7 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.h @@ -0,0 +1,90 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKTableViewController.h" + +#import "MXKContact.h" + +@class MXKContactDetailsViewController; + +/** + `MXKContactDetailsViewController` delegate. + */ +@protocol MXKContactDetailsViewControllerDelegate + +/** + Tells the delegate that the user wants to start chat with the contact by using the selected matrix id. + + @param contactDetailsViewController the `MXKContactDetailsViewController` instance. + @param matrixId the selected matrix id of the contact. + @param completion the block to execute at the end of the operation (independently if it succeeded or not). + */ +- (void)contactDetailsViewController:(MXKContactDetailsViewController *)contactDetailsViewController startChatWithMatrixId:(NSString*)matrixId completion:(void (^)(void))completion; + +@end + +@interface MXKContactDetailsViewController : MXKTableViewController + +@property (weak, nonatomic) IBOutlet UIButton *contactThumbnail; +@property (weak, nonatomic) IBOutlet UITextView *contactDisplayName; + +/** + The default account picture displayed when no picture is defined. + */ +@property (nonatomic) UIImage *picturePlaceholder; + +/** + The displayed contact + */ +@property (strong, nonatomic) MXKContact* contact; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKContactDetailsViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `contactDetailsViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKContactDetailsViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKContactDetailsViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)contactDetailsViewController; + +/** + The contact's thumbnail is displayed inside a button. The following action is registered on + `UIControlEventTouchUpInside` event of this button. + */ +- (IBAction)onContactThumbnailPressed:(id)sender; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.m new file mode 100644 index 000000000..6f181b059 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.m @@ -0,0 +1,207 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKContactDetailsViewController.h" + +#import "MXKTableViewCellWithLabelAndButton.h" + +#import "NSBundle+MatrixKit.h" +#import "MXKSwiftHeader.h" + +@interface MXKContactDetailsViewController () +{ + NSArray* matrixIDs; +} + +@end + +@implementation MXKContactDetailsViewController + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKContactDetailsViewController class]) + bundle:[NSBundle bundleForClass:[MXKContactDetailsViewController class]]]; +} + ++ (instancetype)contactDetailsViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKContactDetailsViewController class]) + bundle:[NSBundle bundleForClass:[MXKContactDetailsViewController class]]]; +} + +- (void)finalizeInit +{ + [super finalizeInit]; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!_contactThumbnail) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + [self updatePictureButton:self.picturePlaceholder]; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onThumbnailUpdate:) name:kMXKContactThumbnailUpdateNotification object:nil]; + + // Force refresh + self.contact = _contact; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (void)destroy +{ + matrixIDs = nil; + + self.delegate = nil; + + [super destroy]; +} + +#pragma mark - + +- (void)setContact:(MXKContact *)contact +{ + _contact = contact; + + self.contactDisplayName.text = _contact.displayName; + + // set the thumbnail info + [self.contactThumbnail.imageView setContentMode: UIViewContentModeScaleAspectFill]; + [self.contactThumbnail.imageView setClipsToBounds:YES]; + + if (_contact.thumbnail) + { + [self updatePictureButton:_contact.thumbnail]; + } + else + { + [self updatePictureButton:self.picturePlaceholder]; + } +} + +- (UIImage*)picturePlaceholder +{ + return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"]; +} + +- (IBAction)onContactThumbnailPressed:(id)sender +{ + // Do nothing by default +} + +#pragma mark - UITableView datasource + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + return 1; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + matrixIDs = _contact.matrixIdentifiers; + return matrixIDs.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + NSInteger row = indexPath.row; + + MXKTableViewCellWithLabelAndButton *cell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithLabelAndButton defaultReuseIdentifier]]; + if (!cell) + { + cell = [[MXKTableViewCellWithLabelAndButton alloc] init]; + } + + if (row < matrixIDs.count) + { + cell.mxkLabel.text = [matrixIDs objectAtIndex:row]; + } + else + { + // should never happen + cell.mxkLabel.text = @""; + } + + [cell.mxkButton setTitle:[MatrixKitL10n startChat] forState:UIControlStateNormal]; + [cell.mxkButton setTitle:[MatrixKitL10n startChat] forState:UIControlStateHighlighted]; + cell.mxkButton.tag = row; + [cell.mxkButton addTarget:self action:@selector(startChat:) forControlEvents:UIControlEventTouchUpInside]; + + return cell; +} + +#pragma mark - Internals + +- (void)updatePictureButton:(UIImage*)image +{ + [self.contactThumbnail setImage:image forState:UIControlStateNormal]; + [self.contactThumbnail setImage:image forState:UIControlStateHighlighted]; + [self.contactThumbnail setImage:image forState:UIControlStateDisabled]; +} + +- (void)startChat:(UIButton*)sender +{ + if (self.delegate && sender.tag < matrixIDs.count) + { + sender.enabled = NO; + + [self.delegate contactDetailsViewController:self startChatWithMatrixId:[matrixIDs objectAtIndex:sender.tag] completion:^{ + + sender.enabled = YES; + + }]; + } +} + +- (void)onThumbnailUpdate:(NSNotification *)notif +{ + // sanity check + if ([notif.object isKindOfClass:[NSString class]]) + { + NSString* contactID = notif.object; + + if ([contactID isEqualToString:self.contact.contactID]) + { + if (_contact.thumbnail) + { + [self updatePictureButton:_contact.thumbnail]; + } + else + { + [self updatePictureButton:self.picturePlaceholder]; + } + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.xib new file mode 100644 index 000000000..d202e2856 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKContactDetailsViewController.xib @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.h new file mode 100644 index 000000000..e516aa15d --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.h @@ -0,0 +1,122 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKTableViewController.h" + +#import "MXKContactManager.h" +#import "MXKContact.h" +#import "MXKContactTableCell.h" + +@class MXKContactListViewController; + +/** + `MXKContactListViewController` delegate. + */ +@protocol MXKContactListViewControllerDelegate + +/** + Tells the delegate that the user selected a contact. + + @param contactListViewController the `MXKContactListViewController` instance. + @param contactId the id of the selected contact. + */ +- (void)contactListViewController:(MXKContactListViewController *)contactListViewController didSelectContact:(NSString*)contactId; + +/** + Tells the delegate that the user tapped a contact thumbnail. + + @param contactListViewController the `MXKContactListViewController` instance. + @param contactId the id of the tapped contact. + */ +- (void)contactListViewController:(MXKContactListViewController *)contactListViewController didTapContactThumbnail:(NSString*)contactId; + +@end + +/** + 'MXKContactListViewController' instance displays constact list. + This view controller support multi sessions by collecting all matrix users (only one occurrence is kept by user). + */ +@interface MXKContactListViewController : MXKTableViewController + +/** + The segmented control used to handle separatly matrix users and local contacts. + User's actions are handled by [MXKContactListViewController onSegmentValueChange:]. + */ +@property (weak, nonatomic) IBOutlet UISegmentedControl* contactsControls; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +/** + Enable the search option by adding a navigation item in the navigation bar (YES by default). + Set NO this property to disable this option and hide the related bar button. + */ +@property (nonatomic) BOOL enableBarButtonSearch; + +/** + Tell whether an action is already in progress. + */ +@property (nonatomic, readonly) BOOL hasPendingAction; + +/** + The class used in creating new contact table cells. + Only MXKContactTableCell classes or sub-classes are accepted. + */ +@property (nonatomic) Class contactTableViewCellClass; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKContactListViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `contactListViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKContactListViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKContactListViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)contactListViewController; + +/** + The action registered on 'value changed' event of the 'UISegmentedControl' contactControls. + */ +- (IBAction)onSegmentValueChange:(id)sender; + +/** + Add a mask in overlay to prevent a new contact selection (used when an action is on progress). + */ +- (void)addPendingActionMask; + +/** + Remove the potential overlay mask + */ +- (void)removePendingActionMask; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.m new file mode 100644 index 000000000..6bcb30b42 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.m @@ -0,0 +1,663 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKContactListViewController.h" + +#import "MXKSectionedContacts.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@interface MXKContactListViewController () +{ + // YES -> only matrix users + // NO -> display local contacts + BOOL displayMatrixUsers; + + // screenshot of the local contacts + NSArray* localContactsArray; + MXKSectionedContacts* sectionedLocalContacts; + + // screenshot of the matrix users + NSArray* matrixContactsArray; + MXKSectionedContacts* sectionedMatrixContacts; + + // Search + UIBarButtonItem *searchButton; + UISearchBar *contactsSearchBar; + NSMutableArray *filteredContacts; + MXKSectionedContacts* sectionedFilteredContacts; + BOOL searchBarShouldEndEditing; + BOOL ignoreSearchRequest; + NSString* latestSearchedPattern; + + NSArray* collationTitles; + + // mask view while processing a request + UIActivityIndicatorView * pendingMaskSpinnerView; +} + +@end + +@implementation MXKContactListViewController + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKContactListViewController class]) + bundle:[NSBundle bundleForClass:[MXKContactListViewController class]]]; +} + ++ (instancetype)contactListViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKContactListViewController class]) + bundle:[NSBundle bundleForClass:[MXKContactListViewController class]]]; +} + +- (void)finalizeInit +{ + [super finalizeInit]; + + _enableBarButtonSearch = YES; + + // get the system collation titles + collationTitles = [[UILocalizedIndexedCollation currentCollation] sectionTitles]; +} + +- (void)dealloc +{ + searchButton = nil; +} + +- (void)destroy +{ + [self removePendingActionMask]; + + [super destroy]; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!_contactsControls) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + // global init + displayMatrixUsers = (0 == self.contactsControls.selectedSegmentIndex); + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onContactsRefresh:) name:kMXKContactManagerDidUpdateMatrixContactsNotification object:nil]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onContactsRefresh:) name:kMXKContactManagerDidUpdateLocalContactsNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onContactsRefresh:) name:kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification object:nil]; + + if (!_contactTableViewCellClass) + { + // Set default table view cell class + self.contactTableViewCellClass = [MXKContactTableCell class]; + } + + // Localize string + [_contactsControls setTitle:[MatrixKitL10n contactMxUsers] forSegmentAtIndex:0]; + [_contactsControls setTitle:[MatrixKitL10n contactLocalContacts] forSegmentAtIndex:1]; + + // Apply search option in navigation bar + self.enableBarButtonSearch = _enableBarButtonSearch; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + // Restore search mechanism (if enabled) + ignoreSearchRequest = NO; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + // The user may still press search button whereas the view disappears + ignoreSearchRequest = YES; + + // Leave potential search session + if (contactsSearchBar) + { + [self searchBarCancelButtonClicked:contactsSearchBar]; + } +} + +- (void)scrollToTop +{ + // stop any scrolling effect + [UIView setAnimationsEnabled:NO]; + // before scrolling to the tableview top + self.tableView.contentOffset = CGPointMake(-self.tableView.adjustedContentInset.left, -self.tableView.adjustedContentInset.top); + [UIView setAnimationsEnabled:YES]; +} + +#pragma mark - + +-(void)setContactTableViewCellClass:(Class)contactTableViewCellClass +{ + // Sanity check: accept only MXKContactTableCell classes or sub-classes + NSParameterAssert([contactTableViewCellClass isSubclassOfClass:MXKContactTableCell.class]); + + _contactTableViewCellClass = contactTableViewCellClass; + [self.tableView registerClass:contactTableViewCellClass forCellReuseIdentifier:[contactTableViewCellClass defaultReuseIdentifier]]; +} + +- (void)setEnableBarButtonSearch:(BOOL)enableBarButtonSearch +{ + _enableBarButtonSearch = enableBarButtonSearch; + + if (enableBarButtonSearch) + { + if (!searchButton) + { + searchButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemSearch target:self action:@selector(search:)]; + } + + // Add it in right bar items + NSArray *rightBarButtonItems = self.navigationItem.rightBarButtonItems; + self.navigationItem.rightBarButtonItems = rightBarButtonItems ? [rightBarButtonItems arrayByAddingObject:searchButton] : @[searchButton]; + } + else + { + NSMutableArray *rightBarButtonItems = [NSMutableArray arrayWithArray: self.navigationItem.rightBarButtonItems]; + [rightBarButtonItems removeObject:searchButton]; + self.navigationItem.rightBarButtonItems = rightBarButtonItems; + } +} + +#pragma mark - Internals + +- (void)updateSectionedLocalContacts:(BOOL)force +{ + [self stopActivityIndicator]; + + MXKContactManager* sharedManager = [MXKContactManager sharedManager]; + + if (force || !localContactsArray) + { + localContactsArray = sharedManager.localContacts; + sectionedLocalContacts = [sharedManager getSectionedContacts:localContactsArray]; + } +} + +- (void)updateSectionedMatrixContacts:(BOOL)force +{ + [self stopActivityIndicator]; + + MXKContactManager* sharedManager = [MXKContactManager sharedManager]; + + if (force || !matrixContactsArray) + { + matrixContactsArray = sharedManager.matrixContacts; + sectionedMatrixContacts = [sharedManager getSectionedContacts:matrixContactsArray]; + } +} + +- (BOOL)hasPendingAction +{ + return nil != pendingMaskSpinnerView; +} + +- (void)addPendingActionMask +{ + // add a spinner above the tableview to avoid that the user tap on any other button + pendingMaskSpinnerView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; + pendingMaskSpinnerView.backgroundColor = [UIColor colorWithRed:0.5 green:0.5 blue:0.5 alpha:0.5]; + pendingMaskSpinnerView.frame = self.tableView.frame; + pendingMaskSpinnerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleTopMargin; + + // append it + [self.tableView.superview addSubview:pendingMaskSpinnerView]; + + // animate it + [pendingMaskSpinnerView startAnimating]; +} + +- (void)removePendingActionMask +{ + if (pendingMaskSpinnerView) + { + [pendingMaskSpinnerView removeFromSuperview]; + pendingMaskSpinnerView = nil; + [self.tableView reloadData]; + } +} + +#pragma mark - UITableView dataSource + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + NSInteger sectionNb; + + // search in progress + if (contactsSearchBar) + { + sectionNb = sectionedFilteredContacts.sectionedContacts.count; + if (!sectionNb) + { + // Keep at least one section to display the search bar + sectionNb = 1; + } + } + else if (displayMatrixUsers) + { + [self updateSectionedMatrixContacts:NO]; + sectionNb = sectionedMatrixContacts.sectionedContacts.count; + + } + else + { + [self updateSectionedLocalContacts:NO]; + sectionNb = sectionedLocalContacts.sectionedContacts.count; + } + + return sectionNb; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + MXKSectionedContacts* sectionedContacts = contactsSearchBar ? sectionedFilteredContacts : (displayMatrixUsers ? sectionedMatrixContacts : sectionedLocalContacts); + + if (section < sectionedContacts.sectionedContacts.count) + { + return [sectionedContacts.sectionedContacts[section] count]; + } + return 0; +} + +- (NSString *)tableView:(UITableView *)aTableView titleForHeaderInSection:(NSInteger)section +{ + if (contactsSearchBar) + { + // Hide section titles during search session + return nil; + } + + MXKSectionedContacts* sectionedContacts = displayMatrixUsers ? sectionedMatrixContacts : sectionedLocalContacts; + if (section < sectionedContacts.sectionTitles.count) + { + return (NSString*)[sectionedContacts.sectionTitles objectAtIndex:section]; + } + + return nil; +} + +- (NSArray *)sectionIndexTitlesForTableView:(UITableView *)aTableView +{ + // do not display the collation during a search + if (contactsSearchBar) + { + return nil; + } + + return [[UILocalizedIndexedCollation currentCollation] sectionIndexTitles]; +} + +- (NSInteger)tableView:(UITableView *)aTableView sectionForSectionIndexTitle:(NSString *)title atIndex:(NSInteger)index +{ + MXKSectionedContacts* sectionedContacts = displayMatrixUsers ? sectionedMatrixContacts : sectionedLocalContacts; + NSInteger section = [sectionedContacts.sectionTitles indexOfObject:title]; + + // undefined title -> jump to the first valid non empty section + if (NSNotFound == section) + { + NSInteger systemCollationIndex = [collationTitles indexOfObject:title]; + + // find in the system collation + if (NSNotFound != systemCollationIndex) + { + systemCollationIndex--; + + while ((systemCollationIndex >= 0) && (NSNotFound == section)) + { + NSString* systemTitle = [collationTitles objectAtIndex:systemCollationIndex]; + section = [sectionedContacts.sectionTitles indexOfObject:systemTitle]; + systemCollationIndex--; + } + } + } + + return section; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + MXKContactTableCell* cell = [tableView dequeueReusableCellWithIdentifier:[_contactTableViewCellClass defaultReuseIdentifier] forIndexPath:indexPath]; + cell.thumbnailDisplayBoxType = MXKTableViewCellDisplayBoxTypeCircle; + + MXKSectionedContacts* sectionedContacts = contactsSearchBar ? sectionedFilteredContacts : (displayMatrixUsers ? sectionedMatrixContacts : sectionedLocalContacts); + + MXKContact* contact = nil; + + if (indexPath.section < sectionedContacts.sectionedContacts.count) + { + NSArray *thisSection = [sectionedContacts.sectionedContacts objectAtIndex:indexPath.section]; + + if (indexPath.row < thisSection.count) + { + contact = [thisSection objectAtIndex:indexPath.row]; + } + } + + if (contact) + { + cell.contactAccessoryViewType = MXKContactTableCellAccessoryMatrixIcon; + [cell render:contact]; + cell.delegate = self; + } + + return cell; +} + +#pragma mark - UITableView delegate + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + MXKSectionedContacts* sectionedContacts = contactsSearchBar ? sectionedFilteredContacts : (displayMatrixUsers ? sectionedMatrixContacts : sectionedLocalContacts); + + MXKContact* contact = nil; + + if (indexPath.section < sectionedContacts.sectionedContacts.count) + { + NSArray *thisSection = [sectionedContacts.sectionedContacts objectAtIndex:indexPath.section]; + + if (indexPath.row < thisSection.count) + { + contact = [thisSection objectAtIndex:indexPath.row]; + } + } + + return [((Class)_contactTableViewCellClass) heightForCellData:contact withMaximumWidth:tableView.frame.size.width]; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section +{ + // In case of search, the section titles are hidden and the search bar is displayed in first section header. + if (contactsSearchBar) + { + if (section == 0) + { + return contactsSearchBar.frame.size.height; + } + return 0; + } + + // Default section header height + return 22; +} + +- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section +{ + if (contactsSearchBar && section == 0) + { + return contactsSearchBar; + } + return nil; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + + MXKSectionedContacts* sectionedContacts = contactsSearchBar ? sectionedFilteredContacts : (displayMatrixUsers ? sectionedMatrixContacts : sectionedLocalContacts); + + MXKContact* contact = nil; + + if (indexPath.section < sectionedContacts.sectionedContacts.count) + { + NSArray *thisSection = [sectionedContacts.sectionedContacts objectAtIndex:indexPath.section]; + + if (indexPath.row < thisSection.count) + { + contact = [thisSection objectAtIndex:indexPath.row]; + } + } + + if (self.delegate) { + [self.delegate contactListViewController:self didSelectContact:contact.contactID]; + } +} + +- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath*)indexPath +{ + // Release here resources, and restore reusable cells + if ([cell respondsToSelector:@selector(didEndDisplay)]) + { + [(id)cell didEndDisplay]; + } +} + +#pragma mark - Actions + +- (void)onContactsRefresh:(NSNotification *)notif +{ + if ([notif.name isEqualToString:kMXKContactManagerDidUpdateMatrixContactsNotification]) + { + [self updateSectionedMatrixContacts:YES]; + } + else if ([notif.name isEqualToString:kMXKContactManagerDidUpdateLocalContactsNotification]) + { + [self updateSectionedLocalContacts:YES]; + } + else //if ([notif.name isEqualToString:kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification]) + { + // Consider here only global notifications, ignore notifications related to a specific contact. + if (notif.object) + { + return; + } + + [self updateSectionedLocalContacts:YES]; + } + + if (contactsSearchBar) + { + latestSearchedPattern = nil; + [self searchBar:contactsSearchBar textDidChange:contactsSearchBar.text]; + } + else + { + [self.tableView reloadData]; + } +} + +- (IBAction)onSegmentValueChange:(id)sender +{ + if (sender == self.contactsControls) + { + displayMatrixUsers = (0 == self.contactsControls.selectedSegmentIndex); + + // Leave potential search session + if (contactsSearchBar) + { + [self searchBarCancelButtonClicked:contactsSearchBar]; + } + + [self.tableView reloadData]; + } +} + +#pragma mark Search management + +- (void)search:(id)sender +{ + // The user may have pressed search button whereas the view controller was disappearing + if (ignoreSearchRequest) + { + return; + } + + if (!contactsSearchBar) + { + MXKSectionedContacts* sectionedContacts = displayMatrixUsers ? sectionedMatrixContacts : sectionedLocalContacts; + + // Check whether there are data in which search + if (sectionedContacts.sectionedContacts.count > 0) + { + // Create search bar + contactsSearchBar = [[UISearchBar alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, 44)]; + contactsSearchBar.autoresizingMask = UIViewAutoresizingFlexibleWidth; + contactsSearchBar.showsCancelButton = YES; + contactsSearchBar.returnKeyType = UIReturnKeyDone; + contactsSearchBar.delegate = self; + searchBarShouldEndEditing = NO; + + // init the table content + latestSearchedPattern = @""; + filteredContacts = [(displayMatrixUsers ? matrixContactsArray : localContactsArray) mutableCopy]; + sectionedFilteredContacts = [[MXKContactManager sharedManager] getSectionedContacts:filteredContacts]; + + [self.tableView reloadData]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [self->contactsSearchBar becomeFirstResponder]; + }); + } + } + else + { + [self searchBarCancelButtonClicked:contactsSearchBar]; + } +} + +#pragma mark - UISearchBarDelegate + +- (BOOL)searchBarShouldBeginEditing:(UISearchBar *)searchBar +{ + searchBarShouldEndEditing = NO; + return YES; +} + +- (BOOL)searchBarShouldEndEditing:(UISearchBar *)searchBar +{ + return searchBarShouldEndEditing; +} + +- (NSArray*)patternsFromText:(NSString*)text +{ + NSArray* items = [text componentsSeparatedByString:@" "]; + + if (items.count <= 1) + { + return items; + } + + NSMutableArray* patterns = [[NSMutableArray alloc] init]; + + for (NSString* item in items) + { + if (item.length > 0) + { + [patterns addObject:item]; + } + } + + return patterns; +} + +- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText +{ + if ((contactsSearchBar == searchBar) && (![latestSearchedPattern isEqualToString:searchText])) + { + latestSearchedPattern = searchText; + + // contacts + NSArray* contacts = displayMatrixUsers ? matrixContactsArray : localContactsArray; + + // Update filtered list + if (searchText.length && contacts.count) + { + filteredContacts = [[NSMutableArray alloc] init]; + + NSArray* patterns = [self patternsFromText:searchText]; + for(MXKContact* contact in contacts) + { + if ([contact matchedWithPatterns:patterns]) + { + [filteredContacts addObject:contact]; + } + } + } + else + { + filteredContacts = [contacts mutableCopy]; + } + + sectionedFilteredContacts = [[MXKContactManager sharedManager] getSectionedContacts:filteredContacts]; + + // Refresh display + [self.tableView reloadData]; + [self scrollToTop]; + } +} + +- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar +{ + if (contactsSearchBar == searchBar) + { + // "Done" key has been pressed + searchBarShouldEndEditing = YES; + [contactsSearchBar resignFirstResponder]; + } +} + +- (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar +{ + if (contactsSearchBar == searchBar) + { + // Leave search + searchBarShouldEndEditing = YES; + [contactsSearchBar resignFirstResponder]; + [contactsSearchBar removeFromSuperview]; + contactsSearchBar = nil; + filteredContacts = nil; + sectionedFilteredContacts = nil; + latestSearchedPattern = nil; + [self.tableView reloadData]; + [self scrollToTop]; + } +} + +#pragma mark - MXKCellRendering delegate + +- (void)cell:(id)cell didRecognizeAction:(NSString*)actionIdentifier userInfo:(NSDictionary *)userInfo +{ + if ([actionIdentifier isEqualToString:kMXKContactCellTapOnThumbnailView]) + { + if (self.delegate) { + [self.delegate contactListViewController:self didTapContactThumbnail:userInfo[kMXKContactCellContactIdKey]]; + } + } +} + +- (BOOL)cell:(id)cell shouldDoAction:(NSString *)actionIdentifier userInfo:(NSDictionary *)userInfo defaultValue:(BOOL)defaultValue +{ + // No such action yet on contacts + return defaultValue; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.xib new file mode 100644 index 000000000..6d638f106 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKContactListViewController.xib @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.h new file mode 100644 index 000000000..83e92f2ae --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.h @@ -0,0 +1,81 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKTableViewController.h" + +@class MXKCountryPickerViewController; + +/** + `MXKCountryPickerViewController` delegate. + */ +@protocol MXKCountryPickerViewControllerDelegate + +/** + Tells the delegate that the user selected a country. + + @param countryPickerViewController the `MXKCountryPickerViewController` instance. + @param isoCountryCode the ISO 3166-1 country code representation. + */ +- (void)countryPickerViewController:(MXKCountryPickerViewController*)countryPickerViewController didSelectCountry:(NSString*)isoCountryCode; + +@end + +/** + 'MXKCountryPickerViewController' instance displays the list of supported countries. + */ +@interface MXKCountryPickerViewController : MXKTableViewController + +/** +The searchController used to manage search. +*/ +@property (nonatomic, strong) UISearchController *searchController; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKCountryPickerViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `countryPickerViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKCountryPickerViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKCountryPickerViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)countryPickerViewController; + +/** + Show/Hide the international dialing code for each country (NO by default). + */ +@property (nonatomic) BOOL showCountryCallingCode; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.m new file mode 100644 index 000000000..273046244 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.m @@ -0,0 +1,299 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKCountryPickerViewController.h" + +@import libPhoneNumber_iOS; + +#import "NSBundle+MatrixKit.h" +#import "MXKSwiftHeader.h" + + +NSString* const kMXKCountryPickerViewControllerCountryCellId = @"kMXKCountryPickerViewControllerCountryCellId"; + +@interface MXKCountryPickerViewController () +{ + NSMutableDictionary *isoCountryCodesByCountryName; + + NSArray *countryNames; + NSMutableArray *filteredCountryNames; + + NSString *previousSearchPattern; + + NSMutableDictionary *callingCodesByCountryName; +} + +@end + +@implementation MXKCountryPickerViewController + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKCountryPickerViewController class]) + bundle:[NSBundle bundleForClass:[MXKCountryPickerViewController class]]]; +} + ++ (instancetype)countryPickerViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKCountryPickerViewController class]) + bundle:[NSBundle bundleForClass:[MXKCountryPickerViewController class]]]; +} + +- (void)finalizeInit +{ + [super finalizeInit]; + + NSArray *isoCountryCodes = [NSLocale ISOCountryCodes]; + NSMutableArray *countries; + + isoCountryCodesByCountryName = [NSMutableDictionary dictionaryWithCapacity:isoCountryCodes.count]; + countries = [NSMutableArray arrayWithCapacity:isoCountryCodes.count]; + + NSLocale *local = [[NSLocale alloc] initWithLocaleIdentifier:[[[NSBundle mainBundle] preferredLocalizations] objectAtIndex:0]]; + + for (NSString *isoCountryCode in isoCountryCodes) + { + NSString *country = [local displayNameForKey:NSLocaleCountryCode value:isoCountryCode]; + if (country) + { + [countries addObject: country]; + isoCountryCodesByCountryName[country] = isoCountryCode; + } + } + + countryNames = [countries sortedArrayUsingSelector:@selector(localizedCaseInsensitiveCompare:)]; + + previousSearchPattern = nil; + filteredCountryNames = nil; + + _showCountryCallingCode = NO; +} + +- (void)destroy +{ + [super destroy]; + + isoCountryCodesByCountryName = nil; + + countryNames = nil; + filteredCountryNames = nil; + + callingCodesByCountryName = nil; + + previousSearchPattern = nil; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!self.tableView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + self.navigationItem.title = [MatrixKitL10n countryPickerTitle]; + + [self setupSearchController]; +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + + self.navigationItem.hidesSearchBarWhenScrolling = YES; +} + +#pragma mark - + +- (void)setShowCountryCallingCode:(BOOL)showCountryCallingCode +{ + if (_showCountryCallingCode != showCountryCallingCode) + { + _showCountryCallingCode = showCountryCallingCode; + + if (_showCountryCallingCode && !callingCodesByCountryName) + { + callingCodesByCountryName = [NSMutableDictionary dictionary]; + + for (NSString *countryName in countryNames) + { + NSString *isoCountryCode = isoCountryCodesByCountryName[countryName]; + NSNumber *callingCode = [[NBPhoneNumberUtil sharedInstance] getCountryCodeForRegion:isoCountryCode]; + + callingCodesByCountryName[countryName] = callingCode; + } + } + + [self.tableView reloadData]; + } +} + +#pragma mark - Private + +- (void)setupSearchController +{ + UISearchController *searchController = [[UISearchController alloc] + initWithSearchResultsController:nil]; + searchController.dimsBackgroundDuringPresentation = NO; + searchController.hidesNavigationBarDuringPresentation = NO; + searchController.searchResultsUpdater = self; + + self.navigationItem.searchController = searchController; + // Make the search bar visible on first view appearance + self.navigationItem.hidesSearchBarWhenScrolling = NO; + + self.definesPresentationContext = YES; + + self.searchController = searchController; +} + +#pragma mark - UITableView dataSource + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + if (filteredCountryNames) + { + return filteredCountryNames.count; + } + return countryNames.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kMXKCountryPickerViewControllerCountryCellId]; + if (!cell) + { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:kMXKCountryPickerViewControllerCountryCellId]; + } + + NSInteger index = indexPath.row; + NSString *countryName; + + if (filteredCountryNames) + { + if (index < filteredCountryNames.count) + { + countryName = filteredCountryNames[index]; + } + } + else if (index < countryNames.count) + { + countryName = countryNames[index]; + } + + if (countryName) + { + cell.textLabel.text = countryName; + + if (self.showCountryCallingCode) + { + cell.detailTextLabel.text = [NSString stringWithFormat:@"+%@", [callingCodesByCountryName[countryName] stringValue]]; + } + } + + return cell; +} + +#pragma mark - UITableView delegate + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + + if (self.delegate) + { + NSInteger index = indexPath.row; + NSString *countryName; + + if (filteredCountryNames) + { + if (index < filteredCountryNames.count) + { + countryName = filteredCountryNames[index]; + } + } + else if (index < countryNames.count) + { + countryName = countryNames[index]; + } + + if (countryName) + { + NSString *isoCountryCode = isoCountryCodesByCountryName[countryName]; + + [self.delegate countryPickerViewController:self didSelectCountry:isoCountryCode]; + } + } +} + +#pragma mark - UISearchResultsUpdating + +- (void)updateSearchResultsForSearchController:(UISearchController *)searchController +{ + NSString *searchText = searchController.searchBar.text; + + if (searchText.length) + { + searchText = [searchText lowercaseString]; + + if (previousSearchPattern && [searchText hasPrefix:previousSearchPattern]) + { + for (NSUInteger index = 0; index < filteredCountryNames.count;) + { + NSString *countryName = [filteredCountryNames[index] lowercaseString]; + + if ([countryName hasPrefix:searchText] == NO) + { + [filteredCountryNames removeObjectAtIndex:index]; + } + else + { + index++; + } + } + } + else + { + filteredCountryNames = [NSMutableArray array]; + + for (NSUInteger index = 0; index < countryNames.count; index++) + { + NSString *countryName = [countryNames[index] lowercaseString]; + + if ([countryName hasPrefix:searchText]) + { + [filteredCountryNames addObject:countryNames[index]]; + } + } + } + + previousSearchPattern = searchText; + } + else + { + previousSearchPattern = nil; + filteredCountryNames = nil; + } + + [self.tableView reloadData]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.xib new file mode 100644 index 000000000..a9349f0b2 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKCountryPickerViewController.xib @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.h new file mode 100644 index 000000000..020f78549 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.h @@ -0,0 +1,118 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKViewController.h" +#import "MXKSessionGroupsDataSource.h" + +@class MXKGroupListViewController; + +/** + `MXKGroupListViewController` delegate. + */ +@protocol MXKGroupListViewControllerDelegate + +/** + Tells the delegate that the user selected a group. + + @param groupListViewController the `MXKGroupListViewController` instance. + @param group the selected group. + @param mxSession the matrix session in which the group is defined. + */ +- (void)groupListViewController:(MXKGroupListViewController *)groupListViewController didSelectGroup:(MXGroup*)group inMatrixSession:(MXSession*)mxSession; + +@end + + +/** + This view controller displays a group list. + */ +@interface MXKGroupListViewController : MXKViewController +{ +@protected + + /** + The fake top view displayed in case of vertical bounce. + */ + __weak UIView *topview; +} + +@property (weak, nonatomic) IBOutlet UISearchBar *groupsSearchBar; +@property (weak, nonatomic) IBOutlet UITableView *groupsTableView; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *groupsSearchBarTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *groupsSearchBarHeightConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *groupsTableViewBottomConstraint; + +/** + The current data source associated to the view controller. + */ +@property (nonatomic, readonly) MXKSessionGroupsDataSource *dataSource; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +/** + Enable the search option by adding a navigation item in the navigation bar (YES by default). + Set NO this property to disable this option and hide the related bar button. + */ +@property (nonatomic) BOOL enableBarButtonSearch; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKGroupListViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `groupListViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKGroupListViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKGroupListViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)groupListViewController; + +/** + Display the groups described in the provided data source. + + Note: The provided data source will replace the current data source if any. The caller + should dispose properly this data source if it is not used anymore. + + @param listDataSource the data source providing the groups list. + */ +- (void)displayList:(MXKSessionGroupsDataSource*)listDataSource; + +/** + Refresh the groups table display. + */ +- (void)refreshGroupsTable; + +/** + Hide/show the search bar at the top of the groups table view. + */ +- (void)hideSearchBar:(BOOL)hidden; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.m new file mode 100644 index 000000000..e9950fa3f --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.m @@ -0,0 +1,608 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKGroupListViewController.h" + +#import "MXKGroupTableViewCell.h" +#import "MXKTableViewHeaderFooterWithLabel.h" + +@interface MXKGroupListViewController () +{ + /** + The data source providing UITableViewCells + */ + MXKSessionGroupsDataSource *dataSource; + + /** + Search handling + */ + UIBarButtonItem *searchButton; + BOOL ignoreSearchRequest; + + /** + The reconnection animated view. + */ + UIView* reconnectingView; + + /** + The current table view header if any. + */ + UIView* tableViewHeaderView; + + /** + The latest server sync date + */ + NSDate* latestServerSync; + + /** + The restart the event connnection + */ + BOOL restartConnection; +} + +@end + +@implementation MXKGroupListViewController +@synthesize dataSource; + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKGroupListViewController class]) + bundle:[NSBundle bundleForClass:[MXKGroupListViewController class]]]; +} + ++ (instancetype)groupListViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKGroupListViewController class]) + bundle:[NSBundle bundleForClass:[MXKGroupListViewController class]]]; +} + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + _enableBarButtonSearch = YES; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!_groupsTableView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + // Adjust search bar Top constraint to take into account potential navBar. + if (_groupsSearchBarTopConstraint) + { + [NSLayoutConstraint deactivateConstraints:@[_groupsSearchBarTopConstraint]]; + + _groupsSearchBarTopConstraint = [NSLayoutConstraint constraintWithItem:self.topLayoutGuide + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self.groupsSearchBar + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:0.0f]; + + [NSLayoutConstraint activateConstraints:@[_groupsSearchBarTopConstraint]]; + } + + // Adjust table view Bottom constraint to take into account tabBar. + if (_groupsTableViewBottomConstraint) + { + [NSLayoutConstraint deactivateConstraints:@[_groupsTableViewBottomConstraint]]; + + _groupsTableViewBottomConstraint = [NSLayoutConstraint constraintWithItem:self.bottomLayoutGuide + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.groupsTableView + attribute:NSLayoutAttributeBottom + multiplier:1.0f + constant:0.0f]; + + [NSLayoutConstraint activateConstraints:@[_groupsTableViewBottomConstraint]]; + } + + // Hide search bar by default + [self hideSearchBar:YES]; + + // Apply search option in navigation bar + self.enableBarButtonSearch = _enableBarButtonSearch; + + // Add an accessory view to the search bar in order to retrieve keyboard view. + self.groupsSearchBar.inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + + // Finalize table view configuration + // Note: self-sizing cells and self-sizing section headers are enabled from the nib file. + self.groupsTableView.delegate = self; + self.groupsTableView.dataSource = dataSource; // Note: dataSource may be nil here + self.groupsTableView.estimatedSectionHeaderHeight = 30; // The value set in the nib seems not available for iOS version < 10. + + // Set up classes to use for the cells and the section headers. + [self.groupsTableView registerNib:MXKGroupTableViewCell.nib forCellReuseIdentifier:MXKGroupTableViewCell.defaultReuseIdentifier]; + [self.groupsTableView registerNib:MXKTableViewHeaderFooterWithLabel.nib forHeaderFooterViewReuseIdentifier:MXKTableViewHeaderFooterWithLabel.defaultReuseIdentifier]; + + // Add a top view which will be displayed in case of vertical bounce. + CGFloat height = self.groupsTableView.frame.size.height; + UIView *topview = [[UIView alloc] initWithFrame:CGRectMake(0,-height,self.groupsTableView.frame.size.width,height)]; + topview.autoresizingMask = UIViewAutoresizingFlexibleWidth; + topview.backgroundColor = [UIColor groupTableViewBackgroundColor]; + [self.groupsTableView addSubview:topview]; + self->topview = topview; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + // Restore search mechanism (if enabled) + ignoreSearchRequest = NO; + + // Observe the server sync + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onSyncNotification) name:kMXSessionDidSyncNotification object:nil]; + + // Do a full reload + [self refreshGroupsTable]; + + // Refresh all groups summary + [self.dataSource refreshGroupsSummary:nil]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + // The user may still press search button whereas the view disappears + ignoreSearchRequest = YES; + + // Leave potential search session + if (!self.groupsSearchBar.isHidden) + { + [self searchBarCancelButtonClicked:self.groupsSearchBar]; + } + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidSyncNotification object:nil]; + + [self removeReconnectingView]; +} + +- (void)dealloc +{ + self.groupsSearchBar.inputAccessoryView = nil; + + searchButton = nil; +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; + + // Dispose of any resources that can be recreated. +} + +#pragma mark - Override MXKViewController + +- (void)onKeyboardShowAnimationComplete +{ + // Report the keyboard view in order to track keyboard frame changes + self.keyboardView = _groupsSearchBar.inputAccessoryView.superview; +} + +- (void)setKeyboardHeight:(CGFloat)keyboardHeight +{ + // Deduce the bottom constraint for the table view (Don't forget the potential tabBar) + CGFloat tableViewBottomConst = keyboardHeight - self.bottomLayoutGuide.length; + // Check whether the keyboard is over the tabBar + if (tableViewBottomConst < 0) + { + tableViewBottomConst = 0; + } + + // Update constraints + _groupsTableViewBottomConstraint.constant = tableViewBottomConst; + + // Force layout immediately to take into account new constraint + [self.view layoutIfNeeded]; +} + +- (void)destroy +{ + self.groupsTableView.dataSource = nil; + self.groupsTableView.delegate = nil; + self.groupsTableView = nil; + + dataSource.delegate = nil; + dataSource = nil; + + _delegate = nil; + + [topview removeFromSuperview]; + topview = nil; + + [super destroy]; +} + +#pragma mark - + +- (void)setEnableBarButtonSearch:(BOOL)enableBarButtonSearch +{ + _enableBarButtonSearch = enableBarButtonSearch; + + if (enableBarButtonSearch) + { + if (!searchButton) + { + searchButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemSearch target:self action:@selector(search:)]; + } + + // Add it in right bar items + NSArray *rightBarButtonItems = self.navigationItem.rightBarButtonItems; + self.navigationItem.rightBarButtonItems = rightBarButtonItems ? [rightBarButtonItems arrayByAddingObject:searchButton] : @[searchButton]; + } + else + { + NSMutableArray *rightBarButtonItems = [NSMutableArray arrayWithArray: self.navigationItem.rightBarButtonItems]; + [rightBarButtonItems removeObject:searchButton]; + self.navigationItem.rightBarButtonItems = rightBarButtonItems; + } +} + +- (void)displayList:(MXKSessionGroupsDataSource *)listDataSource +{ + // Cancel registration on existing dataSource if any + if (dataSource) + { + dataSource.delegate = nil; + + // Remove associated matrix sessions + NSArray *mxSessions = self.mxSessions; + for (MXSession *mxSession in mxSessions) + { + [self removeMatrixSession:mxSession]; + } + } + + dataSource = listDataSource; + dataSource.delegate = self; + + // Report the matrix session at view controller level to update UI according to session state + [self addMatrixSession:listDataSource.mxSession]; + + if (self.groupsTableView) + { + // Set up table data source + self.groupsTableView.dataSource = dataSource; + } +} + +- (void)refreshGroupsTable +{ + // For now, do a simple full reload + [self.groupsTableView reloadData]; +} + +- (void)hideSearchBar:(BOOL)hidden +{ + self.groupsSearchBar.hidden = hidden; + self.groupsSearchBarHeightConstraint.constant = hidden ? 0 : 44; + [self.view setNeedsUpdateConstraints]; +} + +#pragma mark - Action + +- (IBAction)search:(id)sender +{ + // The user may have pressed search button whereas the view controller was disappearing + if (ignoreSearchRequest) + { + return; + } + + if (self.groupsSearchBar.isHidden) + { + // Check whether there are data in which search + if ([self.dataSource numberOfSectionsInTableView:self.groupsTableView]) + { + [self hideSearchBar:NO]; + + // Create search bar + [self.groupsSearchBar becomeFirstResponder]; + } + } + else + { + [self searchBarCancelButtonClicked: self.groupsSearchBar]; + } +} + +#pragma mark - MXKDataSourceDelegate + +- (Class)cellViewClassForCellData:(MXKCellData*)cellData +{ + // Return the default group table view cell + return MXKGroupTableViewCell.class; +} + +- (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData +{ + // Return the default group table view cell + return MXKGroupTableViewCell.defaultReuseIdentifier; +} + +- (void)dataSource:(MXKDataSource *)dataSource didCellChange:(id)changes +{ + // For now, do a simple full reload + [self refreshGroupsTable]; +} + +- (void)dataSource:(MXKDataSource *)dataSource didAddMatrixSession:(MXSession *)mxSession +{ + [self addMatrixSession:mxSession]; +} + +- (void)dataSource:(MXKDataSource *)dataSource didRemoveMatrixSession:(MXSession *)mxSession +{ + [self removeMatrixSession:mxSession]; +} + +#pragma mark - UITableView delegate + +- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + return tableView.estimatedRowHeight; +} + +- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForHeaderInSection:(NSInteger)section +{ + if (tableView.numberOfSections > 1) + { + return tableView.estimatedSectionHeaderHeight; + } + + return 0; +} + +- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath +{ + // Refresh here the estimated row height + tableView.estimatedRowHeight = cell.frame.size.height; +} + +- (void)tableView:(UITableView *)tableView willDisplayHeaderView:(nonnull UIView *)view forSection:(NSInteger)section +{ + // Refresh here the estimated header height + tableView.estimatedSectionHeaderHeight = view.frame.size.height; +} + +- (nullable UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section +{ + MXKTableViewHeaderFooterWithLabel *sectionHeader; + + if (tableView.numberOfSections > 1) + { + sectionHeader = [tableView dequeueReusableHeaderFooterViewWithIdentifier:MXKTableViewHeaderFooterWithLabel.defaultReuseIdentifier]; + + sectionHeader.mxkLabel.text = [self.dataSource tableView:tableView titleForHeaderInSection:section]; + } + + return sectionHeader; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (_delegate) + { + UITableViewCell *selectedCell = [tableView cellForRowAtIndexPath:indexPath]; + + if ([selectedCell conformsToProtocol:@protocol(MXKCellRendering)]) + { + id cell = (id)selectedCell; + + if ([cell respondsToSelector:@selector(renderedCellData)]) + { + MXKCellData *cellData = cell.renderedCellData; + if ([cellData conformsToProtocol:@protocol(MXKGroupCellDataStoring)]) + { + id groupCellData = (id)cellData; + [_delegate groupListViewController:self didSelectGroup:groupCellData.group inMatrixSession:self.mainSession]; + } + } + } + } + + // Hide the keyboard when user select a room + // do not hide the searchBar until the view controller disappear + // on tablets / iphone 6+, the user could expect to search again while looking at a room + [self.groupsSearchBar resignFirstResponder]; +} + +- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath*)indexPath +{ + // Release here resources, and restore reusable cells + if ([cell respondsToSelector:@selector(didEndDisplay)]) + { + [(id)cell didEndDisplay]; + } +} + +- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset +{ + // Detect vertical bounce at the top of the tableview to trigger reconnection. + if (scrollView == _groupsTableView) + { + [self detectPullToKick:scrollView]; + } +} + +- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView +{ + if (scrollView == _groupsTableView) + { + [self managePullToKick:scrollView]; + } +} + +- (void)scrollViewDidScroll:(UIScrollView *)scrollView +{ + if (scrollView == _groupsTableView) + { + if (scrollView.contentOffset.y + scrollView.adjustedContentInset.top == 0) + { + [self managePullToKick:scrollView]; + } + } +} + +#pragma mark - UISearchBarDelegate + +- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText +{ + // Apply filter + if (searchText.length) + { + [self.dataSource searchWithPatterns:@[searchText]]; + } + else + { + [self.dataSource searchWithPatterns:nil]; + } +} + +- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar +{ + // "Done" key has been pressed + [searchBar resignFirstResponder]; +} + +- (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar +{ + // Leave search + [searchBar resignFirstResponder]; + + [self hideSearchBar:YES]; + + self.groupsSearchBar.text = nil; + + // Refresh display + [self.dataSource searchWithPatterns:nil]; +} + +#pragma mark - resync management + +- (void)onSyncNotification +{ + latestServerSync = [NSDate date]; + + MXWeakify(self); + + // Refresh all groups summary + [self.dataSource refreshGroupsSummary:^{ + + MXStrongifyAndReturnIfNil(self); + + [self removeReconnectingView]; + }]; +} + +- (BOOL)canReconnect +{ + // avoid restarting connection if some data has been received within 1 second (1000 : latestServerSync is null) + NSTimeInterval interval = latestServerSync ? [[NSDate date] timeIntervalSinceDate:latestServerSync] : 1000; + return (interval > 1) && [self.mainSession reconnect]; +} + +- (void)addReconnectingView +{ + if (!reconnectingView) + { + UIActivityIndicatorView* spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; + spinner.transform = CGAffineTransformMakeScale(0.75f, 0.75f); + CGRect frame = spinner.frame; + frame.size.height = 80; // 80 * 0.75 = 60 + spinner.bounds = frame; + spinner.color = [UIColor darkGrayColor]; + spinner.hidesWhenStopped = NO; + spinner.backgroundColor = _groupsTableView.backgroundColor; + [spinner startAnimating]; + + // no need to manage constraints here, IOS defines them. + tableViewHeaderView = _groupsTableView.tableHeaderView; + _groupsTableView.tableHeaderView = reconnectingView = spinner; + } +} + +- (void)removeReconnectingView +{ + if (reconnectingView && !restartConnection) + { + _groupsTableView.tableHeaderView = tableViewHeaderView; + reconnectingView = nil; + } +} + +/** + Detect if the current connection must be restarted. + The spinner is displayed until the overscroll ends (and scrollViewDidEndDecelerating is called). + */ +- (void)detectPullToKick:(UIScrollView *)scrollView +{ + if (!reconnectingView) + { + // detect if the user scrolls over the tableview top + restartConnection = (scrollView.contentOffset.y + scrollView.adjustedContentInset.top < -128); + + if (restartConnection) + { + // wait that list decelerate to display / hide it + [self addReconnectingView]; + } + } +} + +/** + Restarts the current connection if it is required. + The 0.3s delay is added to avoid flickering if the connection does not require to be restarted. + */ +- (void)managePullToKick:(UIScrollView *)scrollView +{ + // the current connection must be restarted + if (restartConnection) + { + // display at least 0.3s the spinner to show to the user that something is pending + // else the UI is flickering + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.3 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ + self->restartConnection = NO; + + if (![self canReconnect]) + { + // if the event stream has not been restarted + // hide the spinner + [self removeReconnectingView]; + } + // else wait that onSyncNotification is called. + }); + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.xib new file mode 100644 index 000000000..fad906339 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKGroupListViewController.xib @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.h new file mode 100644 index 000000000..f30cd3d79 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.h @@ -0,0 +1,104 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewController.h" + +@class MXKLanguagePickerViewController; + + /** + `MXKLanguagePickerViewController` delegate. + */ + @protocol MXKLanguagePickerViewControllerDelegate + + /** + Tells the delegate that the user has selected a language. + + @param languagePickerViewController the `MXKLanguagePickerViewController` instance. + @param language the ISO language code. nil means use the language chosen by the OS. + */ + - (void)languagePickerViewController:(MXKLanguagePickerViewController*)languagePickerViewController didSelectLangugage:(NSString*)language; + + @end + +/** + 'MXKLanguagePickerViewController' instance displays the list of languages. + For the moment, it displays only languages available in the application bundle. + */ +@interface MXKLanguagePickerViewController : MXKTableViewController + +/** +The searchController used to manage search. +*/ +@property (nonatomic, strong) UISearchController *searchController; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +/** + The language marked in the list. + @"" by default. + */ +@property (nonatomic) NSString *selectedLanguage; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKLanguagePickerViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `listViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKLanguagePickerViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKLanguagePickerViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)languagePickerViewController; + +/** + Get the description string of a language defined by its ISO country code. + The description is localised in this language. + + @param language the ISO country code of the language (ex: "en"). + @return its description (ex: "English"). + */ ++ (NSString *)languageDescription:(NSString*)language; + +/** + Get the localised description string of a language defined by its ISO country code. + + @param language the ISO country code of the language (ex: "en"). + @return its localised description (ex: "Anglais" on a device running in French). + */ ++ (NSString *)languageLocalisedDescription:(NSString *)language; + +/** + Get the ISO country code of the language selected by the OS according to + the device language and languages available in the app bundle. + */ ++ (NSString *)defaultLanguage; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.m new file mode 100644 index 000000000..92d1a065c --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.m @@ -0,0 +1,308 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKLanguagePickerViewController.h" + +@import libPhoneNumber_iOS; + +#import "NSBundle+MatrixKit.h" +#import "MXKSwiftHeader.h" + +NSString* const kMXKLanguagePickerViewControllerCellId = @"kMXKLanguagePickerViewControllerCellId"; + +NSString* const kMXKLanguagePickerCellDataKeyText = @"text"; +NSString* const kMXKLanguagePickerCellDataKeyDetailText = @"detailText"; +NSString* const kMXKLanguagePickerCellDataKeyLanguage = @"language"; + +@interface MXKLanguagePickerViewController () +{ + NSMutableArray *cellDataArray; + NSMutableArray *filteredCellDataArray; + + NSString *previousSearchPattern; +} + +@end + +@implementation MXKLanguagePickerViewController + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKLanguagePickerViewController class]) + bundle:[NSBundle bundleForClass:[MXKLanguagePickerViewController class]]]; +} + ++ (instancetype)languagePickerViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKLanguagePickerViewController class]) + bundle:[NSBundle bundleForClass:[MXKLanguagePickerViewController class]]]; +} + ++ (NSString *)languageDescription:(NSString *)language +{ + NSLocale *locale = [[NSLocale alloc] initWithLocaleIdentifier:language]; + + return [locale displayNameForKey:NSLocaleIdentifier value:language]; +} + ++ (NSString *)languageLocalisedDescription:(NSString *)language +{ + NSLocale *locale = [[NSLocale alloc] initWithLocaleIdentifier:[NSBundle mainBundle].preferredLocalizations.firstObject]; + + return [locale displayNameForKey:NSLocaleIdentifier value:language]; +} + ++ (NSString *)defaultLanguage +{ + return [NSBundle mainBundle].preferredLocalizations.firstObject; +} + +- (void)finalizeInit +{ + [super finalizeInit]; + + cellDataArray = [NSMutableArray array]; + filteredCellDataArray = nil; + + previousSearchPattern = nil; + + // Populate cellDataArray + // Start by the default language chosen by the OS + NSString *defaultLanguage = [MXKLanguagePickerViewController defaultLanguage]; + NSString *languageDescription = [MatrixKitL10n languagePickerDefaultLanguage:[MXKLanguagePickerViewController languageDescription:defaultLanguage]]; + + [cellDataArray addObject:@{ + kMXKLanguagePickerCellDataKeyText:languageDescription + }]; + + // Then, add languages available in the app bundle + NSArray *localizations = [[NSBundle mainBundle] localizations]; + for (NSString *language in localizations) + { + // Do not duplicate the default lang + if (![language isEqualToString:defaultLanguage]) + { + languageDescription = [MXKLanguagePickerViewController languageDescription:language]; + NSString *localisedLanguageDescription = [MXKLanguagePickerViewController languageLocalisedDescription:language]; + + // Capitalise the description in the language locale + NSLocale *locale = [[NSLocale alloc] initWithLocaleIdentifier:language]; + languageDescription = [languageDescription capitalizedStringWithLocale:locale]; + localisedLanguageDescription = [localisedLanguageDescription capitalizedStringWithLocale:locale]; + + if (languageDescription) + { + [cellDataArray addObject:@{ + kMXKLanguagePickerCellDataKeyText: languageDescription, + kMXKLanguagePickerCellDataKeyDetailText: localisedLanguageDescription, + kMXKLanguagePickerCellDataKeyLanguage: language + }]; + } + } + } + + // Default to "" in order to differentiate it from nil + _selectedLanguage = @""; +} + +- (void)destroy +{ + [super destroy]; + + cellDataArray = nil; + filteredCellDataArray = nil; + + previousSearchPattern = nil; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!self.tableView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + [self setupSearchController]; + + self.navigationItem.title = [MatrixKitL10n languagePickerTitle]; + +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + + self.navigationItem.hidesSearchBarWhenScrolling = YES; +} + +#pragma mark - Private + +- (void)setupSearchController +{ + UISearchController *searchController = [[UISearchController alloc] + initWithSearchResultsController:nil]; + searchController.dimsBackgroundDuringPresentation = NO; + searchController.hidesNavigationBarDuringPresentation = NO; + searchController.searchResultsUpdater = self; + + // Search bar is hidden for the moment, uncomment following line to enable it. + // TODO: Enable it once we have enough translations to fill pages and pages + // self.navigationItem.searchController = searchController; + // Make the search bar visible on first view appearance + self.navigationItem.hidesSearchBarWhenScrolling = NO; + + self.definesPresentationContext = YES; + + self.searchController = searchController; +} + +#pragma mark - UITableView dataSource + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + if (filteredCellDataArray) + { + return filteredCellDataArray.count; + } + return cellDataArray.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell* cell = [tableView dequeueReusableCellWithIdentifier:kMXKLanguagePickerViewControllerCellId]; + if (!cell) + { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:kMXKLanguagePickerViewControllerCellId]; + } + + NSInteger index = indexPath.row; + NSDictionary *itemCellData; + + if (filteredCellDataArray) + { + if (index < filteredCellDataArray.count) + { + itemCellData = filteredCellDataArray[index]; + } + } + else if (index < cellDataArray.count) + { + itemCellData = cellDataArray[index]; + } + + if (itemCellData) + { + cell.textLabel.text = itemCellData[kMXKLanguagePickerCellDataKeyText]; + cell.detailTextLabel.text = itemCellData[kMXKLanguagePickerCellDataKeyDetailText]; + + // Mark the cell with the selected language + if (_selectedLanguage == itemCellData[kMXKLanguagePickerCellDataKeyLanguage] || [_selectedLanguage isEqualToString:itemCellData[kMXKLanguagePickerCellDataKeyLanguage]]) + { + cell.accessoryType = UITableViewCellAccessoryCheckmark; + } + else + { + cell.accessoryType = UITableViewCellAccessoryNone; + } + } + + return cell; +} + +#pragma mark - UITableView delegate + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + + if (self.delegate) + { + NSInteger index = indexPath.row; + NSString *language; + + if (filteredCellDataArray) + { + if (index < filteredCellDataArray.count) + { + language = filteredCellDataArray[index][kMXKLanguagePickerCellDataKeyLanguage]; + } + } + else if (index < cellDataArray.count) + { + language = cellDataArray[index][kMXKLanguagePickerCellDataKeyLanguage]; + } + + [self.delegate languagePickerViewController:self didSelectLangugage:language]; + } +} + +#pragma mark - UISearchResultsUpdating + +- (void)updateSearchResultsForSearchController:(UISearchController *)searchController +{ + NSString *searchText = searchController.searchBar.text; + + if (searchText.length) + { + searchText = [searchText lowercaseString]; + + if (previousSearchPattern && [searchText hasPrefix:previousSearchPattern]) + { + for (NSUInteger index = 0; index < filteredCellDataArray.count;) + { + NSString *text = [filteredCellDataArray[index][kMXKLanguagePickerCellDataKeyText] lowercaseString]; + + if ([text hasPrefix:searchText] == NO) + { + [filteredCellDataArray removeObjectAtIndex:index]; + } + else + { + index++; + } + } + } + else + { + filteredCellDataArray = [NSMutableArray array]; + + for (NSUInteger index = 0; index < cellDataArray.count; index++) + { + NSString *text = [cellDataArray[index][kMXKLanguagePickerCellDataKeyText] lowercaseString]; + + if ([text hasPrefix:searchText]) + { + [filteredCellDataArray addObject:cellDataArray[index]]; + } + } + } + + previousSearchPattern = searchText; + } + else + { + previousSearchPattern = nil; + filteredCellDataArray = nil; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.xib new file mode 100644 index 000000000..2261ee450 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKLanguagePickerViewController.xib @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKNotificationSettingsViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKNotificationSettingsViewController.h new file mode 100644 index 000000000..e8ff33015 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKNotificationSettingsViewController.h @@ -0,0 +1,34 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKTableViewController.h" +#import "MXKAccount.h" + +/** + 'MXKNotificationSettingsViewController' instance may be used to display the notification settings (account's push rules). + Presently only the Global notification settings are supported. + */ +@interface MXKNotificationSettingsViewController : MXKTableViewController + +/** + The account who owns the displayed notification settings. + */ +@property (nonatomic) MXKAccount *mxAccount; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKNotificationSettingsViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKNotificationSettingsViewController.m new file mode 100644 index 000000000..156b0c9c3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKNotificationSettingsViewController.m @@ -0,0 +1,637 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKNotificationSettingsViewController.h" + +#import "MXKTableViewCellWithButton.h" +#import "MXKPushRuleTableViewCell.h" +#import "MXKPushRuleCreationTableViewCell.h" +#import "MXKTableViewCellWithTextView.h" + +#import "MXKConstants.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +#define MXKNOTIFICATIONSETTINGS_SECTION_INTRO_INDEX 0 +#define MXKNOTIFICATIONSETTINGS_SECTION_PER_WORD_INDEX 1 +#define MXKNOTIFICATIONSETTINGS_SECTION_PER_ROOM_INDEX 2 +#define MXKNOTIFICATIONSETTINGS_SECTION_PER_SENDER_INDEX 3 +#define MXKNOTIFICATIONSETTINGS_SECTION_OTHERS_INDEX 4 +#define MXKNOTIFICATIONSETTINGS_SECTION_DEFAULT_INDEX 5 +#define MXKNOTIFICATIONSETTINGS_SECTION_COUNT 6 + +@interface MXKNotificationSettingsViewController () +{ + /** + Handle master rule state + */ + UIButton *ruleMasterButton; + BOOL areAllDisabled; + + /** + */ + NSInteger contentRuleCreationIndex; + NSInteger roomRuleCreationIndex; + NSInteger senderRuleCreationIndex; + + /** + Predefined rules index + */ + NSInteger ruleContainsUserNameIndex; + NSInteger ruleContainsDisplayNameIndex; + NSInteger ruleOneToOneRoomIndex; + NSInteger ruleInviteForMeIndex; + NSInteger ruleMemberEventIndex; + NSInteger ruleCallIndex; + NSInteger ruleSuppressBotsNotificationsIndex; + + /** + Notification center observers + */ + id notificationCenterWillUpdateObserver; + id notificationCenterDidUpdateObserver; + id notificationCenterDidFailObserver; +} + +@end + +@implementation MXKNotificationSettingsViewController + +- (void)finalizeInit +{ + [super finalizeInit]; +} + +- (void)dealloc +{ + ruleMasterButton = nil; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + // Do any additional setup after loading the view, typically from a nib. +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; + // Dispose of any resources that can be recreated. +} + +- (void)destroy +{ + if (notificationCenterWillUpdateObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:notificationCenterWillUpdateObserver]; + notificationCenterWillUpdateObserver = nil; + } + + if (notificationCenterDidUpdateObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:notificationCenterDidUpdateObserver]; + notificationCenterDidUpdateObserver = nil; + } + + if (notificationCenterDidFailObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:notificationCenterDidFailObserver]; + notificationCenterDidFailObserver = nil; + } + + [super destroy]; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + if (_mxAccount) + { + [self startActivityIndicator]; + + // Refresh existing notification rules + [_mxAccount.mxSession.notificationCenter refreshRules:^{ + + [self stopActivityIndicator]; + [self.tableView reloadData]; + + } failure:^(NSError *error) { + + [self stopActivityIndicator]; + + }]; + + notificationCenterWillUpdateObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXNotificationCenterWillUpdateRules object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) { + [self startActivityIndicator]; + }]; + + notificationCenterDidUpdateObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXNotificationCenterDidUpdateRules object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) { + [self stopActivityIndicator]; + [self.tableView reloadData]; + }]; + + notificationCenterDidFailObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXNotificationCenterDidFailRulesUpdate object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) { + [self stopActivityIndicator]; + + // Notify MatrixKit user + NSString *myUserId = self.mxAccount.mxCredentials.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:note.userInfo[kMXNotificationCenterErrorKey] userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + }]; + } + + // Refresh display + [self.tableView reloadData]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + if (notificationCenterWillUpdateObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:notificationCenterWillUpdateObserver]; + notificationCenterWillUpdateObserver = nil; + } + + if (notificationCenterDidUpdateObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:notificationCenterDidUpdateObserver]; + notificationCenterDidUpdateObserver = nil; + } + + if (notificationCenterDidFailObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:notificationCenterDidFailObserver]; + notificationCenterDidFailObserver = nil; + } +} + +#pragma mark - Actions + +- (IBAction)onButtonPressed:(id)sender +{ + if (sender == ruleMasterButton) + { + // Swap enable state for all noticiations + MXPushRule *pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterDisableAllNotificationsRuleID]; + if (pushRule) + { + [_mxAccount.mxSession.notificationCenter enableRule:pushRule isEnabled:!areAllDisabled]; + } + } +} + +#pragma mark - UITableView data source + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + // Check master rule state + MXPushRule *pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterDisableAllNotificationsRuleID]; + if (pushRule.enabled) + { + areAllDisabled = YES; + return 1; + } + else + { + areAllDisabled = NO; + return MXKNOTIFICATIONSETTINGS_SECTION_COUNT; + } +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + NSInteger count = 0; + + if (section == MXKNOTIFICATIONSETTINGS_SECTION_INTRO_INDEX) + { + count = 2; + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_PER_WORD_INDEX) + { + // A first cell will display a user information + count = 1; + + // Only removable content rules are listed in this section (we ignore here predefined rules) + for (MXPushRule *pushRule in _mxAccount.mxSession.notificationCenter.rules.global.content) + { + if (!pushRule.isDefault) + { + count++; + } + } + + // Add one item to suggest new rule creation + contentRuleCreationIndex = count ++; + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_PER_ROOM_INDEX) + { + count = _mxAccount.mxSession.notificationCenter.rules.global.room.count; + + // Add one item to suggest new rule creation + roomRuleCreationIndex = count ++; + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_PER_SENDER_INDEX) + { + count = _mxAccount.mxSession.notificationCenter.rules.global.sender.count; + + // Add one item to suggest new rule creation + senderRuleCreationIndex = count ++; + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_OTHERS_INDEX) + { + ruleContainsUserNameIndex = ruleContainsDisplayNameIndex = ruleOneToOneRoomIndex = ruleInviteForMeIndex = ruleMemberEventIndex = ruleCallIndex = ruleSuppressBotsNotificationsIndex = -1; + + // Check whether each predefined rule is supported + if ([_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterContainUserNameRuleID]) + { + ruleContainsUserNameIndex = count++; + } + if ([_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterContainDisplayNameRuleID]) + { + ruleContainsDisplayNameIndex = count++; + } + if ([_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterOneToOneRoomRuleID]) + { + ruleOneToOneRoomIndex = count++; + } + if ([_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterInviteMeRuleID]) + { + ruleInviteForMeIndex = count++; + } + if ([_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterMemberEventRuleID]) + { + ruleMemberEventIndex = count++; + } + if ([_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterCallRuleID]) + { + ruleCallIndex = count++; + } + if ([_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterSuppressBotsNotificationsRuleID]) + { + ruleSuppressBotsNotificationsIndex = count++; + } + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_DEFAULT_INDEX) + { + if ([_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterAllOtherRoomMessagesRuleID]) + { + count = 1; + } + } + + return count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell *cell = nil; + NSInteger rowIndex = indexPath.row; + + if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_INTRO_INDEX) + { + if (indexPath.row == 0) + { + MXKTableViewCellWithButton *masterBtnCell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithButton defaultReuseIdentifier]]; + if (!masterBtnCell) + { + masterBtnCell = [[MXKTableViewCellWithButton alloc] init]; + } + + if (areAllDisabled) + { + [masterBtnCell.mxkButton setTitle:[MatrixKitL10n notificationSettingsEnableNotifications] forState:UIControlStateNormal]; + [masterBtnCell.mxkButton setTitle:[MatrixKitL10n notificationSettingsEnableNotifications] forState:UIControlStateHighlighted]; + } + else + { + [masterBtnCell.mxkButton setTitle:[MatrixKitL10n notificationSettingsDisableAll] forState:UIControlStateNormal]; + [masterBtnCell.mxkButton setTitle:[MatrixKitL10n notificationSettingsDisableAll] forState:UIControlStateHighlighted]; + } + + [masterBtnCell.mxkButton addTarget:self action:@selector(onButtonPressed:) forControlEvents:UIControlEventTouchUpInside]; + + ruleMasterButton = masterBtnCell.mxkButton; + + cell = masterBtnCell; + } + else + { + MXKTableViewCellWithTextView *introCell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithTextView defaultReuseIdentifier]]; + if (!introCell) + { + introCell = [[MXKTableViewCellWithTextView alloc] init]; + } + + if (areAllDisabled) + { + introCell.mxkTextView.text = [MatrixKitL10n notificationSettingsEnableNotificationsWarning]; + introCell.mxkTextView.backgroundColor = [UIColor redColor]; + } + else + { + introCell.mxkTextView.text = [MatrixKitL10n notificationSettingsGlobalInfo]; + introCell.mxkTextView.backgroundColor = [UIColor clearColor]; + } + + introCell.mxkTextView.font = [UIFont systemFontOfSize:14]; + + cell = introCell; + } + } + else if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_PER_WORD_INDEX) + { + if (rowIndex == 0) + { + MXKTableViewCellWithTextView *introCell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithTextView defaultReuseIdentifier]]; + if (!introCell) + { + introCell = [[MXKTableViewCellWithTextView alloc] init]; + } + introCell.mxkTextView.text = [MatrixKitL10n notificationSettingsPerWordInfo]; + introCell.mxkTextView.font = [UIFont systemFontOfSize:14]; + + cell = introCell; + } + else if (rowIndex == contentRuleCreationIndex) + { + MXKPushRuleCreationTableViewCell *pushRuleCreationCell = [tableView dequeueReusableCellWithIdentifier:[MXKPushRuleCreationTableViewCell defaultReuseIdentifier]]; + if (!pushRuleCreationCell) + { + pushRuleCreationCell = [[MXKPushRuleCreationTableViewCell alloc] init]; + } + + pushRuleCreationCell.mxSession = _mxAccount.mxSession; + pushRuleCreationCell.mxPushRuleKind = MXPushRuleKindContent; + cell = pushRuleCreationCell; + } + else + { + // Only removable content rules are listed in this section + NSInteger count = 0; + for (MXPushRule *pushRule in _mxAccount.mxSession.notificationCenter.rules.global.content) + { + if (!pushRule.isDefault) + { + count++; + + if (count == rowIndex) + { + MXKPushRuleTableViewCell *pushRuleCell = [tableView dequeueReusableCellWithIdentifier:[MXKPushRuleTableViewCell defaultReuseIdentifier]]; + if (!pushRuleCell) + { + pushRuleCell = [[MXKPushRuleTableViewCell alloc] init]; + } + + pushRuleCell.mxSession = _mxAccount.mxSession; + pushRuleCell.mxPushRule = pushRule; + + cell = pushRuleCell; + break; + } + } + } + } + } + else if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_PER_ROOM_INDEX) + { + if (rowIndex == roomRuleCreationIndex) + { + MXKPushRuleCreationTableViewCell *pushRuleCreationCell = [tableView dequeueReusableCellWithIdentifier:[MXKPushRuleCreationTableViewCell defaultReuseIdentifier]]; + if (!pushRuleCreationCell) + { + pushRuleCreationCell = [[MXKPushRuleCreationTableViewCell alloc] init]; + } + + pushRuleCreationCell.mxSession = _mxAccount.mxSession; + pushRuleCreationCell.mxPushRuleKind = MXPushRuleKindRoom; + cell = pushRuleCreationCell; + } + else if (rowIndex < _mxAccount.mxSession.notificationCenter.rules.global.room.count) + { + MXKPushRuleTableViewCell *pushRuleCell = [tableView dequeueReusableCellWithIdentifier:[MXKPushRuleTableViewCell defaultReuseIdentifier]]; + if (!pushRuleCell) + { + pushRuleCell = [[MXKPushRuleTableViewCell alloc] init]; + } + + pushRuleCell.mxSession = _mxAccount.mxSession; + pushRuleCell.mxPushRule = [_mxAccount.mxSession.notificationCenter.rules.global.room objectAtIndex:rowIndex]; + + cell = pushRuleCell; + } + } + else if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_PER_SENDER_INDEX) + { + if (rowIndex == senderRuleCreationIndex) + { + MXKPushRuleCreationTableViewCell *pushRuleCreationCell = [tableView dequeueReusableCellWithIdentifier:[MXKPushRuleCreationTableViewCell defaultReuseIdentifier]]; + if (!pushRuleCreationCell) + { + pushRuleCreationCell = [[MXKPushRuleCreationTableViewCell alloc] init]; + } + + pushRuleCreationCell.mxSession = _mxAccount.mxSession; + pushRuleCreationCell.mxPushRuleKind = MXPushRuleKindSender; + cell = pushRuleCreationCell; + } + else if (rowIndex < _mxAccount.mxSession.notificationCenter.rules.global.sender.count) + { + MXKPushRuleTableViewCell *pushRuleCell = [tableView dequeueReusableCellWithIdentifier:[MXKPushRuleTableViewCell defaultReuseIdentifier]]; + if (!pushRuleCell) + { + pushRuleCell = [[MXKPushRuleTableViewCell alloc] init]; + } + + pushRuleCell.mxSession = _mxAccount.mxSession; + pushRuleCell.mxPushRule = [_mxAccount.mxSession.notificationCenter.rules.global.sender objectAtIndex:rowIndex]; + + cell = pushRuleCell; + } + } + else if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_OTHERS_INDEX) + { + MXPushRule *pushRule; + NSString *ruleDescription; + + if (rowIndex == ruleContainsUserNameIndex) + { + pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterContainUserNameRuleID]; + ruleDescription = [MatrixKitL10n notificationSettingsContainMyUserName]; + } + if (rowIndex == ruleContainsDisplayNameIndex) + { + pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterContainDisplayNameRuleID]; + ruleDescription = [MatrixKitL10n notificationSettingsContainMyDisplayName]; + } + if (rowIndex == ruleOneToOneRoomIndex) + { + pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterOneToOneRoomRuleID]; + ruleDescription = [MatrixKitL10n notificationSettingsJustSentToMe]; + } + if (rowIndex == ruleInviteForMeIndex) + { + pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterInviteMeRuleID]; + ruleDescription = [MatrixKitL10n notificationSettingsInviteToANewRoom]; + } + if (rowIndex == ruleMemberEventIndex) + { + pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterMemberEventRuleID]; + ruleDescription = [MatrixKitL10n notificationSettingsPeopleJoinLeaveRooms]; + } + if (rowIndex == ruleCallIndex) + { + pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterCallRuleID]; + ruleDescription = [MatrixKitL10n notificationSettingsReceiveACall]; + } + if (rowIndex == ruleSuppressBotsNotificationsIndex) + { + pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterSuppressBotsNotificationsRuleID]; + ruleDescription = [MatrixKitL10n notificationSettingsSuppressFromBots]; + } + + if (pushRule) + { + MXKPushRuleTableViewCell *pushRuleCell = [tableView dequeueReusableCellWithIdentifier:[MXKPushRuleTableViewCell defaultReuseIdentifier]]; + if (!pushRuleCell) + { + pushRuleCell = [[MXKPushRuleTableViewCell alloc] init]; + } + + pushRuleCell.mxSession = _mxAccount.mxSession; + pushRuleCell.mxPushRule = pushRule; + pushRuleCell.ruleDescription.text = ruleDescription; + + cell = pushRuleCell; + } + } + else if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_DEFAULT_INDEX) + { + MXPushRule *pushRule = [_mxAccount.mxSession.notificationCenter ruleById:kMXNotificationCenterAllOtherRoomMessagesRuleID]; + + if (pushRule) + { + MXKPushRuleTableViewCell *pushRuleCell = [tableView dequeueReusableCellWithIdentifier:[MXKPushRuleTableViewCell defaultReuseIdentifier]]; + if (!pushRuleCell) + { + pushRuleCell = [[MXKPushRuleTableViewCell alloc] init]; + } + + pushRuleCell.mxSession = _mxAccount.mxSession; + pushRuleCell.mxPushRule = pushRule; + pushRuleCell.ruleDescription.text = [MatrixKitL10n notificationSettingsNotifyAllOther]; + + cell = pushRuleCell; + } + } + else + { + // Return a fake cell to prevent app from crashing. + cell = [[UITableViewCell alloc] init]; + } + + return cell; +} + +#pragma mark - UITableView delegate + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_INTRO_INDEX && indexPath.row == 1) + { + UITextView *textView = [[UITextView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, MAXFLOAT)]; + textView.font = [UIFont systemFontOfSize:14]; + textView.text = areAllDisabled ? [MatrixKitL10n notificationSettingsEnableNotificationsWarning] : [MatrixKitL10n notificationSettingsGlobalInfo]; + CGSize contentSize = [textView sizeThatFits:textView.frame.size]; + return contentSize.height + 1; + } + + if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_PER_WORD_INDEX) + { + if (indexPath.row == 0) + { + UITextView *textView = [[UITextView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, MAXFLOAT)]; + textView.font = [UIFont systemFontOfSize:14]; + textView.text = [MatrixKitL10n notificationSettingsPerWordInfo]; + CGSize contentSize = [textView sizeThatFits:textView.frame.size]; + return contentSize.height + 1; + } + else if (indexPath.row == contentRuleCreationIndex) + { + return 120; + } + } + + if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_PER_ROOM_INDEX && indexPath.row == roomRuleCreationIndex) + { + return 120; + } + + if (indexPath.section == MXKNOTIFICATIONSETTINGS_SECTION_PER_SENDER_INDEX && indexPath.row == senderRuleCreationIndex) + { + return 120; + } + + return 50; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section +{ + if (section != MXKNOTIFICATIONSETTINGS_SECTION_INTRO_INDEX) + { + return 30; + } + return 0; +} + +- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section +{ + UIView *sectionHeader = [[UIView alloc] initWithFrame:[tableView rectForHeaderInSection:section]]; + sectionHeader.backgroundColor = [UIColor colorWithRed:0.9 green:0.9 blue:0.9 alpha:1.0]; + UILabel *sectionLabel = [[UILabel alloc] initWithFrame:CGRectMake(5, 5, sectionHeader.frame.size.width - 10, sectionHeader.frame.size.height - 10)]; + sectionLabel.font = [UIFont boldSystemFontOfSize:16]; + sectionLabel.backgroundColor = [UIColor clearColor]; + [sectionHeader addSubview:sectionLabel]; + + if (section == MXKNOTIFICATIONSETTINGS_SECTION_PER_WORD_INDEX) + { + sectionLabel.text = [MatrixKitL10n notificationSettingsPerWordNotifications]; + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_PER_ROOM_INDEX) + { + sectionLabel.text = [MatrixKitL10n notificationSettingsPerRoomNotifications]; + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_PER_SENDER_INDEX) + { + sectionLabel.text = [MatrixKitL10n notificationSettingsPerSenderNotifications]; + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_OTHERS_INDEX) + { + sectionLabel.text = [MatrixKitL10n notificationSettingsOtherAlerts]; + } + else if (section == MXKNOTIFICATIONSETTINGS_SECTION_DEFAULT_INDEX) + { + sectionLabel.text = [MatrixKitL10n notificationSettingsByDefault]; + } + + return sectionHeader; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKPreviewViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKPreviewViewController.h new file mode 100644 index 000000000..127baaa9b --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKPreviewViewController.h @@ -0,0 +1,74 @@ +/* + Copyright 2020 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol MXKPreviewViewControllerDelegate; + +/** + @brief A view controller that previews, opens, or prints files whose file format cannot be handled directly by your app. + + Use this class to present an appropriate user interface for previewing, opening, copying, or printing a specified file. For example, an email program might use this class to allow the user to preview attachments and open them in other apps. + + After presenting its user interface, a document interaction controller handles all interactions needed to support file preview and menu display. + + Unlike UIDocumentInteractionController, this view controller aims to be modal presented. + */ +@interface MXKPreviewViewController : UINavigationController + +/** + @brief presents a new instance of MXKPreviewViewController as modal. + + @param presenting view controller that presents the MXKPreviewViewController + @param fileUrl URL of the file. This URL should point to a local file. + @param allowActions YES to display actions Button. NO otherwise + @param delegate delegate (optional) that receives some events about the lifecycle of the MXKPreviewViewController + + @return the instance of MXKPreviewViewController + */ ++ (MXKPreviewViewController *)presentFrom:(nonnull UIViewController *)presenting + fileUrl: (nonnull NSURL *)fileUrl + allowActions: (BOOL)allowActions + delegate: (nullable id)delegate; + +@end + +/** + A set of methods you can implement to respond to messages from a preview controller. + */ +@protocol MXKPreviewViewControllerDelegate + +@optional + +/** + The MXKPreviewViewController will present the preview + + @param controller the instance of MXKPreviewViewController + */ +- (void)previewViewControllerWillBeginPreview:(MXKPreviewViewController *)controller; + +/** + The MXKPreviewViewController did end presenting the preview + + @param controller the instance of MXKPreviewViewController + */ +- (void)previewViewControllerDidEndPreview:(MXKPreviewViewController *)controller; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Controllers/MXKPreviewViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKPreviewViewController.m new file mode 100644 index 000000000..214ea6c17 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKPreviewViewController.m @@ -0,0 +1,104 @@ +/* + Copyright 2020 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKPreviewViewController.h" +@import QuickLook; + +@interface MXKPreviewViewController () + +/// A specialized view controller for previewing an item. +@property (nonatomic, weak) QLPreviewController *previewController; + +/// URL of the file to preview +@property (nonatomic, strong) NSURL *fileURL; + +/// YES to display actions Button. NO otherwise +@property (nonatomic) BOOL allowActions; + +@property (nonatomic, weak) id previewDelegate; + +@end + +@implementation MXKPreviewViewController + ++ (MXKPreviewViewController *)presentFrom:(UIViewController *)presenting fileUrl:(NSURL *)fileUrl allowActions:(BOOL)allowActions delegate:(nullable id)delegate +{ + MXKPreviewViewController *previewController = [[MXKPreviewViewController alloc] initWithFileUrl: fileUrl allowActions: allowActions]; + previewController.previewDelegate = delegate; + if ([delegate respondsToSelector:@selector(previewViewControllerWillBeginPreview:)]) { + [delegate previewViewControllerWillBeginPreview:previewController]; + } + [presenting presentViewController:previewController animated:YES completion:^{ + }]; + + return previewController; +} + +- (instancetype)initWithFileUrl: (NSURL *)fileUrl allowActions: (BOOL)allowActions +{ + QLPreviewController *previewController = [[QLPreviewController alloc] init]; + self = [super initWithRootViewController:previewController]; + self.previewController = previewController; + + if (self) + { + self.modalPresentationStyle = UIModalPresentationFullScreen; + self.fileURL = fileUrl; + self.allowActions = allowActions; + self.previewController.dataSource = self; + self.previewController.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(doneAction:)]; + } + + return self; +} + +- (void)viewDidLayoutSubviews +{ + [super viewDidLayoutSubviews]; + + if (!self.allowActions) + { + NSMutableArray *items = [NSMutableArray arrayWithArray: self.previewController.navigationItem.rightBarButtonItems]; + if (items.count > 0) + { + [items removeObjectAtIndex:0]; + } + self.previewController.navigationItem.rightBarButtonItems = items; + } +} + +- (IBAction)doneAction:(id)sender +{ + [self dismissViewControllerAnimated:YES completion:^{ + if ([self.previewDelegate respondsToSelector:@selector(previewViewControllerDidEndPreview:)]) { + [self.previewDelegate previewViewControllerDidEndPreview:self]; + } + }]; +} + +#pragma mark - QLPreviewControllerDataSource + +- (NSInteger)numberOfPreviewItemsInPreviewController:(nonnull QLPreviewController *)controller +{ + return self.fileURL ? 1 : 0; +} + +- (nonnull id)previewController:(nonnull QLPreviewController *)controller previewItemAtIndex:(NSInteger)index +{ + return self.fileURL; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.h new file mode 100644 index 000000000..da8d193f3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.h @@ -0,0 +1,129 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#import + +#import "MXKViewController.h" +#import "MXKRecentsDataSource.h" + +@class MXKRecentListViewController; + +/** + `MXKRecentListViewController` delegate. + */ +@protocol MXKRecentListViewControllerDelegate + +/** + Tells the delegate that the user selected a room. + + @param recentListViewController the `MXKRecentListViewController` instance. + @param roomId the id of the selected room. + @param mxSession the matrix session in which the room is defined. + */ +- (void)recentListViewController:(MXKRecentListViewController *)recentListViewController didSelectRoom:(NSString*)roomId inMatrixSession:(MXSession*)mxSession; + +/** + Tells the delegate that the user selected a suggested room. + + @param recentListViewController the `MXKRecentListViewController` instance. + @param childInfo the `MXSpaceChildInfo` instance that describes the selected room. + */ +-(void)recentListViewController:(MXKRecentListViewController *)recentListViewController didSelectSuggestedRoom:(MXSpaceChildInfo *)childInfo; + +@end + + +/** + This view controller displays a room list. + */ +@interface MXKRecentListViewController : MXKViewController +{ +@protected + + /** + The fake top view displayed in case of vertical bounce. + */ + __weak UIView *topview; +} + +@property (weak, nonatomic) IBOutlet UISearchBar *recentsSearchBar; +@property (weak, nonatomic) IBOutlet UITableView *recentsTableView; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *recentsSearchBarTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *recentsSearchBarHeightConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *recentsTableViewBottomConstraint; + +/** + The current data source associated to the view controller. + */ +@property (nonatomic, readonly) MXKRecentsDataSource *dataSource; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +/** + Enable the search option by adding a navigation item in the navigation bar (YES by default). + Set NO this property to disable this option and hide the related bar button. + */ +@property (nonatomic) BOOL enableBarButtonSearch; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKRecentListViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `recentListViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRecentListViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRecentListViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)recentListViewController; + +/** + Display the recents described in the provided data source. + + Note1: The provided data source will replace the current data source if any. The caller + should dispose properly this data source if it is not used anymore. + + Note2: You may provide here a MXKInterleavedRecentsDataSource instance to display interleaved recents. + + @param listDataSource the data source providing the recents list. + */ +- (void)displayList:(MXKRecentsDataSource*)listDataSource; + +/** + Refresh the recents table display. + */ +- (void)refreshRecentsTable; + +/** + Hide/show the search bar at the top of the recents table view. + */ +- (void)hideSearchBar:(BOOL)hidden; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.m new file mode 100644 index 000000000..828f84825 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.m @@ -0,0 +1,624 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRecentListViewController.h" + +#import "MXKRoomDataSourceManager.h" + +#import "MXKInterleavedRecentsDataSource.h" +#import "MXKInterleavedRecentTableViewCell.h" + +#import "MXKSwiftHeader.h" + +@interface MXKRecentListViewController () +{ + /** + The data source providing UITableViewCells + */ + MXKRecentsDataSource *dataSource; + + /** + Search handling + */ + UIBarButtonItem *searchButton; + BOOL ignoreSearchRequest; + + /** + The reconnection animated view. + */ + __weak UIView* reconnectingView; + + /** + The current table view header if any. + */ + UIView* tableViewHeaderView; + + /** + The latest server sync date + */ + NSDate* latestServerSync; + + /** + The restart the event connnection + */ + BOOL restartConnection; +} + +@end + +@implementation MXKRecentListViewController +@synthesize dataSource; + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRecentListViewController class]) + bundle:[NSBundle bundleForClass:[MXKRecentListViewController class]]]; +} + ++ (instancetype)recentListViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKRecentListViewController class]) + bundle:[NSBundle bundleForClass:[MXKRecentListViewController class]]]; +} + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + _enableBarButtonSearch = YES; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!_recentsTableView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + // Adjust search bar Top constraint to take into account potential navBar. + if (_recentsSearchBarTopConstraint) + { + _recentsSearchBarTopConstraint.active = NO; + _recentsSearchBarTopConstraint = [NSLayoutConstraint constraintWithItem:self.topLayoutGuide + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self.recentsSearchBar + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:0.0f]; + + _recentsSearchBarTopConstraint.active = YES; + } + + // Adjust table view Bottom constraint to take into account tabBar. + if (_recentsTableViewBottomConstraint) + { + _recentsTableViewBottomConstraint.active = NO; + _recentsTableViewBottomConstraint = [NSLayoutConstraint constraintWithItem:self.bottomLayoutGuide + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.recentsTableView + attribute:NSLayoutAttributeBottom + multiplier:1.0f + constant:0.0f]; + + _recentsTableViewBottomConstraint.active = YES; + } + + // Hide search bar by default + [self hideSearchBar:YES]; + + // Apply search option in navigation bar + self.enableBarButtonSearch = _enableBarButtonSearch; + + // Add an accessory view to the search bar in order to retrieve keyboard view. + self.recentsSearchBar.inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + + // Finalize table view configuration + self.recentsTableView.delegate = self; + self.recentsTableView.dataSource = dataSource; // Note: dataSource may be nil here + + // Set up classes to use for cells + [self.recentsTableView registerNib:MXKRecentTableViewCell.nib forCellReuseIdentifier:MXKRecentTableViewCell.defaultReuseIdentifier]; + // Consider here the specific case where interleaved recents are supported + [self.recentsTableView registerNib:MXKInterleavedRecentTableViewCell.nib forCellReuseIdentifier:MXKInterleavedRecentTableViewCell.defaultReuseIdentifier]; + + // Add a top view which will be displayed in case of vertical bounce. + CGFloat height = self.recentsTableView.frame.size.height; + UIView *topview = [[UIView alloc] initWithFrame:CGRectMake(0,-height,self.recentsTableView.frame.size.width,height)]; + topview.autoresizingMask = UIViewAutoresizingFlexibleWidth; + topview.backgroundColor = [UIColor groupTableViewBackgroundColor]; + [self.recentsTableView addSubview:topview]; + self->topview = topview; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + // Restore search mechanism (if enabled) + ignoreSearchRequest = NO; + + // Observe server sync at room data source level too + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMatrixSessionChange) name:kMXKRoomDataSourceSyncStatusChanged object:nil]; + + // Observe the server sync + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onSyncNotification) name:kMXSessionDidSyncNotification object:nil]; + + // Do a full reload + [self refreshRecentsTable]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + // The user may still press search button whereas the view disappears + ignoreSearchRequest = YES; + + // Leave potential search session + if (!self.recentsSearchBar.isHidden) + { + [self searchBarCancelButtonClicked:self.recentsSearchBar]; + } + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXKRoomDataSourceSyncStatusChanged object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidSyncNotification object:nil]; + + [self removeReconnectingView]; +} + +- (void)dealloc +{ + self.recentsSearchBar.inputAccessoryView = nil; + + searchButton = nil; +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; + + // Dispose of any resources that can be recreated. +} + +#pragma mark - Override MXKViewController + +- (void)onMatrixSessionChange +{ + [super onMatrixSessionChange]; + + // Check whether no server sync is in progress in room data sources + NSArray *mxSessions = self.mxSessions; + for (MXSession *mxSession in mxSessions) + { + if ([MXKRoomDataSourceManager sharedManagerForMatrixSession:mxSession].isServerSyncInProgress) + { + // sync is in progress for at least one data source, keep running the loading wheel + [self.activityIndicator startAnimating]; + break; + } + } +} + +- (void)onKeyboardShowAnimationComplete +{ + // Report the keyboard view in order to track keyboard frame changes + self.keyboardView = _recentsSearchBar.inputAccessoryView.superview; +} + +- (void)setKeyboardHeight:(CGFloat)keyboardHeight +{ + // Deduce the bottom constraint for the table view (Don't forget the potential tabBar) + CGFloat tableViewBottomConst = keyboardHeight - self.bottomLayoutGuide.length; + // Check whether the keyboard is over the tabBar + if (tableViewBottomConst < 0) + { + tableViewBottomConst = 0; + } + + // Update constraints + _recentsTableViewBottomConstraint.constant = tableViewBottomConst; + + // Force layout immediately to take into account new constraint + [self.view layoutIfNeeded]; +} + +- (void)destroy +{ + self.recentsTableView.dataSource = nil; + self.recentsTableView.delegate = nil; + self.recentsTableView = nil; + + dataSource.delegate = nil; + dataSource = nil; + + _delegate = nil; + + [topview removeFromSuperview]; + topview = nil; + + [super destroy]; +} + +#pragma mark - + +- (void)setEnableBarButtonSearch:(BOOL)enableBarButtonSearch +{ + _enableBarButtonSearch = enableBarButtonSearch; + + if (enableBarButtonSearch) + { + if (!searchButton) + { + searchButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemSearch target:self action:@selector(search:)]; + } + + // Add it in right bar items + NSArray *rightBarButtonItems = self.navigationItem.rightBarButtonItems; + self.navigationItem.rightBarButtonItems = rightBarButtonItems ? [rightBarButtonItems arrayByAddingObject:searchButton] : @[searchButton]; + } + else + { + NSMutableArray *rightBarButtonItems = [NSMutableArray arrayWithArray: self.navigationItem.rightBarButtonItems]; + [rightBarButtonItems removeObject:searchButton]; + self.navigationItem.rightBarButtonItems = rightBarButtonItems; + } +} + +- (void)displayList:(MXKRecentsDataSource *)listDataSource +{ + // Cancel registration on existing dataSource if any + if (dataSource) + { + dataSource.delegate = nil; + + // Remove associated matrix sessions + NSArray *mxSessions = self.mxSessions; + for (MXSession *mxSession in mxSessions) + { + [self removeMatrixSession:mxSession]; + } + } + + dataSource = listDataSource; + dataSource.delegate = self; + + // Report all matrix sessions at view controller level to update UI according to sessions state + NSArray *mxSessions = listDataSource.mxSessions; + for (MXSession *mxSession in mxSessions) + { + [self addMatrixSession:mxSession]; + } + + if (self.recentsTableView) + { + // Set up table data source + self.recentsTableView.dataSource = dataSource; + } +} + +- (void)refreshRecentsTable +{ + // For now, do a simple full reload + [self.recentsTableView reloadData]; +} + +- (void)hideSearchBar:(BOOL)hidden +{ + self.recentsSearchBar.hidden = hidden; + self.recentsSearchBarHeightConstraint.constant = hidden ? 0 : 44; + [self.view setNeedsUpdateConstraints]; +} + +#pragma mark - Action + +- (IBAction)search:(id)sender +{ + // The user may have pressed search button whereas the view controller was disappearing + if (ignoreSearchRequest) + { + return; + } + + if (self.recentsSearchBar.isHidden) + { + // Check whether there are data in which search + if ([self.dataSource numberOfSectionsInTableView:self.recentsTableView]) + { + [self hideSearchBar:NO]; + + // Create search bar + [self.recentsSearchBar becomeFirstResponder]; + } + } + else + { + [self searchBarCancelButtonClicked: self.recentsSearchBar]; + } +} + +#pragma mark - MXKDataSourceDelegate + +- (Class)cellViewClassForCellData:(MXKCellData*)cellData +{ + // Consider here the specific case where interleaved recents are supported + if ([dataSource isKindOfClass:MXKInterleavedRecentsDataSource.class]) + { + return MXKInterleavedRecentTableViewCell.class; + } + + // Return the default recent table view cell + return MXKRecentTableViewCell.class; +} + +- (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData +{ + // Consider here the specific case where interleaved recents are supported + if ([dataSource isKindOfClass:MXKInterleavedRecentsDataSource.class]) + { + return MXKInterleavedRecentTableViewCell.defaultReuseIdentifier; + } + + // Return the default recent table view cell + return MXKRecentTableViewCell.defaultReuseIdentifier; +} + +- (void)dataSource:(MXKDataSource *)dataSource didCellChange:(id)changes +{ + // For now, do a simple full reload + [self refreshRecentsTable]; +} + +- (void)dataSource:(MXKDataSource *)dataSource didAddMatrixSession:(MXSession *)mxSession +{ + [self addMatrixSession:mxSession]; +} + +- (void)dataSource:(MXKDataSource *)dataSource didRemoveMatrixSession:(MXSession *)mxSession +{ + [self removeMatrixSession:mxSession]; +} + +#pragma mark - UITableView delegate +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + return [dataSource cellHeightAtIndexPath:indexPath]; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section +{ + // Section header is required only when several recent lists are displayed. + if (self.dataSource.displayedRecentsDataSourcesCount > 1) + { + return 35; + } + return 0; +} + +- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section +{ + // Let dataSource provide the section header. + return [dataSource viewForHeaderInSection:section withFrame:[tableView rectForHeaderInSection:section]]; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (_delegate) + { + UITableViewCell *selectedCell = [tableView cellForRowAtIndexPath:indexPath]; + + if ([selectedCell conformsToProtocol:@protocol(MXKCellRendering)]) + { + id cell = (id)selectedCell; + + if ([cell respondsToSelector:@selector(renderedCellData)]) + { + MXKCellData *cellData = cell.renderedCellData; + if ([cellData conformsToProtocol:@protocol(MXKRecentCellDataStoring)]) + { + id recentCellData = (id)cellData; + if (recentCellData.isSuggestedRoom) + { + [_delegate recentListViewController:self + didSelectSuggestedRoom:recentCellData.roomSummary.spaceChildInfo]; + } + else + { + [_delegate recentListViewController:self + didSelectRoom:recentCellData.roomIdentifier + inMatrixSession:recentCellData.mxSession]; + } + } + } + } + } + + // Hide the keyboard when user select a room + // do not hide the searchBar until the view controller disappear + // on tablets / iphone 6+, the user could expect to search again while looking at a room + [self.recentsSearchBar resignFirstResponder]; +} + +- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath*)indexPath +{ + // Release here resources, and restore reusable cells + if ([cell respondsToSelector:@selector(didEndDisplay)]) + { + [(id)cell didEndDisplay]; + } +} + +- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset +{ + // Detect vertical bounce at the top of the tableview to trigger reconnection. + if (scrollView == _recentsTableView) + { + [self detectPullToKick:scrollView]; + } +} + +- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView +{ + if (scrollView == _recentsTableView) + { + [self managePullToKick:scrollView]; + } +} + +- (void)scrollViewDidScroll:(UIScrollView *)scrollView +{ + if (scrollView == _recentsTableView) + { + if (scrollView.contentOffset.y + scrollView.adjustedContentInset.top == 0) + { + [self managePullToKick:scrollView]; + } + } +} + +#pragma mark - UISearchBarDelegate + +- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText +{ + // Apply filter + if (searchText.length) + { + [self.dataSource searchWithPatterns:@[searchText]]; + } + else + { + [self.dataSource searchWithPatterns:nil]; + } +} + +- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar +{ + // "Done" key has been pressed + [searchBar resignFirstResponder]; +} + +- (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar +{ + // Leave search + [searchBar resignFirstResponder]; + + [self hideSearchBar:YES]; + + self.recentsSearchBar.text = nil; + + // Refresh display + [self.dataSource searchWithPatterns:nil]; +} + +#pragma mark - resync management + +- (void)onSyncNotification +{ + latestServerSync = [NSDate date]; + [self removeReconnectingView]; +} + +- (BOOL)canReconnect +{ + // avoid restarting connection if some data has been received within 1 second (1000 : latestServerSync is null) + NSTimeInterval interval = latestServerSync ? [[NSDate date] timeIntervalSinceDate:latestServerSync] : 1000; + return (interval > 1) && [self.mainSession reconnect]; +} + +- (void)addReconnectingView +{ + if (!reconnectingView) + { + UIActivityIndicatorView* spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; + spinner.transform = CGAffineTransformMakeScale(0.75f, 0.75f); + CGRect frame = spinner.frame; + frame.size.height = 80; // 80 * 0.75 = 60 + spinner.bounds = frame; + spinner.color = [UIColor darkGrayColor]; + spinner.hidesWhenStopped = NO; + spinner.backgroundColor = _recentsTableView.backgroundColor; + [spinner startAnimating]; + + // no need to manage constraints here, IOS defines them. + tableViewHeaderView = _recentsTableView.tableHeaderView; + _recentsTableView.tableHeaderView = reconnectingView = spinner; + } +} + +- (void)removeReconnectingView +{ + if (reconnectingView && !restartConnection) + { + _recentsTableView.tableHeaderView = tableViewHeaderView; + reconnectingView = nil; + } +} + +/** + Detect if the current connection must be restarted. + The spinner is displayed until the overscroll ends (and scrollViewDidEndDecelerating is called). + */ +- (void)detectPullToKick:(UIScrollView *)scrollView +{ + if (!reconnectingView) + { + // detect if the user scrolls over the tableview top + restartConnection = (scrollView.contentOffset.y + scrollView.adjustedContentInset.top < -128); + + if (restartConnection) + { + // wait that list decelerate to display / hide it + [self addReconnectingView]; + } + } +} + +/** + Restarts the current connection if it is required. + The 0.3s delay is added to avoid flickering if the connection does not require to be restarted. + */ +- (void)managePullToKick:(UIScrollView *)scrollView +{ + // the current connection must be restarted + if (restartConnection) + { + // display at least 0.3s the spinner to show to the user that something is pending + // else the UI is flickering + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.3 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ + self->restartConnection = NO; + + if (![self canReconnect]) + { + // if the event stream has not been restarted + // hide the spinner + [self removeReconnectingView]; + } + // else wait that onSyncNotification is called. + }); + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.xib new file mode 100644 index 000000000..441694c1f --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRecentListViewController.xib @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.h new file mode 100644 index 000000000..6724f6998 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.h @@ -0,0 +1,212 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKViewController.h" +#import "MXKImageView.h" + +/** + Available actions on room member + */ +typedef enum : NSUInteger +{ + MXKRoomMemberDetailsActionInvite, + MXKRoomMemberDetailsActionLeave, + MXKRoomMemberDetailsActionKick, + MXKRoomMemberDetailsActionBan, + MXKRoomMemberDetailsActionUnban, + MXKRoomMemberDetailsActionIgnore, + MXKRoomMemberDetailsActionUnignore, + MXKRoomMemberDetailsActionSetDefaultPowerLevel, + MXKRoomMemberDetailsActionSetModerator, + MXKRoomMemberDetailsActionSetAdmin, + MXKRoomMemberDetailsActionSetCustomPowerLevel, + MXKRoomMemberDetailsActionStartChat, + MXKRoomMemberDetailsActionStartVoiceCall, + MXKRoomMemberDetailsActionStartVideoCall, + MXKRoomMemberDetailsActionMention, + MXKRoomMemberDetailsActionSecurity, + MXKRoomMemberDetailsActionSecurityInformation + +} MXKRoomMemberDetailsAction; + +@class MXKRoomMemberDetailsViewController; + +/** + `MXKRoomMemberDetailsViewController` delegate. + */ +@protocol MXKRoomMemberDetailsViewControllerDelegate + +/** + Tells the delegate that the user wants to start a one-to-one chat with the room member. + + @param roomMemberDetailsViewController the `MXKRoomMemberDetailsViewController` instance. + @param matrixId the member's matrix id + @param completion the block to execute at the end of the operation (independently if it succeeded or not). + */ +- (void)roomMemberDetailsViewController:(MXKRoomMemberDetailsViewController *)roomMemberDetailsViewController startChatWithMemberId:(NSString*)matrixId completion:(void (^)(void))completion; + +@optional +/** + Tells the delegate that the user wants to mention the room member. + + @discussion the `MXKRoomMemberDetailsViewController` instance is withdrawn automatically. + + @param roomMemberDetailsViewController the `MXKRoomMemberDetailsViewController` instance. + @param member the room member to mention. + */ +- (void)roomMemberDetailsViewController:(MXKRoomMemberDetailsViewController *)roomMemberDetailsViewController mention:(MXRoomMember*)member; + +/** + Tells the delegate that the user wants to place a voip call with the room member. + + @param roomMemberDetailsViewController the `MXKRoomMemberDetailsViewController` instance. + @param matrixId the member's matrix id + @param isVideoCall the type of the call: YES for video call / NO for voice call. + */ +- (void)roomMemberDetailsViewController:(MXKRoomMemberDetailsViewController *)roomMemberDetailsViewController placeVoipCallWithMemberId:(NSString*)matrixId andVideo:(BOOL)isVideoCall; + +@end + +/** + Whereas the main item of this view controller is a table view, the 'MXKRoomMemberDetailsViewController' class inherits + from 'MXKViewController' instead of 'MXKTableViewController' in order to ease the customization. + Indeed some items like header may be added at the same level than the table. + */ +@interface MXKRoomMemberDetailsViewController : MXKViewController +{ +@protected + /** + Current alert (if any). + */ + UIAlertController *currentAlert; + + /** + List of the allowed actions on this member. + */ + NSMutableArray *actionsArray; +} + +@property (weak, nonatomic) IBOutlet UITableView *tableView; + +@property (weak, nonatomic) IBOutlet MXKImageView *memberThumbnail; +@property (weak, nonatomic) IBOutlet UITextView *roomMemberMatrixInfo; + +/** + The default account picture displayed when no picture is defined. + */ +@property (nonatomic) UIImage *picturePlaceholder; + +/** + The displayed member and the corresponding room + */ +@property (nonatomic, readonly) MXRoomMember *mxRoomMember; +@property (nonatomic, readonly) MXRoom *mxRoom; +@property (nonatomic, readonly) MXEventTimeline *mxRoomLiveTimeline; + +/** + Enable mention option. NO by default + */ +@property (nonatomic) BOOL enableMention; + +/** + Enable voip call (voice/video). NO by default + */ +@property (nonatomic) BOOL enableVoipCall; + +/** + Enable leave this room. YES by default + */ +@property (nonatomic) BOOL enableLeave; + +/** + Tell whether an action is already in progress. + */ +@property (nonatomic, readonly) BOOL hasPendingAction; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKRoomMemberDetailsViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `roomMemberDetailsViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRoomMemberDetailsViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRoomMemberDetailsViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)roomMemberDetailsViewController; + +/** + Set the room member to display. Provide the actual room in order to handle member changes. + + @param roomMember the matrix room member + @param room the matrix room to which this member belongs. + */ +- (void)displayRoomMember:(MXRoomMember*)roomMember withMatrixRoom:(MXRoom*)room; + +/** + Refresh the member information. + */ +- (void)updateMemberInfo; + +/** + The following method is registered on `UIControlEventTouchUpInside` event for all displayed action buttons. + + The start chat and mention options are transferred to the delegate. + All the other actions are handled by the current implementation. + + If the delegate responds to selector: @selector(roomMemberDetailsViewController:placeVoipCallWithMemberId:andVideo:), the voip options + are transferred to the delegate. + */ +- (IBAction)onActionButtonPressed:(id)sender; + +/** + Set the power level of the room member + + @param value the value to set. + @param promptUser prompt the user if they ops a member with the same power level. + */ +- (void)setPowerLevel:(NSInteger)value promptUser:(BOOL)promptUser; + +/** + Add a mask in overlay to prevent a new contact selection (used when an action is on progress). + */ +- (void)addPendingActionMask; + +/** + Remove the potential overlay mask + */ +- (void)removePendingActionMask; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.m new file mode 100644 index 000000000..67623bc18 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.m @@ -0,0 +1,1037 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomMemberDetailsViewController.h" + +@import MatrixSDK.MXMediaManager; + +#import "MXKTableViewCellWithButtons.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKAppSettings.h" + +#import "MXKConstants.h" + +#import "MXKSwiftHeader.h" + +@interface MXKRoomMemberDetailsViewController () +{ + id membersListener; + + // mask view while processing a request + UIActivityIndicatorView * pendingMaskSpinnerView; + + // Observe left rooms + id leaveRoomNotificationObserver; + + // Observe kMXRoomDidFlushDataNotification to take into account the updated room members when the room history is flushed. + id roomDidFlushDataNotificationObserver; + + // Cache for the room live timeline + MXEventTimeline *mxRoomLiveTimeline; +} + +@end + +@implementation MXKRoomMemberDetailsViewController +@synthesize mxRoom; + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomMemberDetailsViewController class]) + bundle:[NSBundle bundleForClass:[MXKRoomMemberDetailsViewController class]]]; +} + ++ (instancetype)roomMemberDetailsViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKRoomMemberDetailsViewController class]) + bundle:[NSBundle bundleForClass:[MXKRoomMemberDetailsViewController class]]]; +} + +- (void)finalizeInit +{ + [super finalizeInit]; + + actionsArray = [[NSMutableArray alloc] init]; + _enableLeave = YES; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!self.tableView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + // ignore useless update + if (_mxRoomMember) + { + [self initObservers]; + } +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + [self initObservers]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + [self removeObservers]; +} + +- (void)destroy +{ + // close any pending actionsheet + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + currentAlert = nil; + } + + [self removePendingActionMask]; + + [self removeObservers]; + + _delegate = nil; + _mxRoomMember = nil; + + actionsArray = nil; + + [super destroy]; +} + +#pragma mark - + +- (void)displayRoomMember:(MXRoomMember*)roomMember withMatrixRoom:(MXRoom*)room +{ + [self removeObservers]; + + mxRoom = room; + + MXWeakify(self); + [mxRoom liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + self->mxRoomLiveTimeline = liveTimeline; + + // Update matrix session associated to the view controller + NSArray *mxSessions = self.mxSessions; + for (MXSession *mxSession in mxSessions) { + [self removeMatrixSession:mxSession]; + } + [self addMatrixSession:room.mxSession]; + + self->_mxRoomMember = roomMember; + + [self initObservers]; + }]; +} + +- (MXEventTimeline *)mxRoomLiveTimeline +{ + // @TODO(async-state): Just here for dev + NSAssert(mxRoomLiveTimeline, @"[MXKRoomMemberDetailsViewController] Room live timeline must be preloaded before accessing to MXKRoomMemberDetailsViewController.mxRoomLiveTimeline"); + return mxRoomLiveTimeline; +} + +- (UIImage*)picturePlaceholder +{ + return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"]; +} + +- (void)setEnableMention:(BOOL)enableMention +{ + if (_enableMention != enableMention) + { + _enableMention = enableMention; + + [self updateMemberInfo]; + } +} + +- (void)setEnableVoipCall:(BOOL)enableVoipCall +{ + if (_enableVoipCall != enableVoipCall) + { + _enableVoipCall = enableVoipCall; + + [self updateMemberInfo]; + } +} + +- (void)setEnableLeave:(BOOL)enableLeave +{ + if (_enableLeave != enableLeave) + { + _enableLeave = enableLeave; + + [self updateMemberInfo]; + } +} + +- (IBAction)onActionButtonPressed:(id)sender +{ + if ([sender isKindOfClass:[UIButton class]]) + { + // Check whether an action is already in progress + if ([self hasPendingAction]) + { + return; + } + + UIButton *button = (UIButton*)sender; + + switch (button.tag) + { + case MXKRoomMemberDetailsActionInvite: + { + [self addPendingActionMask]; + [mxRoom inviteUser:_mxRoomMember.userId + success:^{ + + [self removePendingActionMask]; + + } failure:^(NSError *error) { + + [self removePendingActionMask]; + MXLogDebug(@"[MXKRoomMemberDetailsVC] Invite %@ failed", self->_mxRoomMember.userId); + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + break; + } + case MXKRoomMemberDetailsActionLeave: + { + [self addPendingActionMask]; + [self.mxRoom leave:^{ + + [self removePendingActionMask]; + [self withdrawViewControllerAnimated:YES completion:nil]; + + } failure:^(NSError *error) { + + [self removePendingActionMask]; + MXLogDebug(@"[MXKRoomMemberDetailsVC] Leave room %@ failed", self->mxRoom.roomId); + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + break; + } + case MXKRoomMemberDetailsActionKick: + { + [self addPendingActionMask]; + [mxRoom kickUser:_mxRoomMember.userId + reason:nil + success:^{ + + [self removePendingActionMask]; + // Pop/Dismiss the current view controller if the left members are hidden + if (![[MXKAppSettings standardAppSettings] showLeftMembersInRoomMemberList]) + { + [self withdrawViewControllerAnimated:YES completion:nil]; + } + + } failure:^(NSError *error) { + + [self removePendingActionMask]; + MXLogDebug(@"[MXKRoomMemberDetailsVC] Kick %@ failed", self->_mxRoomMember.userId); + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + break; + } + case MXKRoomMemberDetailsActionBan: + { + [self addPendingActionMask]; + [mxRoom banUser:_mxRoomMember.userId + reason:nil + success:^{ + + [self removePendingActionMask]; + + } failure:^(NSError *error) { + + [self removePendingActionMask]; + MXLogDebug(@"[MXKRoomMemberDetailsVC] Ban %@ failed", self->_mxRoomMember.userId); + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + break; + } + case MXKRoomMemberDetailsActionUnban: + { + [self addPendingActionMask]; + [mxRoom unbanUser:_mxRoomMember.userId + success:^{ + + [self removePendingActionMask]; + + } failure:^(NSError *error) { + + [self removePendingActionMask]; + MXLogDebug(@"[MXKRoomMemberDetailsVC] Unban %@ failed", self->_mxRoomMember.userId); + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + break; + } + case MXKRoomMemberDetailsActionIgnore: + { + // Prompt user to ignore content from this user + MXWeakify(self); + + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + currentAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n roomMemberIgnorePrompt] message:nil preferredStyle:UIAlertControllerStyleAlert]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n yes] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + + self->currentAlert = nil; + + // Add the user to the blacklist: ignored users + [self addPendingActionMask]; + + MXWeakify(self); + + [self.mainSession ignoreUsers:@[self.mxRoomMember.userId] + success:^{ + + MXStrongifyAndReturnIfNil(self); + + [self removePendingActionMask]; + + } failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + + [self removePendingActionMask]; + MXLogDebug(@"[MXKRoomMemberDetailsVC] Ignore %@ failed", self.mxRoomMember.userId); + + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + + }]]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n no] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + + self->currentAlert = nil; + }]]; + + [self presentViewController:currentAlert animated:YES completion:nil]; + break; + } + case MXKRoomMemberDetailsActionUnignore: + { + // Remove the member from the ignored user list. + [self addPendingActionMask]; + + MXWeakify(self); + + [self.mainSession unIgnoreUsers:@[self.mxRoomMember.userId] + success:^{ + + MXStrongifyAndReturnIfNil(self); + [self removePendingActionMask]; + + } failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + + [self removePendingActionMask]; + MXLogDebug(@"[MXKRoomMemberDetailsVC] Unignore %@ failed", self.mxRoomMember.userId); + + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + break; + } + case MXKRoomMemberDetailsActionSetDefaultPowerLevel: + { + break; + } + case MXKRoomMemberDetailsActionSetModerator: + { + break; + } + case MXKRoomMemberDetailsActionSetAdmin: + { + break; + } + case MXKRoomMemberDetailsActionSetCustomPowerLevel: + { + [self updateUserPowerLevel]; + break; + } + case MXKRoomMemberDetailsActionStartChat: + { + if (self.delegate) + { + [self addPendingActionMask]; + + [self.delegate roomMemberDetailsViewController:self startChatWithMemberId:_mxRoomMember.userId completion:^{ + + [self removePendingActionMask]; + }]; + } + break; + } + case MXKRoomMemberDetailsActionStartVoiceCall: + case MXKRoomMemberDetailsActionStartVideoCall: + { + BOOL isVideoCall = (button.tag == MXKRoomMemberDetailsActionStartVideoCall); + + if (self.delegate && [self.delegate respondsToSelector:@selector(roomMemberDetailsViewController:placeVoipCallWithMemberId:andVideo:)]) + { + [self addPendingActionMask]; + + [self.delegate roomMemberDetailsViewController:self placeVoipCallWithMemberId:_mxRoomMember.userId andVideo:isVideoCall]; + + [self removePendingActionMask]; + } + else + { + [self addPendingActionMask]; + + MXRoom* directRoom = [self.mainSession directJoinedRoomWithUserId:_mxRoomMember.userId]; + + // Place the call directly if the room exists + if (directRoom) + { + [directRoom placeCallWithVideo:isVideoCall success:nil failure:nil]; + [self removePendingActionMask]; + } + else + { + // Create a new room + MXRoomCreationParameters *roomCreationParameters = [MXRoomCreationParameters parametersForDirectRoomWithUser:_mxRoomMember.userId]; + [self.mainSession createRoomWithParameters:roomCreationParameters success:^(MXRoom *room) { + + // Delay the call in order to be sure that the room is ready + dispatch_async(dispatch_get_main_queue(), ^{ + [room placeCallWithVideo:isVideoCall success:nil failure:nil]; + [self removePendingActionMask]; + }); + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomMemberDetailsVC] Create room failed"); + [self removePendingActionMask]; + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + } + break; + } + case MXKRoomMemberDetailsActionMention: + { + // Sanity check + if (_delegate && [_delegate respondsToSelector:@selector(roomMemberDetailsViewController:mention:)]) + { + id delegate = _delegate; + MXRoomMember *member = _mxRoomMember; + + // Withdraw the current view controller, and let the delegate mention the member + [self withdrawViewControllerAnimated:YES completion:^{ + + [delegate roomMemberDetailsViewController:self mention:member]; + + }]; + } + break; + } + default: + break; + } + } +} + +#pragma mark - Internals + +- (void)initObservers +{ + // Remove any pending observers + [self removeObservers]; + + if (mxRoom) + { + // Observe room's members update + NSArray *mxMembersEvents = @[kMXEventTypeStringRoomMember, kMXEventTypeStringRoomPowerLevels]; + self->membersListener = [mxRoom listenToEventsOfTypes:mxMembersEvents onEvent:^(MXEvent *event, MXTimelineDirection direction, id customObject) { + + // consider only live event + if (direction == MXTimelineDirectionForwards) + { + [self refreshRoomMember]; + } + }]; + + // Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room. + leaveRoomNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionWillLeaveRoomNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + // Check whether the user will leave the room related to the displayed member + if (notif.object == self.mainSession) + { + NSString *roomId = notif.userInfo[kMXSessionNotificationRoomIdKey]; + if (roomId && [roomId isEqualToString:self->mxRoom.roomId]) + { + // We must remove the current view controller. + [self withdrawViewControllerAnimated:YES completion:nil]; + } + } + }]; + + // Observe room history flush (sync with limited timeline, or state event redaction) + roomDidFlushDataNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXRoomDidFlushDataNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXRoom *room = notif.object; + if (self.mainSession == room.mxSession && [self->mxRoom.roomId isEqualToString:room.roomId]) + { + // The existing room history has been flushed during server sync. + // Take into account the updated room members list by updating the room member instance + [self refreshRoomMember]; + } + + }]; + } + + [self updateMemberInfo]; +} + +- (void)removeObservers +{ + if (leaveRoomNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:leaveRoomNotificationObserver]; + leaveRoomNotificationObserver = nil; + } + if (roomDidFlushDataNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:roomDidFlushDataNotificationObserver]; + roomDidFlushDataNotificationObserver = nil; + } + + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + if (membersListener && mxRoom) + { + MXWeakify(self); + [mxRoom liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + [liveTimeline removeListener:self->membersListener]; + self->membersListener = nil; + }]; + } +} + +- (void)refreshRoomMember +{ + // Hide potential action sheet + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + currentAlert = nil; + } + + MXRoomMember* nextRoomMember = nil; + + // get the updated memmber + NSArray *membersList = self.mxRoomLiveTimeline.state.members.members; + for (MXRoomMember* member in membersList) + { + if ([member.userId isEqualToString:_mxRoomMember.userId]) + { + nextRoomMember = member; + break; + } + } + + // does the member still exist ? + if (nextRoomMember) + { + // Refresh member + _mxRoomMember = nextRoomMember; + [self updateMemberInfo]; + } + else + { + [self withdrawViewControllerAnimated:YES completion:nil]; + } +} + +- (void)updateMemberInfo +{ + self.title = _mxRoomMember.displayname ? _mxRoomMember.displayname : _mxRoomMember.userId; + + // set the thumbnail info + self.memberThumbnail.contentMode = UIViewContentModeScaleAspectFill; + self.memberThumbnail.defaultBackgroundColor = [UIColor clearColor]; + [self.memberThumbnail.layer setCornerRadius:self.memberThumbnail.frame.size.width / 2]; + [self.memberThumbnail setClipsToBounds:YES]; + + self.memberThumbnail.mediaFolder = kMXMediaManagerAvatarThumbnailFolder; + self.memberThumbnail.enableInMemoryCache = YES; + [self.memberThumbnail setImageURI:_mxRoomMember.avatarUrl + withType:nil + andImageOrientation:UIImageOrientationUp + toFitViewSize:self.memberThumbnail.frame.size + withMethod:MXThumbnailingMethodCrop + previewImage:self.picturePlaceholder + mediaManager:self.mainSession.mediaManager]; + + self.roomMemberMatrixInfo.text = _mxRoomMember.userId; + + [self.tableView reloadData]; +} + +#pragma mark - Table view data source + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + return 1; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + // Check user's power level before allowing an action (kick, ban, ...) + MXRoomPowerLevels *powerLevels = [self.mxRoomLiveTimeline.state powerLevels]; + NSInteger memberPowerLevel = [powerLevels powerLevelOfUserWithUserID:_mxRoomMember.userId]; + NSInteger oneSelfPowerLevel = [powerLevels powerLevelOfUserWithUserID:self.mainSession.myUser.userId]; + + [actionsArray removeAllObjects]; + + // Consider the case of the user himself + if ([_mxRoomMember.userId isEqualToString:self.mainSession.myUser.userId]) + { + if (_enableLeave) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionLeave)]; + } + + if (oneSelfPowerLevel >= [powerLevels minimumPowerLevelForSendingEventAsStateEvent:kMXEventTypeStringRoomPowerLevels]) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionSetCustomPowerLevel)]; + } + } + else if (_mxRoomMember) + { + if (_enableVoipCall) + { + // Offer voip call options + [actionsArray addObject:@(MXKRoomMemberDetailsActionStartVoiceCall)]; + [actionsArray addObject:@(MXKRoomMemberDetailsActionStartVideoCall)]; + } + + // Consider membership of the selected member + switch (_mxRoomMember.membership) + { + case MXMembershipInvite: + case MXMembershipJoin: + { + // Check conditions to be able to kick someone + if (oneSelfPowerLevel >= [powerLevels kick] && oneSelfPowerLevel > memberPowerLevel) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionKick)]; + } + // Check conditions to be able to ban someone + if (oneSelfPowerLevel >= [powerLevels ban] && oneSelfPowerLevel > memberPowerLevel) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionBan)]; + } + + // Check whether the option Ignore may be presented + if (_mxRoomMember.membership == MXMembershipJoin) + { + // is he already ignored ? + if (![self.mainSession isUserIgnored:_mxRoomMember.userId]) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionIgnore)]; + } + else + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionUnignore)]; + } + } + break; + } + case MXMembershipLeave: + { + // Check conditions to be able to invite someone + if (oneSelfPowerLevel >= [powerLevels invite]) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionInvite)]; + } + // Check conditions to be able to ban someone + if (oneSelfPowerLevel >= [powerLevels ban] && oneSelfPowerLevel > memberPowerLevel) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionBan)]; + } + break; + } + case MXMembershipBan: + { + // Check conditions to be able to unban someone + if (oneSelfPowerLevel >= [powerLevels ban] && oneSelfPowerLevel > memberPowerLevel) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionUnban)]; + } + break; + } + default: + { + break; + } + } + + // update power level + if (oneSelfPowerLevel >= [powerLevels minimumPowerLevelForSendingEventAsStateEvent:kMXEventTypeStringRoomPowerLevels] && oneSelfPowerLevel > memberPowerLevel) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionSetCustomPowerLevel)]; + } + + // offer to start a new chat only if the room is not the first direct chat with this user + // it does not make sense : it would open the same room + MXRoom* directRoom = [self.mainSession directJoinedRoomWithUserId:_mxRoomMember.userId]; + if (!directRoom || (![directRoom.roomId isEqualToString:mxRoom.roomId])) + { + [actionsArray addObject:@(MXKRoomMemberDetailsActionStartChat)]; + } + } + + if (_enableMention) + { + // Add mention option + [actionsArray addObject:@(MXKRoomMemberDetailsActionMention)]; + } + + return (actionsArray.count + 1) / 2; +} + +- (NSString*)actionButtonTitle:(MXKRoomMemberDetailsAction)action +{ + NSString *title; + + switch (action) + { + case MXKRoomMemberDetailsActionInvite: + title = [MatrixKitL10n invite]; + break; + case MXKRoomMemberDetailsActionLeave: + title = [MatrixKitL10n leave]; + break; + case MXKRoomMemberDetailsActionKick: + title = [MatrixKitL10n kick]; + break; + case MXKRoomMemberDetailsActionBan: + title = [MatrixKitL10n ban]; + break; + case MXKRoomMemberDetailsActionUnban: + title = [MatrixKitL10n unban]; + break; + case MXKRoomMemberDetailsActionIgnore: + title = [MatrixKitL10n ignore]; + break; + case MXKRoomMemberDetailsActionUnignore: + title = [MatrixKitL10n unignore]; + break; + case MXKRoomMemberDetailsActionSetDefaultPowerLevel: + title = [MatrixKitL10n setDefaultPowerLevel]; + break; + case MXKRoomMemberDetailsActionSetModerator: + title = [MatrixKitL10n setModerator]; + break; + case MXKRoomMemberDetailsActionSetAdmin: + title = [MatrixKitL10n setAdmin]; + break; + case MXKRoomMemberDetailsActionSetCustomPowerLevel: + title = [MatrixKitL10n setPowerLevel]; + break; + case MXKRoomMemberDetailsActionStartChat: + title = [MatrixKitL10n startChat]; + break; + case MXKRoomMemberDetailsActionStartVoiceCall: + title = [MatrixKitL10n startVoiceCall]; + break; + case MXKRoomMemberDetailsActionStartVideoCall: + title = [MatrixKitL10n startVideoCall]; + break; + case MXKRoomMemberDetailsActionMention: + title = [MatrixKitL10n mention]; + break; + default: + break; + } + + return title; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (self.tableView == tableView) + { + NSInteger row = indexPath.row; + + MXKTableViewCellWithButtons *cell = [tableView dequeueReusableCellWithIdentifier:[MXKTableViewCellWithButtons defaultReuseIdentifier]]; + if (!cell) + { + cell = [[MXKTableViewCellWithButtons alloc] init]; + } + + cell.mxkButtonNumber = 2; + NSArray *buttons = cell.mxkButtons; + NSInteger index = row * 2; + NSString *text = nil; + for (UIButton *button in buttons) + { + NSNumber *actionNumber; + if (index < actionsArray.count) + { + actionNumber = [actionsArray objectAtIndex:index]; + } + + text = (actionNumber ? [self actionButtonTitle:actionNumber.unsignedIntegerValue] : nil); + + button.hidden = (text.length == 0); + + button.layer.borderColor = button.tintColor.CGColor; + button.layer.borderWidth = 1; + button.layer.cornerRadius = 5; + + [button setTitle:text forState:UIControlStateNormal]; + [button setTitle:text forState:UIControlStateHighlighted]; + + [button addTarget:self action:@selector(onActionButtonPressed:) forControlEvents:UIControlEventTouchUpInside]; + + button.tag = (actionNumber ? actionNumber.unsignedIntegerValue : -1); + + index ++; + } + + return cell; + } + + // Return a fake cell to prevent app from crashing. + return [[UITableViewCell alloc] init]; +} + + +#pragma mark - button management + +- (BOOL)hasPendingAction +{ + return nil != pendingMaskSpinnerView; +} + +- (void)addPendingActionMask +{ + // add a spinner above the tableview to avoid that the user tap on any other button + pendingMaskSpinnerView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; + pendingMaskSpinnerView.backgroundColor = [UIColor colorWithRed:0.5 green:0.5 blue:0.5 alpha:0.5]; + pendingMaskSpinnerView.frame = self.tableView.frame; + pendingMaskSpinnerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleTopMargin; + + // append it + [self.tableView.superview addSubview:pendingMaskSpinnerView]; + + // animate it + [pendingMaskSpinnerView startAnimating]; +} + +- (void)removePendingActionMask +{ + if (pendingMaskSpinnerView) + { + [pendingMaskSpinnerView removeFromSuperview]; + pendingMaskSpinnerView = nil; + [self.tableView reloadData]; + } +} + +- (void)setPowerLevel:(NSInteger)value promptUser:(BOOL)promptUser +{ + NSInteger currentPowerLevel = [self.mxRoomLiveTimeline.state.powerLevels powerLevelOfUserWithUserID:_mxRoomMember.userId]; + + // check if the power level has not yet been set to 0 + if (value != currentPowerLevel) + { + __weak typeof(self) weakSelf = self; + + if (promptUser && value == [self.mxRoomLiveTimeline.state.powerLevels powerLevelOfUserWithUserID:self.mainSession.myUser.userId]) + { + // If the user is setting the same power level as his to another user, ask him for a confirmation + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + currentAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n roomMemberPowerLevelPrompt] message:nil preferredStyle:UIAlertControllerStyleAlert]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n no] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + } + + }]]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n yes] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // The user confirms. Apply the power level + [self setPowerLevel:value promptUser:NO]; + } + + }]]; + + [self presentViewController:currentAlert animated:YES completion:nil]; + } + else + { + [self addPendingActionMask]; + + // Reset user power level + [self.mxRoom setPowerLevelOfUserWithUserID:_mxRoomMember.userId powerLevel:value success:^{ + + __strong __typeof(weakSelf)strongSelf = weakSelf; + [strongSelf removePendingActionMask]; + + } failure:^(NSError *error) { + + __strong __typeof(weakSelf)strongSelf = weakSelf; + [strongSelf removePendingActionMask]; + MXLogDebug(@"[MXKRoomMemberDetailsVC] Set user power (%@) failed", strongSelf.mxRoomMember.userId); + + // Notify MatrixKit user + NSString *myUserId = strongSelf.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + } +} + +- (void)updateUserPowerLevel +{ + __weak typeof(self) weakSelf = self; + + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + currentAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n powerLevel] message:nil preferredStyle:UIAlertControllerStyleAlert]; + + + if (![self.mainSession.myUser.userId isEqualToString:_mxRoomMember.userId]) + { + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n resetToDefault] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + + [self setPowerLevel:self.mxRoomLiveTimeline.state.powerLevels.usersDefault promptUser:YES]; + } + + }]]; + } + + [currentAlert addTextFieldWithConfigurationHandler:^(UITextField *textField) + { + typeof(self) self = weakSelf; + + textField.secureTextEntry = NO; + textField.text = [NSString stringWithFormat:@"%ld", (long)[self.mxRoomLiveTimeline.state.powerLevels powerLevelOfUserWithUserID:self.mxRoomMember.userId]]; + textField.placeholder = nil; + textField.keyboardType = UIKeyboardTypeDecimalPad; + }]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + NSString *text = [self->currentAlert textFields].firstObject.text; + self->currentAlert = nil; + + if (text.length > 0) + { + [self setPowerLevel:[text integerValue] promptUser:YES]; + } + } + + }]]; + + [self presentViewController:currentAlert animated:YES completion:nil]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.xib new file mode 100644 index 000000000..238c3d370 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberDetailsViewController.xib @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.h new file mode 100644 index 000000000..d35eb497b --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.h @@ -0,0 +1,116 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKViewController.h" +#import "MXKRoomMemberListDataSource.h" + +@class MXKRoomMemberListViewController; + +/** + `MXKRoomMemberListViewController` delegate. + */ +@protocol MXKRoomMemberListViewControllerDelegate + +/** + Tells the delegate that the user selected a member. + + @param roomMemberListViewController the `MXKRoomMemberListViewController` instance. + @param member the selected member. + */ +- (void)roomMemberListViewController:(MXKRoomMemberListViewController *)roomMemberListViewController didSelectMember:(MXRoomMember*)member; + +@end + + +/** + This view controller displays members of a room. Only one matrix session is handled by this view controller. + */ +@interface MXKRoomMemberListViewController : MXKViewController +{ +@protected + /** + Used to auto scroll at the top when search session is started or cancelled. + */ + BOOL shouldScrollToTopOnRefresh; +} + +@property (weak, nonatomic) IBOutlet UISearchBar *membersSearchBar; +@property (weak, nonatomic) IBOutlet UITableView *membersTableView; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *membersSearchBarTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *membersSearchBarHeightConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *membersTableViewBottomConstraint; + +/** + The current data source associated to the view controller. + */ +@property (nonatomic, readonly) MXKRoomMemberListDataSource *dataSource; + +/** + The delegate for the view controller. + */ +@property (nonatomic, weak) id delegate; + +/** + Enable the search in room members list according to the member's display name (YES by default). + Set NO this property to disable this option and hide the related bar button. + */ +@property (nonatomic) BOOL enableMemberSearch; + +/** + Enable the invitation of a new member (YES by default). + Set NO this property to disable this option and hide the related bar button. + */ +@property (nonatomic) BOOL enableMemberInvitation; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKRoomMemberListViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `roomMemberListViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRoomMemberListViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRoomMemberListViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)roomMemberListViewController; + +/** + Display the members list. + + @param listDataSource the data source providing the members list. + */ +- (void)displayList:(MXKRoomMemberListDataSource*)listDataSource; + +/** + Scroll the members list to the top. + + @param animated YES to animate the transition at a constant velocity to the new offset, NO to make the transition immediate. + */ +- (void)scrollToTop:(BOOL)animated; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.m new file mode 100644 index 000000000..217fb4758 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.m @@ -0,0 +1,571 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomMemberListViewController.h" + +#import "MXKRoomMemberTableViewCell.h" + +#import "MXKConstants.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@interface MXKRoomMemberListViewController () +{ + /** + The data source providing UITableViewCells + */ + MXKRoomMemberListDataSource *dataSource; + + /** + Timer used to update members presence + */ + NSTimer* presenceUpdateTimer; + + /** + Optional bar buttons + */ + UIBarButtonItem *searchBarButton; + UIBarButtonItem *addBarButton; + + /** + The current displayed alert (if any). + */ + UIAlertController *currentAlert; + + /** + Search bar + */ + BOOL ignoreSearchRequest; + + /** + Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room. + */ + id leaveRoomNotificationObserver; +} + +@end + +@implementation MXKRoomMemberListViewController +@synthesize dataSource; + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomMemberListViewController class]) + bundle:[NSBundle bundleForClass:[MXKRoomMemberListViewController class]]]; +} + ++ (instancetype)roomMemberListViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKRoomMemberListViewController class]) + bundle:[NSBundle bundleForClass:[MXKRoomMemberListViewController class]]]; +} + + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + // Enable both bar button by default. + _enableMemberInvitation = YES; + _enableMemberSearch = YES; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!self.membersTableView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + // Adjust Top and Bottom constraints to take into account potential navBar and tabBar. + [NSLayoutConstraint deactivateConstraints:@[_membersSearchBarTopConstraint, _membersTableViewBottomConstraint]]; + + _membersSearchBarTopConstraint = [NSLayoutConstraint constraintWithItem:self.topLayoutGuide + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self.membersSearchBar + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:0.0f]; + + _membersTableViewBottomConstraint = [NSLayoutConstraint constraintWithItem:self.bottomLayoutGuide + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.membersTableView + attribute:NSLayoutAttributeBottom + multiplier:1.0f + constant:0.0f]; + + [NSLayoutConstraint activateConstraints:@[_membersSearchBarTopConstraint, _membersTableViewBottomConstraint]]; + + // Hide search bar by default + self.membersSearchBar.hidden = YES; + self.membersSearchBarHeightConstraint.constant = 0; + [self.view setNeedsUpdateConstraints]; + + searchBarButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemSearch target:self action:@selector(search:)]; + addBarButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAdd target:self action:@selector(inviteNewMember:)]; + + // Refresh bar button display. + [self refreshUIBarButtons]; + + // Add an accessory view to the search bar in order to retrieve keyboard view. + self.membersSearchBar.inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + + // Finalize table view configuration + self.membersTableView.delegate = self; + self.membersTableView.dataSource = dataSource; // Note datasource may be nil here. + + // Set up default table view cell class + [self.membersTableView registerNib:MXKRoomMemberTableViewCell.nib forCellReuseIdentifier:MXKRoomMemberTableViewCell.defaultReuseIdentifier]; +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + + // Check whether the user still belongs to the room's members. + if (self.dataSource && [self.mainSession roomWithRoomId:self.dataSource.roomId]) + { + [self refreshUIBarButtons]; + + // Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room. + leaveRoomNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionWillLeaveRoomNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) + { + + // Check whether the user will leave the room related to the displayed member list + if (notif.object == self.mainSession) + { + NSString *roomId = notif.userInfo[kMXSessionNotificationRoomIdKey]; + if (roomId && [roomId isEqualToString:self.dataSource.roomId]) + { + // We remove the current view controller. + [self withdrawViewControllerAnimated:YES completion:nil]; + } + } + }]; + } + else + { + // We remove the current view controller. + [self withdrawViewControllerAnimated:YES completion:nil]; + } +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + // Restore search mechanism (if enabled) + ignoreSearchRequest = NO; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + // The user may still press search button whereas the view disappears + ignoreSearchRequest = YES; + + if (leaveRoomNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:leaveRoomNotificationObserver]; + leaveRoomNotificationObserver = nil; + } + + // Leave potential search session + if (!self.membersSearchBar.isHidden) + { + [self searchBarCancelButtonClicked:self.membersSearchBar]; + } +} + +- (void)dealloc +{ + self.membersSearchBar.inputAccessoryView = nil; +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; + + // Dispose of any resources that can be recreated. +} + +#pragma mark - Override MXKTableViewController + +- (void)onKeyboardShowAnimationComplete +{ + // Report the keyboard view in order to track keyboard frame changes + self.keyboardView = _membersSearchBar.inputAccessoryView.superview; +} + +- (void)setKeyboardHeight:(CGFloat)keyboardHeight +{ + // Deduce the bottom constraint for the table view (Don't forget the potential tabBar) + CGFloat tableViewBottomConst = keyboardHeight - self.bottomLayoutGuide.length; + // Check whether the keyboard is over the tabBar + if (tableViewBottomConst < 0) + { + tableViewBottomConst = 0; + } + + // Update constraints + _membersTableViewBottomConstraint.constant = tableViewBottomConst; + + // Force layout immediately to take into account new constraint + [self.view layoutIfNeeded]; +} + +- (void)destroy +{ + if (presenceUpdateTimer) + { + [presenceUpdateTimer invalidate]; + presenceUpdateTimer = nil; + } + + self.membersTableView.dataSource = nil; + self.membersTableView.delegate = nil; + self.membersTableView = nil; + dataSource.delegate = nil; + dataSource = nil; + + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + currentAlert = nil; + } + + searchBarButton = nil; + addBarButton = nil; + + _delegate = nil; + + [super destroy]; +} + +#pragma mark - Internal methods + +- (void)updateMembersActivityInfo +{ + for (id memberCell in self.membersTableView.visibleCells) + { + if ([memberCell respondsToSelector:@selector(updateActivityInfo)]) + { + [memberCell updateActivityInfo]; + } + } +} + +#pragma mark - UIBarButton handling + +- (void)setEnableMemberSearch:(BOOL)enableMemberSearch +{ + _enableMemberSearch = enableMemberSearch; + [self refreshUIBarButtons]; +} + +- (void)setEnableMemberInvitation:(BOOL)enableMemberInvitation +{ + _enableMemberInvitation = enableMemberInvitation; + [self refreshUIBarButtons]; +} + +- (void)refreshUIBarButtons +{ + MXRoom *mxRoom = [self.mainSession roomWithRoomId:dataSource.roomId]; + + MXWeakify(self); + [mxRoom state:^(MXRoomState *roomState) { + MXStrongifyAndReturnIfNil(self); + + BOOL showInvitationOption = self.enableMemberInvitation; + + if (showInvitationOption && self->dataSource) + { + // Check conditions to be able to invite someone + NSInteger oneSelfPowerLevel = [roomState.powerLevels powerLevelOfUserWithUserID:self.mainSession.myUser.userId]; + if (oneSelfPowerLevel < [roomState.powerLevels invite]) + { + showInvitationOption = NO; + } + } + + if (showInvitationOption) + { + if (self.enableMemberSearch) + { + self.navigationItem.rightBarButtonItems = @[self->searchBarButton, self->addBarButton]; + } + else + { + self.navigationItem.rightBarButtonItems = @[self->addBarButton]; + } + } + else if (self.enableMemberSearch) + { + self.navigationItem.rightBarButtonItems = @[self->searchBarButton]; + } + else + { + self.navigationItem.rightBarButtonItems = nil; + } + }]; +} + +#pragma mark - +- (void)displayList:(MXKRoomMemberListDataSource *)listDataSource +{ + if (dataSource) + { + dataSource.delegate = nil; + dataSource = nil; + [self removeMatrixSession:self.mainSession]; + } + + dataSource = listDataSource; + dataSource.delegate = self; + + // Report the matrix session at view controller level to update UI according to session state + [self addMatrixSession:dataSource.mxSession]; + + if (self.membersTableView) + { + // Set up table data source + self.membersTableView.dataSource = dataSource; + } +} + +- (void)scrollToTop:(BOOL)animated +{ + [self.membersTableView setContentOffset:CGPointMake(-self.membersTableView.adjustedContentInset.left, -self.membersTableView.adjustedContentInset.top) animated:animated]; +} + +#pragma mark - MXKDataSourceDelegate + +- (Class)cellViewClassForCellData:(MXKCellData*)cellData +{ + // Return the default member table view cell + return MXKRoomMemberTableViewCell.class; +} + +- (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData +{ + // Consider the default member table view cell + return MXKRoomMemberTableViewCell.defaultReuseIdentifier; +} + +- (void)dataSource:(MXKDataSource *)dataSource didCellChange:(id)changes +{ + if (presenceUpdateTimer) + { + [presenceUpdateTimer invalidate]; + presenceUpdateTimer = nil; + } + + // For now, do a simple full reload + [self.membersTableView reloadData]; + + if (shouldScrollToTopOnRefresh) + { + [self scrollToTop:NO]; + shouldScrollToTopOnRefresh = NO; + } + + // Place a timer to update members's activity information + presenceUpdateTimer = [NSTimer scheduledTimerWithTimeInterval:5 target:self selector:@selector(updateMembersActivityInfo) userInfo:self repeats:YES]; +} + +#pragma mark - UITableView delegate +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + return [dataSource cellHeightAtIndex:indexPath.row]; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (_delegate) + { + id cellData = [dataSource cellDataAtIndex:indexPath.row]; + + [_delegate roomMemberListViewController:self didSelectMember:cellData.roomMember]; + } + [tableView deselectRowAtIndexPath:indexPath animated:NO]; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section +{ + return 0; +} + +- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath*)indexPath +{ + // Release here resources, and restore reusable cells + if ([cell respondsToSelector:@selector(didEndDisplay)]) + { + [(id)cell didEndDisplay]; + } +} + +#pragma mark - UISearchBarDelegate + +- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText +{ + // Apply filter + shouldScrollToTopOnRefresh = YES; + if (searchText.length) + { + [self.dataSource searchWithPatterns:@[searchText]]; + } + else + { + [self.dataSource searchWithPatterns:nil]; + } +} + +- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar +{ + // "Done" key has been pressed + [searchBar resignFirstResponder]; +} + +- (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar +{ + // Leave search + [searchBar resignFirstResponder]; + + self.membersSearchBar.hidden = YES; + self.membersSearchBarHeightConstraint.constant = 0; + [self.view setNeedsUpdateConstraints]; + + self.membersSearchBar.text = nil; + + // Refresh display + shouldScrollToTopOnRefresh = YES; + [self.dataSource searchWithPatterns:nil]; +} + +#pragma mark - Actions + +- (void)search:(id)sender +{ + // The user may have pressed search button whereas the view controller was disappearing + if (ignoreSearchRequest) + { + return; + } + + if (self.membersSearchBar.isHidden) + { + // Check whether there are data in which search + if ([self.dataSource tableView:self.membersTableView numberOfRowsInSection:0]) + { + self.membersSearchBar.hidden = NO; + self.membersSearchBarHeightConstraint.constant = 44; + [self.view setNeedsUpdateConstraints]; + + // Create search bar + [self.membersSearchBar becomeFirstResponder]; + } + } + else + { + [self searchBarCancelButtonClicked: self.membersSearchBar]; + } +} + +- (void)inviteNewMember:(id)sender +{ + __weak typeof(self) weakSelf = self; + + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + // Ask for userId to invite + currentAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n userIdTitle] message:nil preferredStyle:UIAlertControllerStyleAlert]; + + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + } + + }]]; + + + [currentAlert addTextFieldWithConfigurationHandler:^(UITextField *textField) + { + textField.secureTextEntry = NO; + textField.placeholder = [MatrixKitL10n userIdPlaceholder]; + }]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n invite] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + NSString *userId = [self->currentAlert textFields].firstObject.text; + + self->currentAlert = nil; + + if (userId.length) + { + MXRoom *mxRoom = [self.mainSession roomWithRoomId:self.dataSource.roomId]; + if (mxRoom) + { + [mxRoom inviteUser:userId success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Invite %@ failed", userId); + // Notify MatrixKit user + NSString *myUserId = self.mainSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + } + } + + }]]; + + [self presentViewController:currentAlert animated:YES completion:nil]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.xib new file mode 100644 index 000000000..4a339bdad --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomMemberListViewController.xib @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.h new file mode 100644 index 000000000..663101028 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.h @@ -0,0 +1,81 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKTableViewController.h" + +/** + This view controller displays the room settings. + */ +@interface MXKRoomSettingsViewController : MXKTableViewController +{ +@protected + // the dedicated room + MXRoom* mxRoom; + + // the room state + MXRoomState* mxRoomState; +} + +/** + The dedicated roomId. + */ +@property (nonatomic, readonly) NSString *roomId; + + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKRoomSettingsViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `roomViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRoomSettingsViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRoomSettingsViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)roomSettingsViewController; + +/** + Set the dedicated session and the room Id + */ +- (void)initWithSession:(MXSession*)session andRoomId:(NSString*)roomId; + +/** + Refresh the displayed room settings. By default this method reload the table view. + + @discusion You may override this method to handle the table refresh. + */ +- (void)refreshRoomSettings; + +/** + Updates the display with a new room state. + + @param newRoomState the new room state. + */ +- (void)updateRoomState:(MXRoomState*)newRoomState; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.m new file mode 100644 index 000000000..8f729e9b9 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.m @@ -0,0 +1,215 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomSettingsViewController.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@interface MXKRoomSettingsViewController() +{ + // the room events listener + id roomListener; + + // Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room. + id leaveRoomNotificationObserver; + + // Observe kMXRoomDidFlushDataNotification to take into account the updated room state when the room history is flushed. + id roomDidFlushDataNotificationObserver; +} +@end + +@implementation MXKRoomSettingsViewController + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomSettingsViewController class]) + bundle:[NSBundle bundleForClass:[MXKRoomSettingsViewController class]]]; +} + ++ (instancetype)roomSettingsViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKRoomSettingsViewController class]) + bundle:[NSBundle bundleForClass:[MXKRoomSettingsViewController class]]]; +} + +#pragma mark - + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + [self refreshRoomSettings]; +} + +#pragma mark - Override MXKTableViewController + +- (void)finalizeInit +{ + [super finalizeInit]; +} + +- (void)destroy +{ + if (roomListener) + { + MXWeakify(self); + [mxRoom liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + [liveTimeline removeListener:self->roomListener]; + self->roomListener = nil; + }]; + } + + if (leaveRoomNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:leaveRoomNotificationObserver]; + leaveRoomNotificationObserver = nil; + } + + if (roomDidFlushDataNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:roomDidFlushDataNotificationObserver]; + roomDidFlushDataNotificationObserver = nil; + } + + mxRoom = nil; + mxRoomState = nil; + + [super destroy]; +} + +- (void)onMatrixSessionStateDidChange:(NSNotification *)notif; +{ + // Check this is our Matrix session that has changed + if (notif.object == self.mainSession) + { + [super onMatrixSessionStateDidChange:notif]; + + // refresh when the session sync is done. + if (MXSessionStateRunning == self.mainSession.state) + { + [self refreshRoomSettings]; + } + } +} + +#pragma mark - Public API + +/** + Set the dedicated session and the room Id + */ +- (void)initWithSession:(MXSession*)mxSession andRoomId:(NSString*)roomId +{ + // Update the matrix session + if (self.mainSession) + { + [self removeMatrixSession:self.mainSession]; + } + mxRoom = nil; + + // Sanity checks + if (mxSession && roomId) + { + [self addMatrixSession:mxSession]; + + // Report the room identifier + _roomId = roomId; + mxRoom = [mxSession roomWithRoomId:roomId]; + } + + if (mxRoom) + { + // Register a listener to handle messages related to room name, topic... + MXWeakify(self); + [mxRoom liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + self->roomListener = [liveTimeline listenToEventsOfTypes:@[kMXEventTypeStringRoomName, kMXEventTypeStringRoomTopic, kMXEventTypeStringRoomAliases, kMXEventTypeStringRoomAvatar, kMXEventTypeStringRoomPowerLevels, kMXEventTypeStringRoomCanonicalAlias, kMXEventTypeStringRoomJoinRules, kMXEventTypeStringRoomGuestAccess, kMXEventTypeStringRoomHistoryVisibility] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + + // Consider only live events + if (direction == MXTimelineDirectionForwards) + { + [self updateRoomState:liveTimeline.state]; + } + }]; + + // Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room. + self->leaveRoomNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionWillLeaveRoomNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + // Check whether the user will leave the room related to the displayed participants + if (notif.object == self.mainSession) + { + NSString *roomId = notif.userInfo[kMXSessionNotificationRoomIdKey]; + if (roomId && [roomId isEqualToString:self.roomId]) + { + // We remove the current view controller. + [self withdrawViewControllerAnimated:YES completion:nil]; + } + } + }]; + + // Observe room history flush (sync with limited timeline, or state event redaction) + self->roomDidFlushDataNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXRoomDidFlushDataNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXRoom *room = notif.object; + if (self.mainSession == room.mxSession && [self.roomId isEqualToString:room.roomId]) + { + // The existing room history has been flushed during server sync. Take into account the updated room state. + [self updateRoomState:liveTimeline.state]; + } + + }]; + + [self updateRoomState:liveTimeline.state]; + }]; + } + + self.title = [MatrixKitL10n roomDetailsTitle]; +} + +- (void)refreshRoomSettings +{ + [self.tableView reloadData]; +} + +- (void)updateRoomState:(MXRoomState*)newRoomState +{ + mxRoomState = newRoomState.copy; + + [self refreshRoomSettings]; +} + +#pragma mark - UITableViewDataSource + +// empty by default + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + return 0; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + // Return a fake cell to prevent app from crashing. + return [[UITableViewCell alloc] init]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.xib new file mode 100644 index 000000000..4dbf0f338 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomSettingsViewController.xib @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.h new file mode 100644 index 000000000..8ae86eccb --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.h @@ -0,0 +1,440 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKViewController.h" +#import "MXKRoomDataSource.h" +#import "MXKRoomTitleView.h" +#import "MXKRoomInputToolbarView.h" +#import "MXKRoomActivitiesView.h" +#import "MXKEventDetailsView.h" + +#import "MXKAttachmentsViewController.h" +#import "MXKAttachmentAnimator.h" + +typedef NS_ENUM(NSUInteger, MXKRoomViewControllerJoinRoomResult) { + MXKRoomViewControllerJoinRoomResultSuccess, + MXKRoomViewControllerJoinRoomResultFailureRoomEmpty, + MXKRoomViewControllerJoinRoomResultFailureJoinInProgress, + MXKRoomViewControllerJoinRoomResultFailureGeneric +}; + +/** + This view controller displays messages of a room. Only one matrix session is handled by this view controller. + */ +@interface MXKRoomViewController : MXKViewController +{ +@protected + /** + The identifier of the current event displayed at the bottom of the table (just above the toolbar). + Use to anchor the message displayed at the bottom during table refresh. + */ + NSString *currentEventIdAtTableBottom; + + /** + Boolean value used to scroll to bottom the bubble history after refresh. + */ + BOOL shouldScrollToBottomOnTableRefresh; + + /** + Potential event details view. + */ + __weak MXKEventDetailsView *eventDetailsView; + + /** + Current alert (if any). + */ + __weak UIAlertController *currentAlert; + + /** + The document interaction Controller used to share attachment + */ + UIDocumentInteractionController *documentInteractionController; + + /** + The current shared attachment. + */ + MXKAttachment *currentSharedAttachment; + + /** + The potential text input placeholder is saved when it is replaced temporarily + */ + NSString *savedInputToolbarPlaceholder; + + /** + Tell whether the input toolbar required to run an animation indicator. + */ + BOOL isInputToolbarProcessing; + + /** + Tell whether a device rotation is in progress + */ + BOOL isSizeTransitionInProgress; + + /** + The current visibility of the status bar in this view controller. + */ + BOOL isStatusBarHidden; + + /** + YES to prevent `bubblesTableView` scrolling when calling -[setBubbleTableViewContentOffset:animated:] + */ + BOOL preventBubblesTableViewScroll; +} + +/** + The current data source associated to the view controller. + */ +@property (nonatomic, readonly) MXKRoomDataSource *roomDataSource; + +/** + Flag indicating if this instance has the memory ownership of its `roomDataSource`. + If YES, it will release it on [self destroy] call; + Default is NO. + */ +@property (nonatomic) BOOL hasRoomDataSourceOwnership; + +/** + Tell whether the bubbles table view display is in transition. Its display is not warranty during the transition. + */ +@property (nonatomic, getter=isBubbleTableViewDisplayInTransition) BOOL bubbleTableViewDisplayInTransition; + +/** + Tell whether the automatic events acknowledgement (based on read receipt) is enabled. + Default is YES. + */ +@property (nonatomic, getter=isEventsAcknowledgementEnabled) BOOL eventsAcknowledgementEnabled; + +/** + Tell whether the room read marker must be updated when an event is acknowledged with a read receipt. + Default is NO. + */ +@property (nonatomic) BOOL updateRoomReadMarker; + +/** + When the room view controller displays a room data source based on a timeline with an initial event, + the bubble table view content is scrolled by default to display the top of this event at the center of the screen + the first time it appears. + Use this property to force the table view to center its content on the bottom part of the event. + Default is NO. + */ +@property (nonatomic) BOOL centerBubblesTableViewContentOnTheInitialEventBottom; + +/** + The current title view defined into the view controller. + */ +@property (nonatomic, weak, readonly) MXKRoomTitleView* titleView; + +/** + The current input toolbar view defined into the view controller. + */ +@property (nonatomic, weak, readonly) MXKRoomInputToolbarView* inputToolbarView; + +/** + The current extra info view defined into the view controller. + */ +@property (nonatomic, readonly) MXKRoomActivitiesView* activitiesView; + +/** + The threshold used to trigger inconspicuous back pagination, or forwards pagination + for non live timeline. A pagination is triggered when the vertical content offset + is lower this threshold. + Default is 300. + */ +@property (nonatomic) NSUInteger paginationThreshold; + +/** + The maximum number of messages to retrieve during a pagination. Default is 30. + */ +@property (nonatomic) NSUInteger paginationLimit; + +/** + Enable/disable saving of the current typed text in message composer when view disappears. + The message composer is prefilled with this text when the room is opened again. + This property value is YES by default. + */ +@property BOOL saveProgressTextInput; + +/** + The invited rooms can be automatically joined when the data source is ready. + This property enable/disable this option. Its value is YES by default. + */ +@property BOOL autoJoinInvitedRoom; + +/** + Tell whether the room history is automatically scrolled to the most recent messages + when a keyboard is presented. YES by default. + This option is ignored when an alert is presented. + */ +@property BOOL scrollHistoryToTheBottomOnKeyboardPresentation; + +/** + YES (default) to show actions button in document preview. NO otherwise. + */ +@property BOOL allowActionsInDocumentPreview; + +/** + Duration of the animation in case of the composer needs to be resized (default 0.3s) + */ +@property NSTimeInterval resizeComposerAnimationDuration; + +/** + This object is defined when the displayed room is left. It is added into the bubbles table header. + This label is used to display the reason why the room has been left. + */ +@property (nonatomic, weak, readonly) UILabel *leftRoomReasonLabel; + +@property (weak, nonatomic) IBOutlet UITableView *bubblesTableView; +@property (weak, nonatomic) IBOutlet UIView *roomTitleViewContainer; +@property (weak, nonatomic) IBOutlet UIView *roomInputToolbarContainer; +@property (weak, nonatomic) IBOutlet UIView *roomActivitiesContainer; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *bubblesTableViewTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *bubblesTableViewBottomConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *roomActivitiesContainerHeightConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *roomInputToolbarContainerHeightConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *roomInputToolbarContainerBottomConstraint; + +#pragma mark - Class methods + +/** + Returns the `UINib` object initialized for a `MXKRoomViewController`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `roomViewController` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRoomViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRoomViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)roomViewController; + +/** + Display a room. + + @param dataSource the data source . + */ +- (void)displayRoom:(MXKRoomDataSource*)dataSource; + +/** + This method is called when the associated data source is ready. + + By default this operation triggers the initial back pagination when the user is an actual + member of the room (membership = join). + + The invited rooms are automatically joined during this operation if 'autoJoinInvitedRoom' is YES. + When the room is successfully joined, an initial back pagination is triggered too. + Else nothing is done for the invited rooms. + + Override it to customize the view controller behavior when the data source is ready. + */ +- (void)onRoomDataSourceReady; + +/** + Update view controller appearance according to the state of its associated data source. + This method is called in the following use cases: + - on data source change (see `[MXKRoomViewController displayRoom:]`). + - on data source state change (see `[MXKDataSourceDelegate dataSource:didStateChange:]`) + - when view did appear. + + The default implementation: + - show input toolbar view if the dataSource is defined and ready (`MXKDataSourceStateReady`), hide toolbar in others use cases. + - stop activity indicator if the dataSource is defined and ready (`MXKDataSourceStateReady`). + - update view controller title with room information. + + Override it to customize view appearance according to data source state. + */ +- (void)updateViewControllerAppearanceOnRoomDataSourceState; + +/** + This method is called when the associated data source has encountered an error on the timeline. + + Override it to customize the view controller behavior. + + @param notif the notification data sent with kMXKRoomDataSourceTimelineError notif. + */ +- (void)onTimelineError:(NSNotification *)notif; + +/** + Join the current displayed room. + + This operation fails if the user has already joined the room, or if the data source is not ready. + It fails if a join request is already running too. + + @param completion the block to execute at the end of the operation. + You may specify nil for this parameter. + */ +- (void)joinRoom:(void(^)(MXKRoomViewControllerJoinRoomResult result))completion; + +/** + Join a room with a room id or an alias. + + This operation fails if the user has already joined the room, or if the data source is not ready, + or if the access to the room is forbidden to the user. + It fails if a join request is already running too. + + @param roomIdOrAlias the id or the alias of the room to join. + @param viaServers The server names to try and join through in addition to those that are automatically chosen. It is optional and can be nil. + @param signUrl the signurl paramater passed with a 3PID invitation. It is optional and can be nil. + + @param completion the block to execute at the end of the operation. + You may specify nil for this parameter. + */ +- (void)joinRoomWithRoomIdOrAlias:(NSString*)roomIdOrAlias + viaServers:(NSArray*)viaServers + andSignUrl:(NSString*)signUrl + completion:(void(^)(MXKRoomViewControllerJoinRoomResult result))completion; + +/** + Update view controller appearance when the user is about to leave the displayed room. + This method is called when the user will leave the current room (see `kMXSessionWillLeaveRoomNotification`). + + The default implementation: + - discard `roomDataSource` + - hide input toolbar view + - freeze the room title display + - add a label (`leftRoomReasonLabel`) in bubbles table header to display the reason why the room has been left. + + Override it to customize view appearance, or to withdraw the view controller. + + @param event the MXEvent responsible for the leaving. + */ +- (void)leaveRoomOnEvent:(MXEvent*)event; + +/** + Register the class used to instantiate the title view which will handle the room name display. + + The resulting view is added into 'roomTitleViewContainer' view, which must be defined before calling this method. + + Note: By default the room name is displayed by using 'navigationItem.title' field of the view controller. + + @param roomTitleViewClass a MXKRoomTitleView-inherited class. + */ +- (void)setRoomTitleViewClass:(Class)roomTitleViewClass; + +/** + Register the class used to instantiate the input toolbar view which will handle message composer + and attachments selection for the room. + + The resulting view is added into 'roomInputToolbarContainer' view, which must be defined before calling this method. + + @param roomInputToolbarViewClass a MXKRoomInputToolbarView-inherited class, or nil to remove the current view. + */ +- (void)setRoomInputToolbarViewClass:(Class)roomInputToolbarViewClass; + +/** + Register the class used to instantiate the extra info view. + + The resulting view is added into 'roomActivitiesContainer' view, which must be defined before calling this method. + + @param roomActivitiesViewClass a MXKRoomActivitiesViewClass-inherited class, or nil to remove the current view. + */ +- (void)setRoomActivitiesViewClass:(Class)roomActivitiesViewClass; + +/** + Register the class used to instantiate the viewer dedicated to the attachments with thumbnail. + By default 'MXKAttachmentsViewController' class is used. + + @param attachmentsViewerClass a MXKAttachmentsViewController-inherited class, or nil to restore the default class. + */ +- (void)setAttachmentsViewerClass:(Class)attachmentsViewerClass; + +/** + Register the view class used to display the details of an event. + MXKEventDetailsView is used by default. + + @param eventDetailsViewClass a MXKEventDetailsView-inherited class. + */ +- (void)setEventDetailsViewClass:(Class)eventDetailsViewClass; + +/** + Detect and process potential IRC command in provided string. + + @param string to analyse + @return YES if IRC style command has been detected and interpreted. + */ +- (BOOL)isIRCStyleCommand:(NSString*)string; + +/** + Mention the member display name in the current text of the message composer. + The message composer becomes then the first responder. + */ +- (void)mention:(MXRoomMember*)roomMember; + +/** + Force to dismiss keyboard if any + */ +- (void)dismissKeyboard; + +/** + Tell whether the most recent message of the room history is visible. + */ +- (BOOL)isBubblesTableScrollViewAtTheBottom; + +/** + Scroll the room history until the most recent message. + */ +- (void)scrollBubblesTableViewToBottomAnimated:(BOOL)animated; + +/** + Dismiss the keyboard and all the potential subviews. + */ +- (void)dismissTemporarySubViews; + +/** + Display a popup with the event detais. + + @param event the event to inspect. + */ +- (void)showEventDetails:(MXEvent *)event; + +/** + Present the attachments viewer by displaying the attachment of the provided cell. + + @param cell the table view cell with attachment + */ +- (void)showAttachmentInCell:(UITableViewCell*)cell; + +/** + Force a refresh of the room history display. + + You should not call this method directly. + You may override it in inherited 'MXKRoomViewController' class. + + @param useBottomAnchor tells whether the updated history must keep display the same event at the bottom. + @return a boolean value which tells whether the table has been scrolled to the bottom. + */ +- (BOOL)reloadBubblesTable:(BOOL)useBottomAnchor; + +/** + Sets the offset from the content `bubblesTableView`'s origin. Take into account `preventBubblesTableViewScroll` value. + + @param contentOffset Offset from the content `bubblesTableView`’s origin. + @param animated YES to animate the transition. + */ +- (void)setBubbleTableViewContentOffset:(CGPoint)contentOffset animated:(BOOL)animated; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.m new file mode 100644 index 000000000..382373a92 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.m @@ -0,0 +1,4066 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#define MXKROOMVIEWCONTROLLER_DEFAULT_TYPING_TIMEOUT_SEC 10 +#define MXKROOMVIEWCONTROLLER_MESSAGES_TABLE_MINIMUM_HEIGHT 50 + +#import "MXKRoomViewController.h" + +#import + +#import "MXKRoomBubbleTableViewCell.h" +#import "MXKSearchTableViewCell.h" +#import "MXKImageView.h" + +#import "MXKRoomDataSourceManager.h" + +#import "MXKRoomInputToolbarViewWithSimpleTextView.h" + +#import "MXKConstants.h" + +#import "MXKRoomBubbleCellData.h" + +#import "MXKRoomIncomingTextMsgBubbleCell.h" +#import "MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.h" +#import "MXKRoomIncomingAttachmentBubbleCell.h" +#import "MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.h" + +#import "MXKRoomOutgoingTextMsgBubbleCell.h" +#import "MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.h" +#import "MXKRoomOutgoingAttachmentBubbleCell.h" +#import "MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.h" + +#import "MXKEncryptionKeysImportView.h" + +#import "NSBundle+MatrixKit.h" +#import "MXKSlashCommands.h" +#import "MXKSwiftHeader.h" + +#import "MXKPreviewViewController.h" + +@interface MXKRoomViewController () +{ + /** + YES once the view has appeared + */ + BOOL hasAppearedOnce; + + /** + YES if scrolling to bottom is in progress + */ + BOOL isScrollingToBottom; + + /** + Date of the last observed typing + */ + NSDate *lastTypingDate; + + /** + Local typing timout + */ + NSTimer *typingTimer; + + /** + YES when pagination is in progress. + */ + BOOL isPaginationInProgress; + + /** + The back pagination spinner view. + */ + UIView* backPaginationActivityView; + + /** + Store the height of the first bubble before back pagination. + */ + CGFloat backPaginationSavedFirstBubbleHeight; + + /** + Potential request in progress to join the selected room + */ + MXHTTPOperation *joinRoomRequest; + + /** + Text selection + */ + NSString *selectedText; + + /** + The class used to instantiate attachments viewer for image and video.. + */ + Class attachmentsViewerClass; + + /** + The class used to display event details. + */ + Class customEventDetailsViewClass; + + /** + The reconnection animated view. + */ + UIView* reconnectingView; + + /** + The view to import e2e keys. + */ + MXKEncryptionKeysImportView *importView; + + /** + The latest server sync date + */ + NSDate* latestServerSync; + + /** + The restart the event connnection + */ + BOOL restartConnection; +} + +/** + The eventId of the Attachment that was used to open the Attachments ViewController + */ +@property (nonatomic) NSString *openedAttachmentEventId; + +/** + The eventId of the Attachment from which the Attachments ViewController was closed + */ +@property (nonatomic) NSString *closedAttachmentEventId; + +@property (nonatomic) UIImageView *openedAttachmentImageView; + +/** + Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room. + */ +@property (nonatomic, weak) id mxSessionWillLeaveRoomNotificationObserver; + +/** + Observe UIApplicationDidBecomeActiveNotification to refresh bubbles when app leaves the background state. + */ +@property (nonatomic, weak) id uiApplicationDidBecomeActiveNotificationObserver; + +/** + Observe UIMenuControllerDidHideMenuNotification to cancel text selection + */ +@property (nonatomic, weak) id uiMenuControllerDidHideMenuNotificationObserver; + +/** + The attachments viewer for image and video. + */ +@property (nonatomic, weak) MXKAttachmentsViewController *attachmentsViewer; + +@end + +@implementation MXKRoomViewController +@synthesize roomDataSource, titleView, inputToolbarView, activitiesView; + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomViewController class]) + bundle:[NSBundle bundleForClass:[MXKRoomViewController class]]]; +} + ++ (instancetype)roomViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKRoomViewController class]) + bundle:[NSBundle bundleForClass:[MXKRoomViewController class]]]; +} + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + // Scroll to bottom the bubble history at first display + shouldScrollToBottomOnTableRefresh = YES; + + // Default pagination settings + _paginationThreshold = 300; + _paginationLimit = 30; + + // Save progress text input by default + _saveProgressTextInput = YES; + + // Enable auto join option by default + _autoJoinInvitedRoom = YES; + + // Do not take ownership of room data source by default + _hasRoomDataSourceOwnership = NO; + + // Turn on the automatic events acknowledgement. + _eventsAcknowledgementEnabled = YES; + + // Do not update the read marker by default. + _updateRoomReadMarker = NO; + + // Center the table content on the initial event top by default. + _centerBubblesTableViewContentOnTheInitialEventBottom = NO; + + // Scroll to the bottom when a keyboard is presented + _scrollHistoryToTheBottomOnKeyboardPresentation = YES; + + // Keep visible the status bar by default. + isStatusBarHidden = NO; + + // By default actions button is shown in document preview + _allowActionsInDocumentPreview = YES; + + // By default the duration of the composer resizing is 0.3s + _resizeComposerAnimationDuration = 0.3; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!_bubblesTableView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + // Adjust bottom constraint of the input toolbar container in order to take into account potential tabBar + _roomInputToolbarContainerBottomConstraint.active = NO; + _roomInputToolbarContainerBottomConstraint = [NSLayoutConstraint constraintWithItem:self.bottomLayoutGuide + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.roomInputToolbarContainer + attribute:NSLayoutAttributeBottom + multiplier:1.0f + constant:0.0f]; + _roomInputToolbarContainerBottomConstraint.active = YES; + [self.view setNeedsUpdateConstraints]; + + // Hide bubbles table by default in order to hide initial scrolling to the bottom + _bubblesTableView.hidden = YES; + + // Ensure that the titleView will be scaled when it will be required + // during a screen rotation for example. + _roomTitleViewContainer.autoresizingMask = UIViewAutoresizingFlexibleWidth; + + // Set default input toolbar view + [self setRoomInputToolbarViewClass:MXKRoomInputToolbarViewWithSimpleTextView.class]; + + // set the default extra + [self setRoomActivitiesViewClass:MXKRoomActivitiesView.class]; + + // Finalize table view configuration + [self configureBubblesTableView]; + + // Observe UIApplicationDidBecomeActiveNotification to refresh bubbles when app leaves the background state. + MXWeakify(self); + _uiApplicationDidBecomeActiveNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidBecomeActiveNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXStrongifyAndReturnIfNil(self); + if (self->roomDataSource.state == MXKDataSourceStateReady && [self->roomDataSource tableView:self->_bubblesTableView numberOfRowsInSection:0]) + { + // Reload the full table + self.bubbleTableViewDisplayInTransition = YES; + [self reloadBubblesTable:YES]; + self.bubbleTableViewDisplayInTransition = NO; + } + }]; + + if ([MXKAppSettings standardAppSettings].outboundGroupSessionKeyPreSharingStrategy == MXKKeyPreSharingWhenEnteringRoom) + { + [self shareEncryptionKeys]; + } +} + +- (BOOL)prefersStatusBarHidden +{ + // Return the current status bar visibility. + // Caution: Enable [UIViewController prefersStatusBarHidden] use at application level + // by turning on UIViewControllerBasedStatusBarAppearance in Info.plist. + return isStatusBarHidden; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + // Observe server sync process at room data source level too + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMatrixSessionChange) name:kMXKRoomDataSourceSyncStatusChanged object:nil]; + + // Observe timeline failure + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onTimelineError:) name:kMXKRoomDataSourceTimelineError object:nil]; + + // Observe the server sync + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onSyncNotification) name:kMXSessionDidSyncNotification object:nil]; + + // Be sure to display the activity indicator during back pagination + if (isPaginationInProgress) + { + [self startActivityIndicator]; + } + + // Finalize view controller appearance + [self updateViewControllerAppearanceOnRoomDataSourceState]; + + // no need to reload the tableview at this stage + // IOS is going to load it after calling this method + // so give a breath to scroll to the bottom if required + if (shouldScrollToBottomOnTableRefresh) + { + self.bubbleTableViewDisplayInTransition = YES; + + dispatch_async(dispatch_get_main_queue(), ^{ + + [self scrollBubblesTableViewToBottomAnimated:NO]; + + // Show bubbles table after initial scrolling to the bottom + // Patch: We need to delay this operation to wait for the end of scrolling. + dispatch_after(dispatch_walltime(DISPATCH_TIME_NOW, 0.3 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ + + self->_bubblesTableView.hidden = NO; + self.bubbleTableViewDisplayInTransition = NO; + + }); + + }); + } + else + { + _bubblesTableView.hidden = NO; + } +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + + // Remove the rounded bottom unsafe area of the iPhone X + _bubblesTableViewBottomConstraint.constant += self.view.safeAreaInsets.bottom; + + if (_saveProgressTextInput && roomDataSource) + { + // Retrieve the potential message partially typed during last room display. + // Note: We have to wait for viewDidAppear before updating growingTextView (viewWillAppear is too early) + inputToolbarView.textMessage = roomDataSource.partialTextMessage; + } + + if (!hasAppearedOnce) + { + hasAppearedOnce = YES; + } + + // Mark all messages as read when the room is displayed + [self.roomDataSource.room.summary markAllAsReadLocally]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXKRoomDataSourceSyncStatusChanged object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXKRoomDataSourceTimelineError object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidSyncNotification object:nil]; + + [self removeReconnectingView]; +} + +- (void)dealloc +{ + if (_mxSessionWillLeaveRoomNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:_mxSessionWillLeaveRoomNotificationObserver]; + } + + if (_uiApplicationDidBecomeActiveNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:_uiApplicationDidBecomeActiveNotificationObserver]; + } + + if (_uiMenuControllerDidHideMenuNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:_uiMenuControllerDidHideMenuNotificationObserver]; + } + + [self destroy]; +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; + + // Dispose of any resources that can be recreated. +} + +- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id )coordinator +{ + isSizeTransitionInProgress = YES; + shouldScrollToBottomOnTableRefresh = [self isBubblesTableScrollViewAtTheBottom]; + + [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(coordinator.transitionDuration * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + + if (!self.keyboardView) + { + [self updateMessageTextViewFrame]; + } + + // Force full table refresh to take into account cell width change. + self.bubbleTableViewDisplayInTransition = YES; + [self reloadBubblesTable:YES invalidateBubblesCellDataCache:YES]; + self.bubbleTableViewDisplayInTransition = NO; + + self->shouldScrollToBottomOnTableRefresh = NO; + self->isSizeTransitionInProgress = NO; + }); +} + +// The 2 following methods are deprecated since iOS 8 +- (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration +{ + isSizeTransitionInProgress = YES; + shouldScrollToBottomOnTableRefresh = [self isBubblesTableScrollViewAtTheBottom]; + + [super willRotateToInterfaceOrientation:toInterfaceOrientation duration:duration]; +} +- (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation +{ + [super didRotateFromInterfaceOrientation:fromInterfaceOrientation]; + + if (!self.keyboardView) + { + [self updateMessageTextViewFrame]; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + // Force full table refresh to take into account cell width change. + self.bubbleTableViewDisplayInTransition = YES; + [self reloadBubblesTable:YES]; + self.bubbleTableViewDisplayInTransition = NO; + + self->shouldScrollToBottomOnTableRefresh = NO; + self->isSizeTransitionInProgress = NO; + }); +} + +- (void)viewDidLayoutSubviews +{ + [super viewDidLayoutSubviews]; + + CGFloat bubblesTableViewBottomConst = self.roomInputToolbarContainerBottomConstraint.constant + self.roomInputToolbarContainerHeightConstraint.constant + self.roomActivitiesContainerHeightConstraint.constant; + + if (self.bubblesTableViewBottomConstraint.constant != bubblesTableViewBottomConst) + { + self.bubblesTableViewBottomConstraint.constant = bubblesTableViewBottomConst; + } + +} + +#pragma mark - Override MXKViewController + +- (void)onMatrixSessionChange +{ + [super onMatrixSessionChange]; + + // Check dataSource state + if (self.roomDataSource && (self.roomDataSource.state == MXKDataSourceStatePreparing || self.roomDataSource.serverSyncEventCount)) + { + // dataSource is not ready, keep running the loading wheel + [self startActivityIndicator]; + } +} + +- (void)onKeyboardShowAnimationComplete +{ + // Check first if the first responder belongs to title view + UIView *keyboardView = titleView.inputAccessoryView.superview; + if (!keyboardView) + { + // Check whether the first responder is the input tool bar text composer + keyboardView = inputToolbarView.inputAccessoryView.superview; + } + + // Report the keyboard view in order to track keyboard frame changes + self.keyboardView = keyboardView; +} + +- (void)setKeyboardHeight:(CGFloat)keyboardHeight +{ + // Deduce the bottom constraint for the input toolbar view (Don't forget the potential tabBar) + CGFloat inputToolbarViewBottomConst = keyboardHeight - self.bottomLayoutGuide.length; + // Check whether the keyboard is over the tabBar + if (inputToolbarViewBottomConst < 0) + { + inputToolbarViewBottomConst = 0; + } + + // Update constraints + _roomInputToolbarContainerBottomConstraint.constant = inputToolbarViewBottomConst; + _bubblesTableViewBottomConstraint.constant = inputToolbarViewBottomConst + _roomInputToolbarContainerHeightConstraint.constant + _roomActivitiesContainerHeightConstraint.constant; + + // Remove the rounded bottom unsafe area of the iPhone X + _bubblesTableViewBottomConstraint.constant += self.view.safeAreaInsets.bottom; + + // Invalidate the current layout to take into account the new constraints in the next update cycle. + [self.view setNeedsLayout]; + + // Compute the visible area (tableview + toolbar) at the end of animation + CGFloat visibleArea = self.view.frame.size.height - _bubblesTableView.adjustedContentInset.top - keyboardHeight; + // Deduce max height of the message text input by considering the minimum height of the table view. + inputToolbarView.maxHeight = visibleArea - MXKROOMVIEWCONTROLLER_MESSAGES_TABLE_MINIMUM_HEIGHT; + + // Check conditions before scrolling the tableview content when a new keyboard is presented. + if ((_scrollHistoryToTheBottomOnKeyboardPresentation || [self isBubblesTableScrollViewAtTheBottom]) && !super.keyboardHeight && keyboardHeight && !currentAlert) + { + self.bubbleTableViewDisplayInTransition = YES; + + // Force here the layout update to scroll correctly the table content. + [self.view layoutIfNeeded]; + [self scrollBubblesTableViewToBottomAnimated:NO]; + + self.bubbleTableViewDisplayInTransition = NO; + } + else + { + [self updateCurrentEventIdAtTableBottom:NO]; + } + + super.keyboardHeight = keyboardHeight; +} + +- (void)destroy +{ + if (documentInteractionController) + { + [documentInteractionController dismissPreviewAnimated:NO]; + [documentInteractionController dismissMenuAnimated:NO]; + documentInteractionController = nil; + } + + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } + + [self dismissTemporarySubViews]; + + _bubblesTableView.dataSource = nil; + _bubblesTableView.delegate = nil; + _bubblesTableView = nil; + + if (roomDataSource.delegate == self) + { + roomDataSource.delegate = nil; + } + + if (_hasRoomDataSourceOwnership) + { + // Release the room data source + [roomDataSource destroy]; + } + roomDataSource = nil; + + if (titleView) + { + [titleView removeFromSuperview]; + [titleView destroy]; + titleView = nil; + } + + if (inputToolbarView) + { + [inputToolbarView removeFromSuperview]; + [inputToolbarView destroy]; + inputToolbarView = nil; + } + + if (activitiesView) + { + [activitiesView removeFromSuperview]; + [activitiesView destroy]; + activitiesView = nil; + } + + [typingTimer invalidate]; + typingTimer = nil; + + if (joinRoomRequest) + { + [joinRoomRequest cancel]; + joinRoomRequest = nil; + } + + [super destroy]; +} + +#pragma mark - + +- (void)configureBubblesTableView +{ + // Set up table delegates + _bubblesTableView.delegate = self; + _bubblesTableView.dataSource = roomDataSource; // Note: data source may be nil here, it will be set during [displayRoom:] call. + + // Set up default classes to use for cells + [_bubblesTableView registerClass:MXKRoomIncomingTextMsgBubbleCell.class forCellReuseIdentifier:MXKRoomIncomingTextMsgBubbleCell.defaultReuseIdentifier]; + [_bubblesTableView registerClass:MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.class forCellReuseIdentifier:MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.defaultReuseIdentifier]; + [_bubblesTableView registerClass:MXKRoomIncomingAttachmentBubbleCell.class forCellReuseIdentifier:MXKRoomIncomingAttachmentBubbleCell.defaultReuseIdentifier]; + [_bubblesTableView registerClass:MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.class forCellReuseIdentifier:MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.defaultReuseIdentifier]; + + [_bubblesTableView registerClass:MXKRoomOutgoingTextMsgBubbleCell.class forCellReuseIdentifier:MXKRoomOutgoingTextMsgBubbleCell.defaultReuseIdentifier]; + [_bubblesTableView registerClass:MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.class forCellReuseIdentifier:MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.defaultReuseIdentifier]; + [_bubblesTableView registerClass:MXKRoomOutgoingAttachmentBubbleCell.class forCellReuseIdentifier:MXKRoomOutgoingAttachmentBubbleCell.defaultReuseIdentifier]; + [_bubblesTableView registerClass:MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.class forCellReuseIdentifier:MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.defaultReuseIdentifier]; + + // Observe kMXSessionWillLeaveRoomNotification to be notified if the user leaves the current room. + MXWeakify(self); + _mxSessionWillLeaveRoomNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionWillLeaveRoomNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXStrongifyAndReturnIfNil(self); + // Check whether the user will leave the current room + if (notif.object == self.mainSession) + { + NSString *roomId = notif.userInfo[kMXSessionNotificationRoomIdKey]; + if (roomId && [roomId isEqualToString:self->roomDataSource.roomId]) + { + // Update view controller appearance + [self leaveRoomOnEvent:notif.userInfo[kMXSessionNotificationEventKey]]; + } + } + }]; +} + +- (void)updateMessageTextViewFrame +{ + if (!self.keyboardView) + { + // Compute the visible area (tableview + toolbar) + CGFloat visibleArea = self.view.frame.size.height - _bubblesTableView.adjustedContentInset.top; + // Deduce max height of the message text input by considering the minimum height of the table view. + inputToolbarView.maxHeight = visibleArea - MXKROOMVIEWCONTROLLER_MESSAGES_TABLE_MINIMUM_HEIGHT; + } +} + +- (CGFloat)tableViewSafeAreaWidth +{ + CGFloat safeAreaInsetsWidth; + + // Take safe area into account + safeAreaInsetsWidth = self.bubblesTableView.safeAreaInsets.left + self.bubblesTableView.safeAreaInsets.right; + + return self.bubblesTableView.frame.size.width - safeAreaInsetsWidth; +} + +#pragma mark - Public API + +- (void)displayRoom:(MXKRoomDataSource *)dataSource +{ + if (roomDataSource) + { + if (self.hasRoomDataSourceOwnership) + { + // Release the room data source + [roomDataSource destroy]; + } + else if (roomDataSource.delegate == self) + { + roomDataSource.delegate = nil; + } + roomDataSource = nil; + + [self removeMatrixSession:self.mainSession]; + } + + // Reset the current event id + currentEventIdAtTableBottom = nil; + + if (dataSource) + { + if (!dataSource.isLive || dataSource.isPeeking) + { + // Remove the input toolbar if the displayed timeline is not a live one or in case of peeking. + // We do not let the user type message in this case. + [self setRoomInputToolbarViewClass:nil]; + } + + roomDataSource = dataSource; + roomDataSource.delegate = self; + roomDataSource.paginationLimitAroundInitialEvent = _paginationLimit; + + // Report the matrix session at view controller level to update UI according to session state + [self addMatrixSession:roomDataSource.mxSession]; + + if (_bubblesTableView) + { + [self dismissTemporarySubViews]; + + // Set up table data source + _bubblesTableView.dataSource = roomDataSource; + } + + // When ready, do the initial back pagination + if (roomDataSource.state == MXKDataSourceStateReady) + { + [self onRoomDataSourceReady]; + } + } + + [self updateViewControllerAppearanceOnRoomDataSourceState]; +} + +- (void)onRoomDataSourceReady +{ + // If the user is only invited, auto-join the room if this option is enabled + if (roomDataSource.room.summary.membership == MXMembershipInvite) + { + if (_autoJoinInvitedRoom) + { + [self joinRoom:nil]; + } + } + else + { + [self triggerInitialBackPagination]; + } +} + +- (void)updateViewControllerAppearanceOnRoomDataSourceState +{ + // Update UI by considering dataSource state + if (roomDataSource && roomDataSource.state == MXKDataSourceStateReady) + { + [self stopActivityIndicator]; + + if (titleView) + { + titleView.mxRoom = roomDataSource.room; + titleView.editable = YES; + titleView.hidden = NO; + } + else + { + // set default title + self.navigationItem.title = roomDataSource.room.summary.displayname; + } + + // Show input tool bar + inputToolbarView.hidden = NO; + } + else + { + // Update the title except if the room has just been left + if (!_leftRoomReasonLabel) + { + if (roomDataSource && roomDataSource.state == MXKDataSourceStatePreparing) + { + if (titleView) + { + titleView.mxRoom = roomDataSource.room; + titleView.hidden = (!titleView.mxRoom); + } + else + { + self.navigationItem.title = roomDataSource.room.summary.displayname; + } + } + else + { + if (titleView) + { + titleView.mxRoom = nil; + titleView.hidden = NO; + } + else + { + self.navigationItem.title = nil; + } + } + } + titleView.editable = NO; + + // Hide input tool bar + inputToolbarView.hidden = YES; + } + + // Finalize room title refresh + [titleView refreshDisplay]; + + if (activitiesView) + { + // Hide by default the activity view when no room is displayed + activitiesView.hidden = (roomDataSource == nil); + } +} + +- (void)onTimelineError:(NSNotification *)notif +{ + if (notif.object == roomDataSource) + { + [self stopActivityIndicator]; + + // Compute the message to display to the end user + NSString *errorTitle; + NSString *errorMessage; + + NSError *error = notif.userInfo[kMXKRoomDataSourceTimelineErrorErrorKey]; + if ([MXError isMXError:error]) + { + MXError *mxError = [[MXError alloc] initWithNSError:error]; + if ([mxError.errcode isEqualToString:kMXErrCodeStringNotFound]) + { + errorTitle = [MatrixKitL10n roomErrorTimelineEventNotFoundTitle]; + errorMessage = [MatrixKitL10n roomErrorTimelineEventNotFound]; + } + else + { + errorTitle = [MatrixKitL10n roomErrorCannotLoadTimeline]; + errorMessage = mxError.error; + } + } + else + { + errorTitle = [MatrixKitL10n roomErrorCannotLoadTimeline]; + } + + // And show it + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + + __weak typeof(self) weakSelf = self; + UIAlertController *errorAlert = [UIAlertController alertControllerWithTitle:errorTitle + message:errorMessage + preferredStyle:UIAlertControllerStyleAlert]; + + [errorAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + }]]; + + [self presentViewController:errorAlert animated:YES completion:nil]; + currentAlert = errorAlert; + } +} + +- (void)joinRoom:(void(^)(MXKRoomViewControllerJoinRoomResult result))completion +{ + if (joinRoomRequest != nil) + { + if (completion) + { + completion(MXKRoomViewControllerJoinRoomResultFailureJoinInProgress); + } + return; + } + + [self startActivityIndicator]; + + joinRoomRequest = [roomDataSource.room join:^{ + + self->joinRoomRequest = nil; + [self stopActivityIndicator]; + + [self triggerInitialBackPagination]; + + if (completion) + { + completion(MXKRoomViewControllerJoinRoomResultSuccess); + } + + } failure:^(NSError *error) { + MXLogDebug(@"[MXKRoomVC] Failed to join room (%@)", self->roomDataSource.room.summary.displayname); + [self processRoomJoinFailureWithError:error completion:completion]; + }]; +} + +- (void)joinRoomWithRoomIdOrAlias:(NSString*)roomIdOrAlias + viaServers:(NSArray*)viaServers + andSignUrl:(NSString*)signUrl + completion:(void(^)(MXKRoomViewControllerJoinRoomResult result))completion +{ + if (joinRoomRequest != nil) + { + if (completion) + { + completion(MXKRoomViewControllerJoinRoomResultFailureJoinInProgress); + } + + return; + } + + [self startActivityIndicator]; + + void (^success)(MXRoom *room) = ^(MXRoom *room) { + + self->joinRoomRequest = nil; + [self stopActivityIndicator]; + + MXWeakify(self); + + // The room is now part of the user's room + MXKRoomDataSourceManager *roomDataSourceManager = [MXKRoomDataSourceManager sharedManagerForMatrixSession:self.mainSession]; + + [roomDataSourceManager roomDataSourceForRoom:room.roomId create:YES onComplete:^(MXKRoomDataSource *newRoomDataSource) { + + MXStrongifyAndReturnIfNil(self); + + // And can be displayed + [self displayRoom:newRoomDataSource]; + + if (completion) + { + completion(MXKRoomViewControllerJoinRoomResultSuccess); + } + }]; + }; + + void (^failure)(NSError *error) = ^(NSError *error) { + MXLogDebug(@"[MXKRoomVC] Failed to join room (%@)", roomIdOrAlias); + [self processRoomJoinFailureWithError:error completion:completion]; + }; + + // Does the join need to be validated before? + if (signUrl) + { + joinRoomRequest = [self.mainSession joinRoom:roomIdOrAlias viaServers:viaServers withSignUrl:signUrl success:success failure:failure]; + } + else + { + joinRoomRequest = [self.mainSession joinRoom:roomIdOrAlias viaServers:viaServers success:success failure:failure]; + } +} + +- (void)processRoomJoinFailureWithError:(NSError *)error completion:(void(^)(MXKRoomViewControllerJoinRoomResult result))completion +{ + self->joinRoomRequest = nil; + [self stopActivityIndicator]; + + // Show the error to the end user + NSString *msg = [error.userInfo valueForKey:NSLocalizedDescriptionKey]; + + // FIXME: We should hide this inside the SDK and expose it as a domain specific error + BOOL isRoomEmpty = [msg isEqualToString:@"No known servers"]; + if (isRoomEmpty) + { + // minging kludge until https://matrix.org/jira/browse/SYN-678 is fixed + // 'Error when trying to join an empty room should be more explicit' + msg = [MatrixKitL10n roomErrorJoinFailedEmptyRoom]; + } + + MXWeakify(self); + [self->currentAlert dismissViewControllerAnimated:NO completion:nil]; + + UIAlertController *errorAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n roomErrorJoinFailedTitle] + message:msg + preferredStyle:UIAlertControllerStyleAlert]; + + [errorAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + MXStrongifyAndReturnIfNil(self); + self->currentAlert = nil; + + if (completion) + { + completion((isRoomEmpty ? MXKRoomViewControllerJoinRoomResultFailureRoomEmpty : MXKRoomViewControllerJoinRoomResultFailureGeneric)); + } + }]]; + + [self presentViewController:errorAlert animated:YES completion:nil]; + currentAlert = errorAlert; +} + +- (void)leaveRoomOnEvent:(MXEvent*)event +{ + [self dismissTemporarySubViews]; + + NSString *reason = nil; + if (event) + { + MXKEventFormatterError error; + reason = [roomDataSource.eventFormatter stringFromEvent:event withRoomState:roomDataSource.roomState error:&error]; + if (error != MXKEventFormatterErrorNone) + { + reason = nil; + } + } + + if (!reason.length) + { + if (self.roomDataSource.room.isDirect) + { + reason = [MatrixKitL10n roomLeftForDm]; + } + else + { + reason = [MatrixKitL10n roomLeft]; + } + } + + + _bubblesTableView.dataSource = nil; + _bubblesTableView.delegate = nil; + + if (self.hasRoomDataSourceOwnership) + { + // Release the room data source + [roomDataSource destroy]; + } + else if (roomDataSource.delegate == self) + { + roomDataSource.delegate = nil; + } + roomDataSource = nil; + + // Add reason label + _leftRoomReasonLabel = [[UILabel alloc] initWithFrame:CGRectMake(10, 5, self.view.frame.size.width - 20, 70)]; + _leftRoomReasonLabel.numberOfLines = 0; + _leftRoomReasonLabel.text = reason; + _leftRoomReasonLabel.autoresizingMask = UIViewAutoresizingFlexibleWidth; + _bubblesTableView.tableHeaderView = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, 80)]; + [_bubblesTableView.tableHeaderView addSubview:_leftRoomReasonLabel]; + [_bubblesTableView reloadData]; + + [self updateViewControllerAppearanceOnRoomDataSourceState]; +} + +- (void)setPaginationLimit:(NSUInteger)paginationLimit +{ + _paginationLimit = paginationLimit; + + // Use the same value when loading messages around the initial event + roomDataSource.paginationLimitAroundInitialEvent = _paginationLimit; +} + +- (void)setRoomTitleViewClass:(Class)roomTitleViewClass +{ + // Sanity check: accept only MXKRoomTitleView classes or sub-classes + NSParameterAssert([roomTitleViewClass isSubclassOfClass:MXKRoomTitleView.class]); + + // Remove potential title view + if (titleView) + { + [NSLayoutConstraint deactivateConstraints:titleView.constraints]; + + [titleView dismissKeyboard]; + [titleView removeFromSuperview]; + [titleView destroy]; + } + + titleView = self.navigationItem.titleView = [roomTitleViewClass roomTitleView]; + titleView.delegate = self; + + // Define directly the navigation titleView with the custom title view instance. Do not use anymore a container. + self.navigationItem.titleView = titleView; + + [self updateViewControllerAppearanceOnRoomDataSourceState]; +} + +- (void)setRoomInputToolbarViewClass:(Class)roomInputToolbarViewClass +{ + if (!_roomInputToolbarContainer) + { + MXLogDebug(@"[MXKRoomVC] Set roomInputToolbarViewClass failed: container is missing"); + return; + } + + // Remove potential toolbar + if (inputToolbarView) + { + MXLogDebug(@"[MXKRoomVC] setRoomInputToolbarViewClass: Set inputToolbarView with class %@ to nil", [self.inputToolbarView class]); + + [NSLayoutConstraint deactivateConstraints:inputToolbarView.constraints]; + [inputToolbarView dismissKeyboard]; + [inputToolbarView removeFromSuperview]; + [inputToolbarView destroy]; + inputToolbarView = nil; + } + + if (roomDataSource && (!roomDataSource.isLive || roomDataSource.isPeeking)) + { + // Do not show the input toolbar if the displayed timeline is not a live one, or in case of peeking. + // We do not let the user type message in this case. + roomInputToolbarViewClass = nil; + } + + if (roomInputToolbarViewClass) + { + // Sanity check: accept only MXKRoomInputToolbarView classes or sub-classes + NSParameterAssert([roomInputToolbarViewClass isSubclassOfClass:MXKRoomInputToolbarView.class]); + + MXLogDebug(@"[MXKRoomVC] setRoomInputToolbarViewClass: Set inputToolbarView to class %@", roomInputToolbarViewClass); + + id inputToolbarView = [roomInputToolbarViewClass roomInputToolbarView]; + self->inputToolbarView = inputToolbarView; + self->inputToolbarView.delegate = self; + + // Add the input toolbar view and define edge constraints + [_roomInputToolbarContainer addSubview:inputToolbarView]; + [_roomInputToolbarContainer addConstraint:[NSLayoutConstraint constraintWithItem:_roomInputToolbarContainer + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:inputToolbarView + attribute:NSLayoutAttributeBottom + multiplier:1.0f + constant:0.0f]]; + [_roomInputToolbarContainer addConstraint:[NSLayoutConstraint constraintWithItem:_roomInputToolbarContainer + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:inputToolbarView + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:0.0f]]; + [_roomInputToolbarContainer addConstraint:[NSLayoutConstraint constraintWithItem:_roomInputToolbarContainer + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:inputToolbarView + attribute:NSLayoutAttributeLeading + multiplier:1.0f + constant:0.0f]]; + [_roomInputToolbarContainer addConstraint:[NSLayoutConstraint constraintWithItem:_roomInputToolbarContainer + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:inputToolbarView + attribute:NSLayoutAttributeTrailing + multiplier:1.0f + constant:0.0f]]; + } + + [_roomInputToolbarContainer setNeedsUpdateConstraints]; +} + + +- (void)setRoomActivitiesViewClass:(Class)roomActivitiesViewClass +{ + if (!_roomActivitiesContainer) + { + MXLogDebug(@"[MXKRoomVC] Set RoomActivitiesViewClass failed: container is missing"); + return; + } + + // Remove potential toolbar + if (activitiesView) + { + [NSLayoutConstraint deactivateConstraints:activitiesView.constraints]; + [activitiesView removeFromSuperview]; + [activitiesView destroy]; + activitiesView = nil; + } + + if (roomActivitiesViewClass) + { + // Sanity check: accept only MXKRoomExtraInfoView classes or sub-classes + NSParameterAssert([roomActivitiesViewClass isSubclassOfClass:MXKRoomActivitiesView.class]); + + activitiesView = [roomActivitiesViewClass roomActivitiesView]; + + // Add the view and define edge constraints + activitiesView.translatesAutoresizingMaskIntoConstraints = NO; + [_roomActivitiesContainer addSubview:activitiesView]; + + NSLayoutConstraint* topConstraint = [NSLayoutConstraint constraintWithItem:_roomActivitiesContainer + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:activitiesView + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:0.0f]; + + + NSLayoutConstraint* leadingConstraint = [NSLayoutConstraint constraintWithItem:_roomActivitiesContainer + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:activitiesView + attribute:NSLayoutAttributeLeading + multiplier:1.0f + constant:0.0f]; + + NSLayoutConstraint* widthConstraint = [NSLayoutConstraint constraintWithItem:_roomActivitiesContainer + attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationEqual + toItem:activitiesView + attribute:NSLayoutAttributeWidth + multiplier:1.0f + constant:0.0f]; + + NSLayoutConstraint* heightConstraint = [NSLayoutConstraint constraintWithItem:_roomActivitiesContainer + attribute:NSLayoutAttributeHeight + relatedBy:NSLayoutRelationEqual + toItem:activitiesView + attribute:NSLayoutAttributeHeight + multiplier:1.0f + constant:0.0f]; + + + [NSLayoutConstraint activateConstraints:@[topConstraint, leadingConstraint, widthConstraint, heightConstraint]]; + + // let the provide view to define a height. + // it could have no constrainst if there is no defined xib + _roomActivitiesContainerHeightConstraint.constant = activitiesView.height; + + // Listen to activities view change + activitiesView.delegate = self; + } + else + { + _roomActivitiesContainerHeightConstraint.constant = 0; + } + + _bubblesTableViewBottomConstraint.constant = _roomInputToolbarContainerBottomConstraint.constant + _roomInputToolbarContainerHeightConstraint.constant +_roomActivitiesContainerHeightConstraint.constant; + + [_roomActivitiesContainer setNeedsUpdateConstraints]; +} + +- (void)setAttachmentsViewerClass:(Class)theAttachmentsViewerClass +{ + if (theAttachmentsViewerClass) + { + // Sanity check: accept only MXKAttachmentsViewController classes or sub-classes + NSParameterAssert([theAttachmentsViewerClass isSubclassOfClass:MXKAttachmentsViewController.class]); + } + + attachmentsViewerClass = theAttachmentsViewerClass; +} + +- (void)setEventDetailsViewClass:(Class)eventDetailsViewClass +{ + if (eventDetailsViewClass) + { + // Sanity check: accept only MXKEventDetailsView classes or sub-classes + NSParameterAssert([eventDetailsViewClass isSubclassOfClass:MXKEventDetailsView.class]); + } + + customEventDetailsViewClass = eventDetailsViewClass; +} + +- (BOOL)isIRCStyleCommand:(NSString*)string +{ + // Check whether the provided text may be an IRC-style command + if ([string hasPrefix:@"/"] == NO || [string hasPrefix:@"//"] == YES) + { + return NO; + } + + // Parse command line + NSArray *components = [string componentsSeparatedByString:@" "]; + NSString *cmd = [components objectAtIndex:0]; + NSUInteger index = 1; + + // TODO: display an alert with the cmd usage in case of error or unrecognized cmd. + NSString *cmdUsage; + + if ([cmd isEqualToString:kMXKSlashCmdEmote]) + { + // send message as an emote + [self sendTextMessage:string]; + } + else if ([string hasPrefix:kMXKSlashCmdChangeDisplayName]) + { + // Change display name + NSString *displayName; + + // Sanity check + if (string.length > kMXKSlashCmdChangeDisplayName.length) + { + displayName = [string substringFromIndex:kMXKSlashCmdChangeDisplayName.length + 1]; + + // Remove white space from both ends + displayName = [displayName stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + } + + if (displayName.length) + { + [roomDataSource.mxSession.matrixRestClient setDisplayName:displayName success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Set displayName failed"); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /nick "; + } + } + else if ([string hasPrefix:kMXKSlashCmdJoinRoom]) + { + // Join a room + NSString *roomAlias; + + // Sanity check + if (string.length > kMXKSlashCmdJoinRoom.length) + { + roomAlias = [string substringFromIndex:kMXKSlashCmdJoinRoom.length + 1]; + + // Remove white space from both ends + roomAlias = [roomAlias stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + } + + // Check + if (roomAlias.length) + { + // TODO: /join command does not support via parameters yet + [roomDataSource.mxSession joinRoom:roomAlias viaServers:nil success:^(MXRoom *room) { + // Do nothing by default when we succeed to join the room + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Join roomAlias (%@) failed", roomAlias); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /join "; + } + } + else if ([string hasPrefix:kMXKSlashCmdPartRoom]) + { + // Leave this room or another one + NSString *roomId; + NSString *roomIdOrAlias; + + // Sanity check + if (string.length > kMXKSlashCmdPartRoom.length) + { + roomIdOrAlias = [string substringFromIndex:kMXKSlashCmdPartRoom.length + 1]; + + // Remove white space from both ends + roomIdOrAlias = [roomIdOrAlias stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + } + + // Check + if (roomIdOrAlias.length) + { + // Leave another room + if ([MXTools isMatrixRoomAlias:roomIdOrAlias]) + { + // Convert the alias to a room ID + MXRoom *room = [roomDataSource.mxSession roomWithAlias:roomIdOrAlias]; + if (room) + { + roomId = room.roomId; + } + } + else if ([MXTools isMatrixRoomIdentifier:roomIdOrAlias]) + { + roomId = roomIdOrAlias; + } + } + else + { + // Leave the current room + roomId = roomDataSource.roomId; + } + + if (roomId.length) + { + [roomDataSource.mxSession leaveRoom:roomId success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Part room_alias (%@ / %@) failed", roomIdOrAlias, roomId); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /part []"; + } + } + else if ([string hasPrefix:kMXKSlashCmdChangeRoomTopic]) + { + // Change topic + NSString *topic; + + // Sanity check + if (string.length > kMXKSlashCmdChangeRoomTopic.length) + { + topic = [string substringFromIndex:kMXKSlashCmdChangeRoomTopic.length + 1]; + // Remove white space from both ends + topic = [topic stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + } + + if (topic.length) + { + [roomDataSource.room setTopic:topic success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Set topic failed"); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /topic "; + } + } + else + { + // Retrieve userId + NSString *userId = nil; + while (index < components.count) + { + userId = [components objectAtIndex:index++]; + if (userId.length) + { + // done + break; + } + // reset + userId = nil; + } + + if ([cmd isEqualToString:kMXKSlashCmdInviteUser]) + { + if (userId) + { + // Invite the user + [roomDataSource.room inviteUser:userId success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Invite user (%@) failed", userId); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /invite "; + } + } + else if ([cmd isEqualToString:kMXKSlashCmdKickUser]) + { + if (userId) + { + // Retrieve potential reason + NSString *reason = nil; + while (index < components.count) + { + if (reason) + { + reason = [NSString stringWithFormat:@"%@ %@", reason, [components objectAtIndex:index++]]; + } + else + { + reason = [components objectAtIndex:index++]; + } + } + // Kick the user + [roomDataSource.room kickUser:userId reason:reason success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Kick user (%@) failed", userId); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /kick []"; + } + } + else if ([cmd isEqualToString:kMXKSlashCmdBanUser]) + { + if (userId) + { + // Retrieve potential reason + NSString *reason = nil; + while (index < components.count) + { + if (reason) + { + reason = [NSString stringWithFormat:@"%@ %@", reason, [components objectAtIndex:index++]]; + } + else + { + reason = [components objectAtIndex:index++]; + } + } + // Ban the user + [roomDataSource.room banUser:userId reason:reason success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Ban user (%@) failed", userId); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /ban []"; + } + } + else if ([cmd isEqualToString:kMXKSlashCmdUnbanUser]) + { + if (userId) + { + // Unban the user + [roomDataSource.room unbanUser:userId success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Unban user (%@) failed", userId); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /unban "; + } + } + else if ([cmd isEqualToString:kMXKSlashCmdSetUserPowerLevel]) + { + // Retrieve power level + NSString *powerLevel = nil; + while (index < components.count) + { + powerLevel = [components objectAtIndex:index++]; + if (powerLevel.length) + { + // done + break; + } + // reset + powerLevel = nil; + } + // Set power level + if (userId && powerLevel) + { + // Set user power level + [roomDataSource.room setPowerLevelOfUserWithUserID:userId powerLevel:[powerLevel integerValue] success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Set user power (%@) failed", userId); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /op "; + } + } + else if ([cmd isEqualToString:kMXKSlashCmdResetUserPowerLevel]) + { + if (userId) + { + // Reset user power level + [roomDataSource.room setPowerLevelOfUserWithUserID:userId powerLevel:0 success:^{ + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomVC] Reset user power (%@) failed", userId); + // Notify MatrixKit user + NSString *myUserId = self->roomDataSource.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + else + { + // Display cmd usage in text input as placeholder + cmdUsage = @"Usage: /deop "; + } + } + else + { + MXLogDebug(@"[MXKRoomVC] Unrecognised IRC-style command: %@", string); +// cmdUsage = [NSString stringWithFormat:@"Unrecognised IRC-style command: %@", cmd]; + return NO; + } + } + return YES; +} + +- (void)mention:(MXRoomMember*)roomMember +{ + NSString *memberName = roomMember.displayname.length ? roomMember.displayname : roomMember.userId; + + if (inputToolbarView.textMessage.length) + { + [inputToolbarView pasteText:memberName]; + } + else if ([roomMember.userId isEqualToString:self.mainSession.myUser.userId]) + { + // Prepare emote + inputToolbarView.textMessage = @"/me "; + } + else + { + // Bing the member + inputToolbarView.textMessage = [NSString stringWithFormat:@"%@: ", memberName]; + } + + [inputToolbarView becomeFirstResponder]; +} + +- (void)dismissKeyboard +{ + [titleView dismissKeyboard]; + [inputToolbarView dismissKeyboard]; +} + +- (BOOL)isBubblesTableScrollViewAtTheBottom +{ + if (_bubblesTableView.contentSize.height) + { + // Check whether the most recent message is visible. + // Compute the max vertical position visible according to contentOffset + CGFloat maxPositionY = _bubblesTableView.contentOffset.y + (_bubblesTableView.frame.size.height - _bubblesTableView.adjustedContentInset.bottom); + // Be a bit less retrictive, consider the table view at the bottom even if the most recent message is partially hidden + maxPositionY += 44; + BOOL isScrolledToBottom = (maxPositionY >= _bubblesTableView.contentSize.height); + + // Consider the table view at the bottom if a scrolling to bottom is in progress too + return (isScrolledToBottom || isScrollingToBottom); + } + + // Consider empty table view as at the bottom. Only do this after it has appeared. + // Returning YES here before the view has appeared allows calls to scrollBubblesTableViewToBottomAnimated + // before the view knows its final size, resulting in a position offset the second time a room is shown (#4524). + return hasAppearedOnce; +} + +- (void)scrollBubblesTableViewToBottomAnimated:(BOOL)animated +{ + if (_bubblesTableView.contentSize.height) + { + CGFloat visibleHeight = _bubblesTableView.frame.size.height - _bubblesTableView.adjustedContentInset.top - _bubblesTableView.adjustedContentInset.bottom; + if (visibleHeight < _bubblesTableView.contentSize.height) + { + CGFloat wantedOffsetY = _bubblesTableView.contentSize.height - visibleHeight - _bubblesTableView.adjustedContentInset.top; + CGFloat currentOffsetY = _bubblesTableView.contentOffset.y; + if (wantedOffsetY != currentOffsetY) + { + isScrollingToBottom = YES; + BOOL savedBubbleTableViewDisplayInTransition = self.isBubbleTableViewDisplayInTransition; + self.bubbleTableViewDisplayInTransition = YES; + [self setBubbleTableViewContentOffset:CGPointMake(0, wantedOffsetY) animated:animated]; + self.bubbleTableViewDisplayInTransition = savedBubbleTableViewDisplayInTransition; + } + else + { + // upateCurrentEventIdAtTableBottom must be called here (it is usually called by the scrollview delegate at the end of scrolling). + [self updateCurrentEventIdAtTableBottom:YES]; + } + } + else + { + [self setBubbleTableViewContentOffset:CGPointMake(0, -_bubblesTableView.adjustedContentInset.top) animated:animated]; + } + + shouldScrollToBottomOnTableRefresh = NO; + } +} + +- (void)dismissTemporarySubViews +{ + [self dismissKeyboard]; + + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + currentAlert = nil; + } + + if (eventDetailsView) + { + [eventDetailsView removeFromSuperview]; + eventDetailsView = nil; + } + + if (_leftRoomReasonLabel) + { + [_leftRoomReasonLabel removeFromSuperview]; + _leftRoomReasonLabel = nil; + _bubblesTableView.tableHeaderView = nil; + } + + // Dispose potential keyboard view + self.keyboardView = nil; +} + +- (void)setBubbleTableViewContentOffset:(CGPoint)contentOffset animated:(BOOL)animated +{ + if (preventBubblesTableViewScroll) + { + return; + } + + [self.bubblesTableView setContentOffset:contentOffset animated:animated]; +} + +#pragma mark - properties + +- (void)setBubbleTableViewDisplayInTransition:(BOOL)bubbleTableViewDisplayInTransition +{ + if (_bubbleTableViewDisplayInTransition != bubbleTableViewDisplayInTransition) + { + _bubbleTableViewDisplayInTransition = bubbleTableViewDisplayInTransition; + + [self updateCurrentEventIdAtTableBottom:YES]; + } +} + +- (void)setUpdateRoomReadMarker:(BOOL)updateRoomReadMarker +{ + if (_updateRoomReadMarker != updateRoomReadMarker) + { + _updateRoomReadMarker = updateRoomReadMarker; + + if (updateRoomReadMarker == YES) + { + if (currentEventIdAtTableBottom) + { + [self.roomDataSource.room moveReadMarkerToEventId:currentEventIdAtTableBottom]; + } + else + { + // Look for the last displayed event. + [self updateCurrentEventIdAtTableBottom:YES]; + } + } + } +} + +#pragma mark - activity indicator + +- (void)stopActivityIndicator +{ + // Keep the loading wheel displayed while we are joining the room + if (joinRoomRequest) + { + return; + } + + // Check internal processes before stopping the loading wheel + if (isPaginationInProgress || isInputToolbarProcessing) + { + // Keep activity indicator running + return; + } + + // Leave super decide + [super stopActivityIndicator]; +} + +#pragma mark - Pagination + +- (void)triggerInitialBackPagination +{ + // Trigger back pagination to fill all the screen + CGRect frame = [[UIScreen mainScreen] bounds]; + + MXWeakify(self); + + isPaginationInProgress = YES; + [self startActivityIndicator]; + [roomDataSource paginateToFillRect:frame + direction:MXTimelineDirectionBackwards + withMinRequestMessagesCount:_paginationLimit + success:^{ + + MXStrongifyAndReturnIfNil(self); + + // Stop spinner + self->isPaginationInProgress = NO; + [self stopActivityIndicator]; + + self.bubbleTableViewDisplayInTransition = YES; + + // Reload table + [self reloadBubblesTable:YES]; + + if (self->roomDataSource.timeline.initialEventId) + { + // Center the table view to the cell that contains this event + NSInteger index = [self->roomDataSource indexOfCellDataWithEventId:self->roomDataSource.timeline.initialEventId]; + if (index != NSNotFound) + { + // Let iOS put the cell at the top of the table view + [self.bubblesTableView scrollToRowAtIndexPath: [NSIndexPath indexPathForRow:index inSection:0] atScrollPosition:UITableViewScrollPositionTop animated:NO]; + + // Apply an offset to move the targeted component at the center of the screen. + UITableViewCell *cell = [self->_bubblesTableView cellForRowAtIndexPath:[NSIndexPath indexPathForRow:index inSection:0]]; + + CGPoint contentOffset = self->_bubblesTableView.contentOffset; + CGFloat firstVisibleContentRowOffset = self->_bubblesTableView.contentOffset.y + self->_bubblesTableView.adjustedContentInset.top; + CGFloat lastVisibleContentRowOffset = self->_bubblesTableView.frame.size.height - self->_bubblesTableView.adjustedContentInset.bottom; + + CGFloat localPositionOfEvent = 0.0; + + if ([cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; + + if (self->_centerBubblesTableViewContentOnTheInitialEventBottom) + { + localPositionOfEvent = [roomBubbleTableViewCell bottomPositionOfEvent:self->roomDataSource.timeline.initialEventId]; + } + else + { + localPositionOfEvent = [roomBubbleTableViewCell topPositionOfEvent:self->roomDataSource.timeline.initialEventId]; + } + } + + contentOffset.y += localPositionOfEvent - (lastVisibleContentRowOffset / 2 - (cell.frame.origin.y - firstVisibleContentRowOffset)); + + // Sanity check + if (contentOffset.y + lastVisibleContentRowOffset > self->_bubblesTableView.contentSize.height) + { + contentOffset.y = self->_bubblesTableView.contentSize.height - lastVisibleContentRowOffset; + } + + [self setBubbleTableViewContentOffset:contentOffset animated:NO]; + + + // Update the read receipt and potentially the read marker. + [self updateCurrentEventIdAtTableBottom:YES]; + } + } + + self.bubbleTableViewDisplayInTransition = NO; + } + failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + + // Stop spinner + self->isPaginationInProgress = NO; + [self stopActivityIndicator]; + + self.bubbleTableViewDisplayInTransition = YES; + + // Reload table + [self reloadBubblesTable:YES]; + + self.bubbleTableViewDisplayInTransition = NO; + + }]; +} + +/** + Trigger an inconspicuous pagination. + The retrieved history is added discretely to the top or the bottom of bubbles table without change the current display. + + @param limit the maximum number of messages to retrieve. + @param direction backwards or forwards. + */ +- (void)triggerPagination:(NSUInteger)limit direction:(MXTimelineDirection)direction +{ + // Paginate only if possible + if (isPaginationInProgress || roomDataSource.state != MXKDataSourceStateReady || NO == [roomDataSource.timeline canPaginate:direction]) + { + return; + } + + // Store the current height of the first bubble (if any) + backPaginationSavedFirstBubbleHeight = 0; + if (direction == MXTimelineDirectionBackwards && [roomDataSource tableView:_bubblesTableView numberOfRowsInSection:0]) + { + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0]; + backPaginationSavedFirstBubbleHeight = [self tableView:_bubblesTableView heightForRowAtIndexPath:indexPath]; + } + + isPaginationInProgress = YES; + + MXWeakify(self); + + // Trigger pagination + [roomDataSource paginate:limit direction:direction onlyFromStore:NO success:^(NSUInteger addedCellNumber) { + + MXStrongifyAndReturnIfNil(self); + + // We will adjust the vertical offset in order to unchange the current display (pagination should be inconspicuous) + CGFloat verticalOffset = 0; + + if (direction == MXTimelineDirectionBackwards) + { + // Compute the cumulative height of the added messages + for (NSUInteger index = 0; index < addedCellNumber; index++) + { + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:index inSection:0]; + verticalOffset += [self tableView:self->_bubblesTableView heightForRowAtIndexPath:indexPath]; + } + + // Add delta of the height of the previous first cell (if any) + if (addedCellNumber < [self->roomDataSource tableView:self->_bubblesTableView numberOfRowsInSection:0]) + { + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:addedCellNumber inSection:0]; + verticalOffset += ([self tableView:self->_bubblesTableView heightForRowAtIndexPath:indexPath] - self->backPaginationSavedFirstBubbleHeight); + } + + self->_bubblesTableView.tableHeaderView = self->backPaginationActivityView = nil; + } + else + { + self->_bubblesTableView.tableFooterView = self->reconnectingView = nil; + } + + // Trigger a full table reload. We could not only insert new cells related to pagination, + // because some other changes may have been ignored during pagination (see[dataSource:didCellChange:]). + self.bubbleTableViewDisplayInTransition = YES; + + // Disable temporarily scrolling and hide the scroll indicator during refresh to prevent flickering + [self.bubblesTableView setShowsVerticalScrollIndicator:NO]; + [self.bubblesTableView setScrollEnabled:NO]; + + CGPoint contentOffset = self.bubblesTableView.contentOffset; + + BOOL hasBeenScrolledToBottom = [self reloadBubblesTable:NO]; + + if (direction == MXTimelineDirectionBackwards) + { + // Backwards pagination adds cells at the top of the tableview content. + // Vertical content offset needs to be updated (except if the table has been scrolled to bottom) + if ((!hasBeenScrolledToBottom && verticalOffset > 0) || direction == MXTimelineDirectionForwards) + { + // Adjust vertical offset in order to compensate scrolling + contentOffset.y += verticalOffset; + [self setBubbleTableViewContentOffset:contentOffset animated:NO]; + } + } + else + { + [self setBubbleTableViewContentOffset:contentOffset animated:NO]; + } + + // Restore scrolling and the scroll indicator + [self.bubblesTableView setShowsVerticalScrollIndicator:YES]; + [self.bubblesTableView setScrollEnabled:YES]; + + self.bubbleTableViewDisplayInTransition = NO; + self->isPaginationInProgress = NO; + + // Force the update of the current visual position + // Else there is a scroll jump on incoming message (see https://github.com/vector-im/vector-ios/issues/79) + if (direction == MXTimelineDirectionBackwards) + { + [self updateCurrentEventIdAtTableBottom:NO]; + } + + } failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + + self.bubbleTableViewDisplayInTransition = YES; + + // Reload table on failure because some changes may have been ignored during pagination (see[dataSource:didCellChange:]) + self->isPaginationInProgress = NO; + self->_bubblesTableView.tableHeaderView = self->backPaginationActivityView = nil; + + [self reloadBubblesTable:NO]; + + self.bubbleTableViewDisplayInTransition = NO; + + }]; +} + +- (void)triggerAttachmentBackPagination:(NSString*)eventId +{ + // Paginate only if possible + if (NO == [roomDataSource.timeline canPaginate:MXTimelineDirectionBackwards] && self.attachmentsViewer) + { + return; + } + + isPaginationInProgress = YES; + + MXWeakify(self); + + // Trigger back pagination to find previous attachments + [roomDataSource paginate:_paginationLimit direction:MXTimelineDirectionBackwards onlyFromStore:NO success:^(NSUInteger addedCellNumber) { + + MXStrongifyAndReturnIfNil(self); + + // Check whether attachments viewer is still visible + if (self.attachmentsViewer) + { + // Check whether some older attachments have been added. + // Note: the stickers are excluded from the attachments list returned by the room datasource. + BOOL isDone = NO; + NSArray *attachmentsWithThumbnail = self.roomDataSource.attachmentsWithThumbnail; + if (attachmentsWithThumbnail.count) + { + MXKAttachment *attachment = attachmentsWithThumbnail.firstObject; + isDone = ![attachment.eventId isEqualToString:eventId]; + } + + // Check whether pagination is still available + self.attachmentsViewer.complete = ([self->roomDataSource.timeline canPaginate:MXTimelineDirectionBackwards] == NO); + + if (isDone || self.attachmentsViewer.complete) + { + // Refresh the current attachments list. + [self.attachmentsViewer displayAttachments:attachmentsWithThumbnail focusOn:nil]; + + // Trigger a full table reload without scrolling. We could not only insert new cells related to back pagination, + // because some other changes may have been ignored during back pagination (see[dataSource:didCellChange:]). + self.bubbleTableViewDisplayInTransition = YES; + self->isPaginationInProgress = NO; + [self reloadBubblesTable:YES]; + self.bubbleTableViewDisplayInTransition = NO; + + // Done + return; + } + + // Here a new back pagination is required + [self triggerAttachmentBackPagination:eventId]; + } + else + { + // Trigger a full table reload without scrolling. We could not only insert new cells related to back pagination, + // because some other changes may have been ignored during back pagination (see[dataSource:didCellChange:]). + self.bubbleTableViewDisplayInTransition = YES; + self->isPaginationInProgress = NO; + [self reloadBubblesTable:YES]; + self.bubbleTableViewDisplayInTransition = NO; + } + + } failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + + // Reload table on failure because some changes may have been ignored during back pagination (see[dataSource:didCellChange:]) + self.bubbleTableViewDisplayInTransition = YES; + self->isPaginationInProgress = NO; + [self reloadBubblesTable:YES]; + self.bubbleTableViewDisplayInTransition = NO; + + if (self.attachmentsViewer) + { + // Force attachments update to cancel potential loading wheel + [self.attachmentsViewer displayAttachments:self.attachmentsViewer.attachments focusOn:nil]; + } + + }]; +} + +#pragma mark - Post messages + +- (void)sendTextMessage:(NSString*)msgTxt +{ + // Let the datasource send it and manage the local echo + [roomDataSource sendTextMessage:msgTxt success:nil failure:^(NSError *error) + { + // Just log the error. The message will be displayed in red in the room history + MXLogDebug(@"[MXKRoomViewController] sendTextMessage failed."); + }]; +} + +# pragma mark - Event handling + +- (void)showEventDetails:(MXEvent *)event +{ + [self dismissKeyboard]; + + // Remove potential existing subviews + [self dismissTemporarySubViews]; + + MXKEventDetailsView *eventDetailsView; + + if (customEventDetailsViewClass) + { + eventDetailsView = [[customEventDetailsViewClass alloc] initWithEvent:event andMatrixSession:roomDataSource.mxSession]; + } + else + { + eventDetailsView = [[MXKEventDetailsView alloc] initWithEvent:event andMatrixSession:roomDataSource.mxSession]; + } + + // Add shadow on event details view + eventDetailsView.layer.cornerRadius = 5; + eventDetailsView.layer.shadowOffset = CGSizeMake(0, 1); + eventDetailsView.layer.shadowOpacity = 0.5f; + + // Add the view and define edge constraints + [self.view addSubview:eventDetailsView]; + + self->eventDetailsView = eventDetailsView; + + [self.view addConstraint:[NSLayoutConstraint constraintWithItem:eventDetailsView + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.topLayoutGuide + attribute:NSLayoutAttributeBottom + multiplier:1.0f + constant:10.0f]]; + + [self.view addConstraint:[NSLayoutConstraint constraintWithItem:eventDetailsView + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self.bottomLayoutGuide + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:-10.0f]]; + + [self.view addConstraint:[NSLayoutConstraint constraintWithItem:self.view + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:eventDetailsView + attribute:NSLayoutAttributeLeading + multiplier:1.0f + constant:-10.0f]]; + + [self.view addConstraint:[NSLayoutConstraint constraintWithItem:self.view + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:eventDetailsView + attribute:NSLayoutAttributeTrailing + multiplier:1.0f + constant:10.0f]]; + [self.view setNeedsUpdateConstraints]; +} + +- (void)promptUserToResendEvent:(NSString *)eventId +{ + MXEvent *event = [roomDataSource eventWithEventId:eventId]; + + MXLogDebug(@"[MXKRoomViewController] promptUserToResendEvent: %@", event); + + if (event && event.eventType == MXEventTypeRoomMessage) + { + NSString *msgtype = event.content[@"msgtype"]; + + NSString* textMessage; + if ([msgtype isEqualToString:kMXMessageTypeText]) + { + textMessage = event.content[@"body"]; + } + + // Show a confirmation popup to the end user + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + __weak typeof(self) weakSelf = self; + + UIAlertController *resendAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n resendMessage] + message:textMessage + preferredStyle:UIAlertControllerStyleAlert]; + + [resendAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + }]]; + + [resendAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Let the datasource resend. It will manage local echo, etc. + [self->roomDataSource resendEventWithEventId:eventId success:nil failure:nil]; + + }]]; + + [self presentViewController:resendAlert animated:YES completion:nil]; + currentAlert = resendAlert; + } +} + +#pragma mark - bubbles table + +- (BOOL)reloadBubblesTable:(BOOL)useBottomAnchor +{ + return [self reloadBubblesTable:useBottomAnchor invalidateBubblesCellDataCache:NO]; +} + +- (BOOL)reloadBubblesTable:(BOOL)useBottomAnchor invalidateBubblesCellDataCache:(BOOL)invalidateBubblesCellDataCache +{ + BOOL shouldScrollToBottom = shouldScrollToBottomOnTableRefresh; + + // When no size transition is in progress, check if the bottom of the content is currently visible. + // If this is the case, we will scroll automatically to the bottom after table refresh. + if (!isSizeTransitionInProgress && !shouldScrollToBottom) + { + shouldScrollToBottom = [self isBubblesTableScrollViewAtTheBottom]; + } + + // Force bubblesCellData message recalculation if requested + if (invalidateBubblesCellDataCache) + { + [self.roomDataSource invalidateBubblesCellDataCache]; + } + + // When scroll to bottom is not active, check whether we should keep the current event displayed at the bottom of the table + if (!shouldScrollToBottom && useBottomAnchor && currentEventIdAtTableBottom) + { + // Update content offset after refresh in order to keep visible the current event displayed at the bottom + + [_bubblesTableView reloadData]; + + // Retrieve the new cell index of the event displayed previously at the bottom of table + NSInteger rowIndex = [roomDataSource indexOfCellDataWithEventId:currentEventIdAtTableBottom]; + if (rowIndex != NSNotFound) + { + // Retrieve the corresponding cell + UITableViewCell *cell = [_bubblesTableView cellForRowAtIndexPath:[NSIndexPath indexPathForRow:rowIndex inSection:0]]; + UITableViewCell *cellTmp; + if (!cell) + { + NSString *reuseIdentifier = [self cellReuseIdentifierForCellData:[roomDataSource cellDataAtIndex:rowIndex]]; + // Create temporarily the cell (this cell will released at the end, to be reusable) + // Do not pass in the indexPath when creating this cell, as there is a possible crash by dequeuing + // multiple cells for the same index path if rotating the device coincides with reloading the data. + cellTmp = [_bubblesTableView dequeueReusableCellWithIdentifier:reuseIdentifier]; + cell = cellTmp; + } + + if (cell) + { + CGFloat eventTopPosition = cell.frame.origin.y; + CGFloat eventBottomPosition = eventTopPosition + cell.frame.size.height; + + // Compute accurate event positions in case of bubble with multiple components + if ([cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; + NSArray *bubbleComponents = roomBubbleTableViewCell.bubbleData.bubbleComponents; + + if (bubbleComponents.count > 1) + { + // Check and update each component position + [roomBubbleTableViewCell.bubbleData prepareBubbleComponentsPosition]; + + NSInteger index = bubbleComponents.count - 1; + MXKRoomBubbleComponent *component = bubbleComponents[index]; + + if ([component.event.eventId isEqualToString:currentEventIdAtTableBottom]) + { + eventTopPosition += roomBubbleTableViewCell.msgTextViewTopConstraint.constant + component.position.y; + } + else + { + while (index--) + { + MXKRoomBubbleComponent *previousComponent = bubbleComponents[index]; + if ([previousComponent.event.eventId isEqualToString:currentEventIdAtTableBottom]) + { + // Update top position if this is not the first component + if (index) + { + eventTopPosition += roomBubbleTableViewCell.msgTextViewTopConstraint.constant + previousComponent.position.y; + } + + eventBottomPosition = cell.frame.origin.y + roomBubbleTableViewCell.msgTextViewTopConstraint.constant + component.position.y; + break; + } + + component = previousComponent; + } + } + } + } + + // Compute the offset of the content displayed at the bottom. + CGFloat contentBottomOffsetY = _bubblesTableView.contentOffset.y + (_bubblesTableView.frame.size.height - _bubblesTableView.adjustedContentInset.bottom); + if (contentBottomOffsetY > _bubblesTableView.contentSize.height) + { + contentBottomOffsetY = _bubblesTableView.contentSize.height; + } + + // Check whether this event is no more displayed at the bottom + if ((contentBottomOffsetY <= eventTopPosition ) || (eventBottomPosition < contentBottomOffsetY)) + { + // Compute the top content offset to display again this event at the table bottom + CGFloat contentOffsetY = eventBottomPosition - (_bubblesTableView.frame.size.height - _bubblesTableView.adjustedContentInset.bottom); + + // Check if there are enought data to fill the top + if (contentOffsetY < -_bubblesTableView.adjustedContentInset.top) + { + // Scroll to the top + contentOffsetY = -_bubblesTableView.adjustedContentInset.top; + } + + CGPoint contentOffset = _bubblesTableView.contentOffset; + contentOffset.y = contentOffsetY; + [self setBubbleTableViewContentOffset:contentOffset animated:NO]; + } + + if (cellTmp && [cellTmp conformsToProtocol:@protocol(MXKCellRendering)] && [cellTmp respondsToSelector:@selector(didEndDisplay)]) + { + // Release here resources, and restore reusable cells + [(id)cellTmp didEndDisplay]; + } + } + } + } + else + { + // Do a full reload + [_bubblesTableView reloadData]; + } + + if (shouldScrollToBottom) + { + [self scrollBubblesTableViewToBottomAnimated:NO]; + } + + return shouldScrollToBottom; +} + +- (void)updateCurrentEventIdAtTableBottom:(BOOL)acknowledge +{ + // Update the identifier of the event displayed at the bottom of the table, except if a rotation or other size transition is in progress. + if (!isSizeTransitionInProgress && !self.isBubbleTableViewDisplayInTransition) + { + // Compute the content offset corresponding to the line displayed at the table bottom (just above the toolbar). + CGFloat contentBottomOffsetY = _bubblesTableView.contentOffset.y + (_bubblesTableView.frame.size.height - _bubblesTableView.adjustedContentInset.bottom); + if (contentBottomOffsetY > _bubblesTableView.contentSize.height) + { + contentBottomOffsetY = _bubblesTableView.contentSize.height; + } + // Be a bit less retrictive, consider visible an event at the bottom even if is partially hidden. + contentBottomOffsetY += 8; + + // Reset the current event id + currentEventIdAtTableBottom = nil; + + // Consider the visible cells (starting by those displayed at the bottom) + NSArray *visibleCells = [_bubblesTableView visibleCells]; + NSInteger index = visibleCells.count; + UITableViewCell *cell; + while (index--) + { + cell = visibleCells[index]; + + // Check whether the cell is actually visible + if (cell && (cell.frame.origin.y < contentBottomOffsetY)) + { + if (![cell isKindOfClass:MXKTableViewCell.class]) + { + continue; + } + + MXKCellData *cellData = ((MXKTableViewCell *)cell).mxkCellData; + + // Only 'MXKRoomBubbleCellData' is supported here for the moment. + if (![cellData isKindOfClass:MXKRoomBubbleCellData.class]) + { + continue; + } + + MXKRoomBubbleCellData *bubbleData = (MXKRoomBubbleCellData*)cellData; + + // Check which bubble component is displayed at the bottom. + // For that update each component position. + [bubbleData prepareBubbleComponentsPosition]; + + NSArray *bubbleComponents = bubbleData.bubbleComponents; + NSInteger componentIndex = bubbleComponents.count; + + CGFloat bottomPositionY = cell.frame.size.height; + + MXKRoomBubbleComponent *component; + + while (componentIndex --) + { + component = bubbleComponents[componentIndex]; + if (![cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + continue; + } + + MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; + + // Check whether the bottom part of the component is visible. + CGFloat pos = cell.frame.origin.y + bottomPositionY; + if (pos <= contentBottomOffsetY) + { + // We found the component + currentEventIdAtTableBottom = component.event.eventId; + break; + } + + // Prepare the bottom position for the next component + bottomPositionY = roomBubbleTableViewCell.msgTextViewTopConstraint.constant + component.position.y; + } + + if (currentEventIdAtTableBottom) + { + if (acknowledge && self.isEventsAcknowledgementEnabled) + { + // Indicate to the homeserver that the user has read this event. + + // Check whether the read marker must be updated. + BOOL updateReadMarker = _updateRoomReadMarker; + if (updateReadMarker && roomDataSource.room.accountData.readMarkerEventId) + { + MXEvent *currentReadMarkerEvent = [roomDataSource.mxSession.store eventWithEventId:roomDataSource.room.accountData.readMarkerEventId inRoom:roomDataSource.roomId]; + if (!currentReadMarkerEvent) + { + currentReadMarkerEvent = [roomDataSource eventWithEventId:roomDataSource.room.accountData.readMarkerEventId]; + } + + // Update the read marker only if the current event is available, and the new event is posterior to it. + updateReadMarker = (currentReadMarkerEvent && (currentReadMarkerEvent.originServerTs <= component.event.originServerTs)); + } + + [roomDataSource.room acknowledgeEvent:component.event andUpdateReadMarker:updateReadMarker]; + } + break; + } + // else we consider the previous cell. + } + } + } +} + +#pragma mark - MXKDataSourceDelegate + +- (Class)cellViewClassForCellData:(MXKCellData*)cellData +{ + Class cellViewClass = nil; + + // Sanity check + if ([cellData conformsToProtocol:@protocol(MXKRoomBubbleCellDataStoring)]) + { + id bubbleData = (id)cellData; + + // Select the suitable table view cell class + if (bubbleData.isIncoming) + { + if (bubbleData.isAttachmentWithThumbnail) + { + if (bubbleData.shouldHideSenderInformation) + { + cellViewClass = MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.class; + } + else + { + cellViewClass = MXKRoomIncomingAttachmentBubbleCell.class; + } + } + else + { + if (bubbleData.shouldHideSenderInformation) + { + cellViewClass = MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.class; + } + else + { + cellViewClass = MXKRoomIncomingTextMsgBubbleCell.class; + } + } + } + else if (bubbleData.isAttachmentWithThumbnail) + { + if (bubbleData.shouldHideSenderInformation) + { + cellViewClass = MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.class; + } + else + { + cellViewClass = MXKRoomOutgoingAttachmentBubbleCell.class; + } + } + else + { + if (bubbleData.shouldHideSenderInformation) + { + cellViewClass = MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.class; + } + else + { + cellViewClass = MXKRoomOutgoingTextMsgBubbleCell.class; + } + } + } + + return cellViewClass; +} + +- (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData +{ + Class class = [self cellViewClassForCellData:cellData]; + + if ([class respondsToSelector:@selector(defaultReuseIdentifier)]) + { + return [class defaultReuseIdentifier]; + } + + return nil; +} + +- (void)dataSource:(MXKDataSource *)dataSource didCellChange:(id)changes +{ + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication && sharedApplication.applicationState != UIApplicationStateActive) + { + // Do nothing at the UI level if the application do a sync in background + return; + } + + if (isPaginationInProgress) + { + // Ignore these changes, the table will be full updated at the end of pagination. + return; + } + + if (self.attachmentsViewer) + { + // Refresh the current attachments list without changing the current displayed attachment (see focus = nil). + NSArray *attachmentsWithThumbnail = self.roomDataSource.attachmentsWithThumbnail; + [self.attachmentsViewer displayAttachments:attachmentsWithThumbnail focusOn:nil]; + } + + self.bubbleTableViewDisplayInTransition = YES; + + CGPoint contentOffset = self.bubblesTableView.contentOffset; + + BOOL hasScrolledToTheBottom = [self reloadBubblesTable:YES]; + + // If the user is scrolling while we reload the data for a new incoming message for example, + // there will be a jump in the table view display. + // Resetting the contentOffset after the reload fixes the issue. + if (hasScrolledToTheBottom == NO) + { + [self setBubbleTableViewContentOffset:contentOffset animated:NO]; + } + + self.bubbleTableViewDisplayInTransition = NO; +} + +- (void)dataSource:(MXKDataSource *)dataSource didStateChange:(MXKDataSourceState)state +{ + [self updateViewControllerAppearanceOnRoomDataSourceState]; + + if (state == MXKDataSourceStateReady) + { + [self onRoomDataSourceReady]; + } +} + +- (void)dataSource:(MXKDataSource *)dataSource didRecognizeAction:(NSString *)actionIdentifier inCell:(id)cell userInfo:(NSDictionary *)userInfo +{ + MXLogDebug(@"Gesture %@ has been recognized in %@. UserInfo: %@", actionIdentifier, cell, userInfo); + + if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnMessageTextView]) + { + MXLogDebug(@" -> A message has been tapped"); + } + else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnSenderNameLabel] || [actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnAvatarView]) + { +// MXLogDebug(@" -> Name or avatar of %@ has been tapped", userInfo[kMXKRoomBubbleCellUserIdKey]); + + // Add the member display name in text input + MXRoomMember *selectedRoomMember = [roomDataSource.roomState.members memberWithUserId:userInfo[kMXKRoomBubbleCellUserIdKey]]; + if (selectedRoomMember) + { + [self mention:selectedRoomMember]; + } + } + else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnDateTimeContainer]) + { + roomDataSource.showBubblesDateTime = !roomDataSource.showBubblesDateTime; + MXLogDebug(@" -> Turn %@ cells date", roomDataSource.showBubblesDateTime ? @"ON" : @"OFF"); + } + else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnAttachmentView] && [cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + [self showAttachmentInCell:(MXKRoomBubbleTableViewCell *)cell]; + } + else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellLongPressOnProgressView] && [cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; + + // Check if there is a download in progress, then offer to cancel it + NSString *downloadId = roomBubbleTableViewCell.bubbleData.attachment.downloadId; + if ([MXMediaManager existingDownloaderWithIdentifier:downloadId]) + { + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + __weak __typeof(self) weakSelf = self; + UIAlertController *cancelAlert = [UIAlertController alertControllerWithTitle:nil + message:[MatrixKitL10n attachmentCancelDownload] + preferredStyle:UIAlertControllerStyleAlert]; + + [cancelAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n no] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + }]]; + + [cancelAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n yes] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Get again the loader + MXMediaLoader *loader = [MXMediaManager existingDownloaderWithIdentifier:downloadId]; + if (loader) + { + [loader cancel]; + } + + // Hide the progress animation + roomBubbleTableViewCell.progressView.hidden = YES; + + }]]; + + [self presentViewController:cancelAlert animated:YES completion:nil]; + currentAlert = cancelAlert; + } + else if (roomBubbleTableViewCell.bubbleData.attachment.eventSentState == MXEventSentStatePreparing || + roomBubbleTableViewCell.bubbleData.attachment.eventSentState == MXEventSentStateEncrypting || + roomBubbleTableViewCell.bubbleData.attachment.eventSentState == MXEventSentStateUploading) + { + // Offer to cancel the upload in progress + // Upload id is stored in attachment url (nasty trick) + NSString *uploadId = roomBubbleTableViewCell.bubbleData.attachment.contentURL; + if ([MXMediaManager existingUploaderWithId:uploadId]) + { + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + __weak __typeof(self) weakSelf = self; + UIAlertController *cancelAlert = [UIAlertController alertControllerWithTitle:nil + message:[MatrixKitL10n attachmentCancelUpload] + preferredStyle:UIAlertControllerStyleAlert]; + + [cancelAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n no] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + }]]; + + [cancelAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n yes] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + // TODO cancel the attachment encryption if it is in progress. + + // Get again the loader + MXMediaLoader *loader = [MXMediaManager existingUploaderWithId:uploadId]; + if (loader) + { + [loader cancel]; + } + + // Hide the progress animation + roomBubbleTableViewCell.progressView.hidden = YES; + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Remove the outgoing message and its related cached file. + [[NSFileManager defaultManager] removeItemAtPath:roomBubbleTableViewCell.bubbleData.attachment.cacheFilePath error:nil]; + [[NSFileManager defaultManager] removeItemAtPath:roomBubbleTableViewCell.bubbleData.attachment.thumbnailCachePath error:nil]; + [self.roomDataSource removeEventWithEventId:roomBubbleTableViewCell.bubbleData.attachment.eventId]; + } + + }]]; + + [self presentViewController:cancelAlert animated:YES completion:nil]; + currentAlert = cancelAlert; + } + } + } + else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellLongPressOnEvent] && [cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + [self dismissKeyboard]; + + MXEvent *selectedEvent = userInfo[kMXKRoomBubbleCellEventKey]; + MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; + MXKAttachment *attachment = roomBubbleTableViewCell.bubbleData.attachment; + + if (selectedEvent) + { + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + currentAlert = nil; + + // Cancel potential text selection in other bubbles + for (MXKRoomBubbleTableViewCell *bubble in self.bubblesTableView.visibleCells) + { + [bubble highlightTextMessageForEvent:nil]; + } + } + + __weak __typeof(self) weakSelf = self; + UIAlertController *actionSheet = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + + // Add actions for a failed event + if (selectedEvent.sentState == MXEventSentStateFailed) + { + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n resend] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Let the datasource resend. It will manage local echo, etc. + [self.roomDataSource resendEventWithEventId:selectedEvent.eventId success:nil failure:nil]; + + }]]; + + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n delete] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + [self.roomDataSource removeEventWithEventId:selectedEvent.eventId]; + + }]]; + } + + // Add actions for text message + if (!attachment) + { + // Highlight the select event + [roomBubbleTableViewCell highlightTextMessageForEvent:selectedEvent.eventId]; + + // Retrieved data related to the selected event + NSArray *components = roomBubbleTableViewCell.bubbleData.bubbleComponents; + MXKRoomBubbleComponent *selectedComponent; + for (selectedComponent in components) + { + if ([selectedComponent.event.eventId isEqualToString:selectedEvent.eventId]) + { + break; + } + selectedComponent = nil; + } + + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n copy] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Cancel event highlighting + [roomBubbleTableViewCell highlightTextMessageForEvent:nil]; + + NSString *textMessage = selectedComponent.textMessage; + + if (textMessage) + { + MXKPasteboardManager.shared.pasteboard.string = textMessage; + } + else + { + MXLogDebug(@"[MXKRoomViewController] Copy text failed. Text is nil for room id/event id: %@/%@", selectedComponent.event.roomId, selectedComponent.event.eventId); + } + }]]; + + if ([MXKAppSettings standardAppSettings].messageDetailsAllowSharing) + { + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n share] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Cancel event highlighting + [roomBubbleTableViewCell highlightTextMessageForEvent:nil]; + + NSArray *activityItems = [NSArray arrayWithObjects:selectedComponent.textMessage, nil]; + + UIActivityViewController *activityViewController = [[UIActivityViewController alloc] initWithActivityItems:activityItems applicationActivities:nil]; + if (activityViewController) + { + activityViewController.modalTransitionStyle = UIModalTransitionStyleCoverVertical; + activityViewController.popoverPresentationController.sourceView = roomBubbleTableViewCell; + activityViewController.popoverPresentationController.sourceRect = roomBubbleTableViewCell.bounds; + + [self presentViewController:activityViewController animated:YES completion:nil]; + } + + }]]; + } + + if (components.count > 1) + { + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n selectAll] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + [self selectAllTextMessageInCell:cell]; + + }]]; + } + } + else // Add action for attachment + { + if (attachment.type == MXKAttachmentTypeImage || attachment.type == MXKAttachmentTypeVideo) + { + if ([MXKAppSettings standardAppSettings].messageDetailsAllowSaving) + { + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n save] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + [self startActivityIndicator]; + + [attachment save:^{ + + typeof(self) self = weakSelf; + [self stopActivityIndicator]; + + } failure:^(NSError *error) { + + typeof(self) self = weakSelf; + [self stopActivityIndicator]; + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }]; + + // Start animation in case of download during attachment preparing + [roomBubbleTableViewCell startProgressUI]; + + }]]; + } + } + + if (attachment.type != MXKAttachmentTypeSticker) + { + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n copyButtonName] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + [self startActivityIndicator]; + + [attachment copy:^{ + + typeof(self) self = weakSelf; + [self stopActivityIndicator]; + + } failure:^(NSError *error) { + + typeof(self) self = weakSelf; + [self stopActivityIndicator]; + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }]; + + // Start animation in case of download during attachment preparing + [roomBubbleTableViewCell startProgressUI]; + + }]]; + + if ([MXKAppSettings standardAppSettings].messageDetailsAllowSharing) + { + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n share] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + [attachment prepareShare:^(NSURL *fileURL) { + + typeof(self) self = weakSelf; + self->documentInteractionController = [UIDocumentInteractionController interactionControllerWithURL:fileURL]; + [self->documentInteractionController setDelegate:self]; + self->currentSharedAttachment = attachment; + + if (![self->documentInteractionController presentOptionsMenuFromRect:self.view.frame inView:self.view animated:YES]) + { + self->documentInteractionController = nil; + [attachment onShareEnded]; + self->currentSharedAttachment = nil; + } + + } failure:^(NSError *error) { + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }]; + + // Start animation in case of download during attachment preparing + [roomBubbleTableViewCell startProgressUI]; + + }]]; + } + } + + // Check status of the selected event + if (selectedEvent.sentState == MXEventSentStatePreparing || + selectedEvent.sentState == MXEventSentStateEncrypting || + selectedEvent.sentState == MXEventSentStateUploading) + { + // Upload id is stored in attachment url (nasty trick) + NSString *uploadId = roomBubbleTableViewCell.bubbleData.attachment.contentURL; + if ([MXMediaManager existingUploaderWithId:uploadId]) + { + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancelUpload] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + // TODO cancel the attachment encryption if it is in progress. + + // Cancel the loader + MXMediaLoader *loader = [MXMediaManager existingUploaderWithId:uploadId]; + if (loader) + { + [loader cancel]; + } + + // Hide the progress animation + roomBubbleTableViewCell.progressView.hidden = YES; + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Remove the outgoing message and its related cached file. + [[NSFileManager defaultManager] removeItemAtPath:roomBubbleTableViewCell.bubbleData.attachment.cacheFilePath error:nil]; + [[NSFileManager defaultManager] removeItemAtPath:roomBubbleTableViewCell.bubbleData.attachment.thumbnailCachePath error:nil]; + [self.roomDataSource removeEventWithEventId:selectedEvent.eventId]; + } + + }]]; + } + } + } + + // Check status of the selected event + if (selectedEvent.sentState == MXEventSentStateSent) + { + // Check whether download is in progress + if (selectedEvent.isMediaAttachment) + { + NSString *downloadId = roomBubbleTableViewCell.bubbleData.attachment.downloadId; + if ([MXMediaManager existingDownloaderWithIdentifier:downloadId]) + { + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancelDownload] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Get again the loader + MXMediaLoader *loader = [MXMediaManager existingDownloaderWithIdentifier:downloadId]; + if (loader) + { + [loader cancel]; + } + // Hide the progress animation + roomBubbleTableViewCell.progressView.hidden = YES; + + }]]; + } + } + + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n showDetails] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Cancel event highlighting (if any) + [roomBubbleTableViewCell highlightTextMessageForEvent:nil]; + + // Display event details + [self showEventDetails:selectedEvent]; + + }]]; + } + + [actionSheet addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleCancel + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Cancel event highlighting (if any) + [roomBubbleTableViewCell highlightTextMessageForEvent:nil]; + + }]]; + + // Do not display empty action sheet + if (actionSheet.actions.count > 1) + { + [actionSheet popoverPresentationController].sourceView = roomBubbleTableViewCell; + [actionSheet popoverPresentationController].sourceRect = roomBubbleTableViewCell.bounds; + [self presentViewController:actionSheet animated:YES completion:nil]; + currentAlert = actionSheet; + } + } + } + else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellLongPressOnAvatarView]) + { + MXLogDebug(@" -> Avatar of %@ has been long pressed", userInfo[kMXKRoomBubbleCellUserIdKey]); + } + else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellUnsentButtonPressed]) + { + MXEvent *selectedEvent = userInfo[kMXKRoomBubbleCellEventKey]; + if (selectedEvent) + { + // The user may want to resend it + [self promptUserToResendEvent:selectedEvent.eventId]; + } + } +} + +#pragma mark - Clipboard + +- (void)selectAllTextMessageInCell:(id)cell +{ + if (![MXKAppSettings standardAppSettings].messageDetailsAllowSharing) + { + return; + } + + if ([cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; + selectedText = roomBubbleTableViewCell.bubbleData.textMessage; + roomBubbleTableViewCell.allTextHighlighted = YES; + + // Display Menu (dispatch is required here, else the attributed text change hides the menu) + dispatch_async(dispatch_get_main_queue(), ^{ + MXWeakify(self); + self.uiMenuControllerDidHideMenuNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIMenuControllerDidHideMenuNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXStrongifyAndReturnIfNil(self); + // Deselect text + roomBubbleTableViewCell.allTextHighlighted = NO; + self->selectedText = nil; + + [UIMenuController sharedMenuController].menuItems = nil; + + [[NSNotificationCenter defaultCenter] removeObserver:self.uiMenuControllerDidHideMenuNotificationObserver]; + }]; + + [self becomeFirstResponder]; + UIMenuController *menu = [UIMenuController sharedMenuController]; + menu.menuItems = @[[[UIMenuItem alloc] initWithTitle:[MatrixKitL10n share] action:@selector(share:)]]; + [menu setTargetRect:roomBubbleTableViewCell.messageTextView.frame inView:roomBubbleTableViewCell]; + [menu setMenuVisible:YES animated:YES]; + }); + } +} + +- (void)copy:(id)sender +{ + if (selectedText) + { + MXKPasteboardManager.shared.pasteboard.string = selectedText; + } + else + { + MXLogDebug(@"[MXKRoomViewController] Selected text copy failed. Selected text is nil"); + } +} + +- (void)share:(id)sender +{ + if (selectedText) + { + NSArray *activityItems = [NSArray arrayWithObjects:selectedText, nil]; + + UIActivityViewController *activityViewController = [[UIActivityViewController alloc] initWithActivityItems:activityItems applicationActivities:nil]; + if (activityViewController) + { + activityViewController.modalTransitionStyle = UIModalTransitionStyleCoverVertical; + activityViewController.popoverPresentationController.sourceView = self.view; + activityViewController.popoverPresentationController.sourceRect = self.view.bounds; + + [self presentViewController:activityViewController animated:YES completion:nil]; + } + } +} + +- (BOOL)canPerformAction:(SEL)action withSender:(id)sender +{ + if (selectedText.length && (action == @selector(copy:) || action == @selector(share:))) + { + return YES; + } + return NO; +} + +- (BOOL)canBecomeFirstResponder +{ + return (selectedText.length != 0); +} + +#pragma mark - UITableView delegate + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (tableView == _bubblesTableView) + { + return [roomDataSource cellHeightAtIndex:indexPath.row withMaximumWidth:self.tableViewSafeAreaWidth]; + } + + return 0; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (tableView == _bubblesTableView) + { + // Dismiss keyboard when user taps on messages table view content + [self dismissKeyboard]; + } +} + +- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath*)indexPath +{ + // Release here resources, and restore reusable cells + if ([cell respondsToSelector:@selector(didEndDisplay)]) + { + [(id)cell didEndDisplay]; + } +} + +- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset +{ + // Detect vertical bounce at the top of the tableview to trigger pagination + if (scrollView == _bubblesTableView) + { + // Detect top bounce + if (scrollView.contentOffset.y < -scrollView.adjustedContentInset.top) + { + // Shall we add back pagination spinner? + if (isPaginationInProgress && !backPaginationActivityView) + { + UIActivityIndicatorView* spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray]; + spinner.hidesWhenStopped = NO; + spinner.backgroundColor = [UIColor clearColor]; + [spinner startAnimating]; + + // no need to manage constraints here + // IOS defines them. + // since IOS7 the spinner is centered so need to create a background and add it. + _bubblesTableView.tableHeaderView = backPaginationActivityView = spinner; + } + } + else + { + // Shall we add forward pagination spinner? + if (!roomDataSource.isLive && isPaginationInProgress && scrollView.contentOffset.y + scrollView.frame.size.height > scrollView.contentSize.height + 64 && !reconnectingView) + { + [self addReconnectingView]; + } + else + { + [self detectPullToKick:scrollView]; + } + } + } +} + +- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate +{ + if (scrollView == _bubblesTableView) + { + // if the user scrolls the history content without animation + // upateCurrentEventIdAtTableBottom must be called here (without dispatch). + // else it will be done in scrollViewDidEndDecelerating + if (!decelerate) + { + [self updateCurrentEventIdAtTableBottom:YES]; + } + } +} + +- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView +{ + if (scrollView == _bubblesTableView) + { + // do not dispatch the upateCurrentEventIdAtTableBottom call + // else it might triggers weird UI lags. + [self updateCurrentEventIdAtTableBottom:YES]; + [self managePullToKick:scrollView]; + } +} + +- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView +{ + if (scrollView == _bubblesTableView) + { + // do not dispatch the upateCurrentEventIdAtTableBottom call + // else it might triggers weird UI lags. + [self updateCurrentEventIdAtTableBottom:YES]; + } +} + +- (void)scrollViewDidScroll:(UIScrollView *)scrollView +{ + if (scrollView == _bubblesTableView) + { + BOOL wasScrollingToBottom = isScrollingToBottom; + + // Consider this callback to reset scrolling to bottom flag + isScrollingToBottom = NO; + + // shouldScrollToBottomOnTableRefresh is used to inhibit false detection of + // scrolling action from the user when the viewVC appears or rotates + if (scrollView == _bubblesTableView && scrollView.contentSize.height && !shouldScrollToBottomOnTableRefresh) + { + // when the content size if smaller that the frame + // scrollViewDidEndDecelerating is not called + // so test it when the content offset goes back to the screen top. + if ((scrollView.contentSize.height < scrollView.frame.size.height) && (-scrollView.contentOffset.y == scrollView.adjustedContentInset.top)) + { + [self managePullToKick:scrollView]; + } + + // Trigger inconspicuous pagination when user scrolls toward the top + if (scrollView.contentOffset.y < _paginationThreshold) + { + [self triggerPagination:_paginationLimit direction:MXTimelineDirectionBackwards]; + } + // Enable forwards pagination when displaying non live timeline + else if (!roomDataSource.isLive && !wasScrollingToBottom && ((scrollView.contentSize.height - scrollView.contentOffset.y - scrollView.frame.size.height) < _paginationThreshold)) + { + [self triggerPagination:_paginationLimit direction:MXTimelineDirectionForwards]; + } + } + + if (wasScrollingToBottom) + { + // When scrolling to the bottom is performed without animation, 'scrollViewDidEndScrollingAnimation' is not called. + // upateCurrentEventIdAtTableBottom must be called here (without dispatch). + [self updateCurrentEventIdAtTableBottom:YES]; + } + } +} + +#pragma mark - MXKRoomTitleViewDelegate + +- (void)roomTitleView:(MXKRoomTitleView*)titleView presentAlertController:(UIAlertController *)alertController +{ + [self dismissKeyboard]; + [self presentViewController:alertController animated:YES completion:nil]; +} + +- (BOOL)roomTitleViewShouldBeginEditing:(MXKRoomTitleView*)titleView +{ + return YES; +} + +- (void)roomTitleView:(MXKRoomTitleView*)titleView isSaving:(BOOL)saving +{ + if (saving) + { + [self startActivityIndicator]; + } + else + { + [self stopActivityIndicator]; + } +} + +#pragma mark - MXKRoomInputToolbarViewDelegate + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView *)toolbarView hideStatusBar:(BOOL)isHidden +{ + isStatusBarHidden = isHidden; + + // Trigger status bar update + [self setNeedsStatusBarAppearanceUpdate]; + + // Handle status bar with the historical method. + // TODO: remove this [UIApplication statusBarHidden] use (deprecated since iOS 9). + // Note: setting statusBarHidden does nothing if your application is using the default UIViewController-based status bar system. + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + sharedApplication.statusBarHidden = isHidden; + } +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView isTyping:(BOOL)typing +{ + if (_saveProgressTextInput && roomDataSource) + { + // Store the potential message partially typed in text input + roomDataSource.partialTextMessage = inputToolbarView.textMessage; + } + + [self handleTypingNotification:typing]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView heightDidChanged:(CGFloat)height completion:(void (^)(BOOL finished))completion +{ + _roomInputToolbarContainerHeightConstraint.constant = height; + + // Update layout with animation + [UIView animateWithDuration:self.resizeComposerAnimationDuration delay:0 options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseIn + animations:^{ + // We will scroll to bottom if the bottom of the table is currently visible + BOOL shouldScrollToBottom = [self isBubblesTableScrollViewAtTheBottom]; + + CGFloat bubblesTableViewBottomConst = self->_roomInputToolbarContainerBottomConstraint.constant + self->_roomInputToolbarContainerHeightConstraint.constant + self->_roomActivitiesContainerHeightConstraint.constant; + + self->_bubblesTableViewBottomConstraint.constant = bubblesTableViewBottomConst; + + // Force to render the view + [self.view layoutIfNeeded]; + + if (shouldScrollToBottom) + { + [self scrollBubblesTableViewToBottomAnimated:NO]; + } + } + completion:^(BOOL finished){ + if (completion) + { + completion(finished); + } + }]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendTextMessage:(NSString*)textMessage +{ + // Handle potential IRC commands in typed string + if ([self isIRCStyleCommand:textMessage] == NO) + { + // Send text message in the current room + [self sendTextMessage:textMessage]; + } +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendImage:(UIImage*)image +{ + // Let the datasource send it and manage the local echo + [roomDataSource sendImage:image success:nil failure:^(NSError *error) + { + // Nothing to do. The image is marked as unsent in the room history by the datasource + MXLogDebug(@"[MXKRoomViewController] sendImage failed."); + }]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendImage:(NSData*)imageData withMimeType:(NSString*)mimetype +{ + // Let the datasource send it and manage the local echo + [roomDataSource sendImage:imageData mimeType:mimetype success:nil failure:^(NSError *error) + { + // Nothing to do. The image is marked as unsent in the room history by the datasource + MXLogDebug(@"[MXKRoomViewController] sendImage with mimetype failed."); + }]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendVideo:(NSURL*)videoLocalURL withThumbnail:(UIImage*)videoThumbnail +{ + AVURLAsset *videoAsset = [AVURLAsset assetWithURL:videoLocalURL]; + [self roomInputToolbarView:toolbarView sendVideoAsset:videoAsset withThumbnail:videoThumbnail]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendVideoAsset:(AVAsset*)videoAsset withThumbnail:(UIImage*)videoThumbnail +{ + // Let the datasource send it and manage the local echo + [roomDataSource sendVideoAsset:videoAsset withThumbnail:videoThumbnail success:nil failure:^(NSError *error) + { + // Nothing to do. The video is marked as unsent in the room history by the datasource + MXLogDebug(@"[MXKRoomViewController] sendVideo failed."); + }]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendFile:(NSURL *)fileLocalURL withMimeType:(NSString*)mimetype +{ + // Let the datasource send it and manage the local echo + [roomDataSource sendFile:fileLocalURL mimeType:mimetype success:nil failure:^(NSError *error) + { + // Nothing to do. The file is marked as unsent in the room history by the datasource + MXLogDebug(@"[MXKRoomViewController] sendFile failed."); + }]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView presentAlertController:(UIAlertController *)alertController +{ + [self dismissKeyboard]; + [self presentViewController:alertController animated:YES completion:nil]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView presentViewController:(UIViewController*)viewControllerToPresent +{ + [self dismissKeyboard]; + [self presentViewController:viewControllerToPresent animated:YES completion:nil]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView dismissViewControllerAnimated:(BOOL)flag completion:(void (^)(void))completion +{ + [self dismissViewControllerAnimated:flag completion:completion]; +} + +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView updateActivityIndicator:(BOOL)isAnimating +{ + isInputToolbarProcessing = isAnimating; + + if (isAnimating) + { + [self startActivityIndicator]; + } + else + { + [self stopActivityIndicator]; + } +} +# pragma mark - Typing notification + +- (void)handleTypingNotification:(BOOL)typing +{ + NSUInteger notificationTimeoutMS = -1; + if (typing) + { + // Check whether a typing event has been already reported to server (We wait for the end of the local timout before considering this new event) + if (typingTimer) + { + // Refresh date of the last observed typing + lastTypingDate = [[NSDate alloc] init]; + return; + } + + // No typing event has been yet reported -> share encryption keys if requested + if ([MXKAppSettings standardAppSettings].outboundGroupSessionKeyPreSharingStrategy == MXKKeyPreSharingWhenTyping) + { + [self shareEncryptionKeys]; + } + + // Launch a timer to prevent sending multiple typing notifications + NSTimeInterval timerTimeout = MXKROOMVIEWCONTROLLER_DEFAULT_TYPING_TIMEOUT_SEC; + if (lastTypingDate) + { + NSTimeInterval lastTypingAge = -[lastTypingDate timeIntervalSinceNow]; + if (lastTypingAge < timerTimeout) + { + // Subtract the time interval since last typing from the timer timeout + timerTimeout -= lastTypingAge; + } + else + { + timerTimeout = 0; + } + } + else + { + // Keep date of this typing event + lastTypingDate = [[NSDate alloc] init]; + } + + if (timerTimeout) + { + typingTimer = [NSTimer scheduledTimerWithTimeInterval:timerTimeout target:self selector:@selector(typingTimeout:) userInfo:self repeats:NO]; + // Compute the notification timeout in ms (consider the double of the local typing timeout) + notificationTimeoutMS = 2000 * MXKROOMVIEWCONTROLLER_DEFAULT_TYPING_TIMEOUT_SEC; + } + else + { + // This typing event is too old, we will ignore it + typing = NO; + MXLogDebug(@"[MXKRoomVC] Ignore typing event (too old)"); + } + } + else + { + // Cancel any typing timer + [typingTimer invalidate]; + typingTimer = nil; + // Reset last typing date + lastTypingDate = nil; + } + + MXWeakify(self); + + // Send typing notification to server + [roomDataSource.room sendTypingNotification:typing + timeout:notificationTimeoutMS + success:^{ + + MXStrongifyAndReturnIfNil(self); + // Reset last typing date + self->lastTypingDate = nil; + } failure:^(NSError *error) + { + MXStrongifyAndReturnIfNil(self); + + MXLogDebug(@"[MXKRoomVC] Failed to send typing notification (%d)", typing); + + // Cancel timer (if any) + [self->typingTimer invalidate]; + self->typingTimer = nil; + }]; +} + +- (IBAction)typingTimeout:(id)sender +{ + [typingTimer invalidate]; + typingTimer = nil; + + // Check whether a new typing event has been observed + BOOL typing = (lastTypingDate != nil); + // Post a new typing notification + [self handleTypingNotification:typing]; +} + + +# pragma mark - Attachment handling + +- (void)showAttachmentInCell:(UITableViewCell*)cell +{ + [self dismissKeyboard]; + + // Retrieve the attachment information from the associated cell data + if ([cell isKindOfClass:MXKTableViewCell.class]) + { + MXKCellData *cellData = ((MXKTableViewCell*)cell).mxkCellData; + + // Only 'MXKRoomBubbleCellData' is supported here for the moment. + if ([cellData isKindOfClass:MXKRoomBubbleCellData.class]) + { + MXKRoomBubbleCellData *bubbleData = (MXKRoomBubbleCellData*)cellData; + + MXKAttachment *selectedAttachment = bubbleData.attachment; + + if (bubbleData.isAttachmentWithThumbnail) + { + // The attachments viewer is opened only on a valid attachment. It does not display the stickers. + if (selectedAttachment.eventSentState == MXEventSentStateSent && selectedAttachment.type != MXKAttachmentTypeSticker) + { + // Note: the stickers are presently excluded from the attachments list returned by the room dataSource. + NSArray *attachmentsWithThumbnail = self.roomDataSource.attachmentsWithThumbnail; + + MXKAttachmentsViewController *attachmentsViewer; + + // Present an attachment viewer + if (attachmentsViewerClass) + { + attachmentsViewer = [attachmentsViewerClass animatedAttachmentsViewControllerWithSourceViewController:self]; + } + else + { + attachmentsViewer = [MXKAttachmentsViewController animatedAttachmentsViewControllerWithSourceViewController:self]; + } + + attachmentsViewer.delegate = self; + attachmentsViewer.complete = ([roomDataSource.timeline canPaginate:MXTimelineDirectionBackwards] == NO); + attachmentsViewer.hidesBottomBarWhenPushed = YES; + [attachmentsViewer displayAttachments:attachmentsWithThumbnail focusOn:selectedAttachment.eventId]; + + // Keep here the image view used to display the attachment in the selected cell. + // Note: Only `MXKRoomBubbleTableViewCell` and `MXKSearchTableViewCell` are supported for the moment. + if ([cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + self.openedAttachmentImageView = ((MXKRoomBubbleTableViewCell *)cell).attachmentView.imageView; + } + else if ([cell isKindOfClass:MXKSearchTableViewCell.class]) + { + self.openedAttachmentImageView = ((MXKSearchTableViewCell *)cell).attachmentImageView.imageView; + } + + self.openedAttachmentEventId = selectedAttachment.eventId; + + // "Initializing" closedAttachmentEventId so it is equal to openedAttachmentEventId at the beginning + self.closedAttachmentEventId = self.openedAttachmentEventId; + + if (@available(iOS 13.0, *)) + { + attachmentsViewer.modalPresentationStyle = UIModalPresentationFullScreen; + } + + [self presentViewController:attachmentsViewer animated:YES completion:nil]; + + self.attachmentsViewer = attachmentsViewer; + } + else + { + // Let's the application do something + MXLogDebug(@"[MXKRoomVC] showAttachmentInCell on an unsent media"); + } + } + else if (selectedAttachment.type == MXKAttachmentTypeLocation) + { + } + else if (selectedAttachment.type == MXKAttachmentTypeFile || selectedAttachment.type == MXKAttachmentTypeAudio) + { + // Start activity indicator as feedback on file selection. + [self startActivityIndicator]; + + [selectedAttachment prepareShare:^(NSURL *fileURL) { + + [self stopActivityIndicator]; + + MXWeakify(self); + void(^viewAttachment)(void) = ^() { + + MXStrongifyAndReturnIfNil(self); + + if (![self canPreviewFileAttachment:selectedAttachment withLocalFileURL:fileURL]) + { + // When we don't support showing a preview for a file, show a share + // sheet if allowed, otherwise display an error to inform the user. + if (self.allowActionsInDocumentPreview) + { + UIActivityViewController *shareSheet = [[UIActivityViewController alloc] initWithActivityItems:@[fileURL] + applicationActivities:nil]; + MXWeakify(self); + shareSheet.completionWithItemsHandler = ^(UIActivityType activityType, BOOL completed, NSArray *returnedItems, NSError *activityError) { + MXStrongifyAndReturnIfNil(self); + [selectedAttachment onShareEnded]; + self->currentSharedAttachment = nil; + }; + + self->currentSharedAttachment = selectedAttachment; + [self presentViewController:shareSheet animated:YES completion:nil]; + } + else + { + UIAlertController *alert = [UIAlertController alertControllerWithTitle:MatrixKitL10n.attachmentUnsupportedPreviewTitle + message:MatrixKitL10n.attachmentUnsupportedPreviewMessage + preferredStyle:UIAlertControllerStyleAlert]; + MXWeakify(self); + [alert addAction:[UIAlertAction actionWithTitle:MatrixKitL10n.ok style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) { + MXStrongifyAndReturnIfNil(self); + [selectedAttachment onShareEnded]; + self->currentAlert = nil; + }]]; + + [self presentViewController:alert animated:YES completion:nil]; + self->currentAlert = alert; + } + + return; + } + + if (self.allowActionsInDocumentPreview) + { + // We could get rid of this part of code and use only a MXKPreviewViewController + // Nevertheless, MXKRoomViewController is compliant to UIDocumentInteractionControllerDelegate + // and remove all this code could have effect on some custom implementations. + self->documentInteractionController = [UIDocumentInteractionController interactionControllerWithURL:fileURL]; + [self->documentInteractionController setDelegate:self]; + self->currentSharedAttachment = selectedAttachment; + + if (![self->documentInteractionController presentPreviewAnimated:YES]) + { + if (![self->documentInteractionController presentOptionsMenuFromRect:self.view.frame inView:self.view animated:YES]) + { + self->documentInteractionController = nil; + [selectedAttachment onShareEnded]; + self->currentSharedAttachment = nil; + } + } + } + else + { + self->currentSharedAttachment = selectedAttachment; + [MXKPreviewViewController presentFrom:self fileUrl:fileURL allowActions:self.allowActionsInDocumentPreview delegate:self]; + } + }; + + if (self->roomDataSource.mxSession.crypto + && [selectedAttachment.contentInfo[@"mimetype"] isEqualToString:@"text/plain"] + && [MXMegolmExportEncryption isMegolmKeyFile:fileURL]) + { + // The file is a megolm key file + // Ask the user if they wants to view the file as a classic file attachment + // or open an import process + [self->currentAlert dismissViewControllerAnimated:NO completion:nil]; + + __weak typeof(self) weakSelf = self; + UIAlertController *keysPrompt = [UIAlertController alertControllerWithTitle:@"" + message:[MatrixKitL10n attachmentE2eKeysFilePrompt] + preferredStyle:UIAlertControllerStyleAlert]; + + [keysPrompt addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n view] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + // View file content + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + + viewAttachment(); + } + + }]]; + + [keysPrompt addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n attachmentE2eKeysImport] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + + // Show the keys import dialog + self->importView = [[MXKEncryptionKeysImportView alloc] initWithMatrixSession:self->roomDataSource.mxSession]; + self->currentAlert = self->importView.alertController; + [self->importView showInViewController:self toImportKeys:fileURL onComplete:^{ + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + self->importView = nil; + } + + }]; + } + + }]]; + + [self presentViewController:keysPrompt animated:YES completion:nil]; + self->currentAlert = keysPrompt; + } + else + { + viewAttachment(); + } + + } failure:^(NSError *error) { + + [self stopActivityIndicator]; + + // Notify MatrixKit user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }]; + + if ([cell isKindOfClass:MXKRoomBubbleTableViewCell.class]) + { + // Start animation in case of download + MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; + [roomBubbleTableViewCell startProgressUI]; + } + } + } + } +} + +- (BOOL)canPreviewFileAttachment:(MXKAttachment *)attachment withLocalFileURL:(NSURL *)localFileURL +{ + // Sanity check. + if (![NSFileManager.defaultManager isReadableFileAtPath:localFileURL.path]) + { + return NO; + } + + if (UIDevice.currentDevice.systemVersion.floatValue >= 13) + { + return YES; + } + + MXKUTI *attachmentUTI = attachment.uti; + MXKUTI *fileUTI = [[MXKUTI alloc] initWithLocalFileURL:localFileURL]; + if (!attachmentUTI || !fileUTI) + { + return NO; + } + + NSArray *unsupportedUTIs = @[MXKUTI.html, MXKUTI.xml, MXKUTI.svg]; + if ([attachmentUTI conformsToAnyOf:unsupportedUTIs] || [fileUTI conformsToAnyOf:unsupportedUTIs]) + { + return NO; + } + + return YES; +} + +#pragma mark - MXKAttachmentsViewControllerDelegate + +- (BOOL)attachmentsViewController:(MXKAttachmentsViewController*)attachmentsViewController paginateAttachmentBefore:(NSString*)eventId +{ + [self triggerAttachmentBackPagination:eventId]; + + return [self.roomDataSource.timeline canPaginate:MXTimelineDirectionBackwards]; +} + +- (void)displayedNewAttachmentWithEventId:(NSString *)eventId { + self.closedAttachmentEventId = eventId; +} + +#pragma mark - MXKRoomActivitiesViewDelegate + +- (void)didChangeHeight:(MXKRoomActivitiesView *)roomActivitiesView oldHeight:(CGFloat)oldHeight newHeight:(CGFloat)newHeight +{ + // We will scroll to bottom if the bottom of the table is currently visible + BOOL shouldScrollToBottom = [self isBubblesTableScrollViewAtTheBottom]; + + // Apply height change to constraints + _roomActivitiesContainerHeightConstraint.constant = newHeight; + _bubblesTableViewBottomConstraint.constant += newHeight - oldHeight; + + // Force to render the view + [self.view layoutIfNeeded]; + + if (shouldScrollToBottom) + { + [self scrollBubblesTableViewToBottomAnimated:YES]; + } +} + +#pragma mark - MXKPreviewViewControllerDelegate + +- (void)previewViewControllerDidEndPreview:(MXKPreviewViewController *)controller +{ + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } +} + +#pragma mark - UIDocumentInteractionControllerDelegate + +- (UIViewController *)documentInteractionControllerViewControllerForPreview: (UIDocumentInteractionController *) controller +{ + return self; +} + +// Preview presented/dismissed on document. Use to set up any HI underneath. +- (void)documentInteractionControllerWillBeginPreview:(UIDocumentInteractionController *)controller +{ + documentInteractionController = controller; +} + +- (void)documentInteractionControllerDidEndPreview:(UIDocumentInteractionController *)controller +{ + documentInteractionController = nil; + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } +} + +- (void)documentInteractionControllerDidDismissOptionsMenu:(UIDocumentInteractionController *)controller +{ + documentInteractionController = nil; + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } +} + +- (void)documentInteractionControllerDidDismissOpenInMenu:(UIDocumentInteractionController *)controller +{ + documentInteractionController = nil; + if (currentSharedAttachment) + { + [currentSharedAttachment onShareEnded]; + currentSharedAttachment = nil; + } +} + +#pragma mark - resync management + +- (void)onSyncNotification +{ + latestServerSync = [NSDate date]; + [self removeReconnectingView]; +} + +- (BOOL)canReconnect +{ + // avoid restarting connection if some data has been received within 1 second (1000 : latestServerSync is null) + NSTimeInterval interval = latestServerSync ? [[NSDate date] timeIntervalSinceDate:latestServerSync] : 1000; + return (interval > 1) && [self.mainSession reconnect]; +} + +- (void)addReconnectingView +{ + if (!reconnectingView) + { + UIActivityIndicatorView* spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray]; + [spinner sizeToFit]; + spinner.hidesWhenStopped = NO; + spinner.backgroundColor = [UIColor clearColor]; + [spinner startAnimating]; + + // no need to manage constraints here + // IOS defines them. + // since IOS7 the spinner is centered so need to create a background and add it. + _bubblesTableView.tableFooterView = reconnectingView = spinner; + } +} + +- (void)removeReconnectingView +{ + if (reconnectingView && !restartConnection) + { + _bubblesTableView.tableFooterView = reconnectingView = nil; + } +} + +/** + Detect if the current connection must be restarted. + The spinner is displayed until the overscroll ends (and scrollViewDidEndDecelerating is called). + */ +- (void)detectPullToKick:(UIScrollView *)scrollView +{ + if (roomDataSource.isLive && !reconnectingView) + { + // detect if the user scrolls over the tableview bottom + restartConnection = ( + ((scrollView.contentSize.height < scrollView.frame.size.height) && (scrollView.contentOffset.y > 128)) + || + ((scrollView.contentSize.height > scrollView.frame.size.height) && (scrollView.contentOffset.y + scrollView.frame.size.height) > (scrollView.contentSize.height + 128))); + + if (restartConnection) + { + // wait that list decelerate to display / hide it + [self addReconnectingView]; + } + } +} + + +/** + Restarts the current connection if it is required. + The 0.3s delay is added to avoid flickering if the connection does not require to be restarted. + */ +- (void)managePullToKick:(UIScrollView *)scrollView +{ + // the current connection must be restarted + if (roomDataSource.isLive && restartConnection) + { + // display at least 0.3s the spinner to show to the user that something is pending + // else the UI is flickering + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.3 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ + self->restartConnection = NO; + + if (![self canReconnect]) + { + // if the event stream has not been restarted + // hide the spinner + [self removeReconnectingView]; + } + // else wait that onSyncNotification is called. + }); + } +} + +#pragma mark - MXKSourceAttachmentAnimatorDelegate + +- (UIImageView *)originalImageView { + if ([self.openedAttachmentEventId isEqualToString:self.closedAttachmentEventId]) { + return self.openedAttachmentImageView; + } + return nil; +} + + +- (CGRect)convertedFrameForOriginalImageView { + if ([self.openedAttachmentEventId isEqualToString:self.closedAttachmentEventId]) { + return [self.openedAttachmentImageView convertRect:self.openedAttachmentImageView.frame toView:nil]; + } + //default frame which will be used if the user scrolls to other attachments in MXKAttachmentsViewController + return CGRectMake(CGRectGetWidth(self.view.frame)/2, 0.0, 0.0, 0.0); +} + +#pragma mark - Encryption key sharing + +- (void)shareEncryptionKeys +{ + __block NSString *roomId = roomDataSource.roomId; + [roomDataSource.mxSession.crypto ensureEncryptionInRoom:roomId success:^{ + MXLogDebug(@"[MXKRoomViewController] Key shared for room: %@", roomId); + } failure:^(NSError *error) { + MXLogDebug(@"[MXKRoomViewController] Failed to share key for room %@: %@", roomId, error); + }]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.xib new file mode 100644 index 000000000..1ad5d0650 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKRoomViewController.xib @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.h new file mode 100644 index 000000000..b07abb50d --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.h @@ -0,0 +1,75 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#import + +#import "MXKViewController.h" + +#import "MXKSearchDataSource.h" + +/** + This view controller handles search server side. Only one matrix session is handled by this view controller. + + According to its dataSource configuration the search can be done all user's rooms or a set of rooms. + */ +@interface MXKSearchViewController : MXKViewController + +@property (weak, nonatomic) IBOutlet UISearchBar *searchSearchBar; +@property (weak, nonatomic) IBOutlet UITableView *searchTableView; +@property (weak, nonatomic) IBOutlet UILabel *noResultsLabel; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *searchSearchBarTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *searchSearchBarHeightConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *searchTableViewBottomConstraint; + +/** + The current data source associated to the view controller. + */ +@property (nonatomic, readonly) MXKSearchDataSource *dataSource; + +/** + Enable the search option by adding a navigation item in the navigation bar (YES by default). + Set NO this property to disable this option and hide the related bar button. + */ +@property (nonatomic) BOOL enableBarButtonSearch; + +/** + If YES, the table view will scroll at the bottom on the next data source refresh. + It comes back to NO after each refresh. + */ +@property (nonatomic) BOOL shouldScrollToBottomOnRefresh; + + +#pragma mark - Class methods + +/** + Creates and returns a new `MXKSearchViewController` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKSearchViewController` object if successful, `nil` otherwise. + */ ++ (instancetype)searchViewController; + +/** + Display the search results described in the provided data source. + + Note: The provided data source replaces the current data source if any. The current + data source is released. + + @param searchDataSource the data source providing the search results. + */ +- (void)displaySearch:(MXKSearchDataSource*)searchDataSource; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.m new file mode 100644 index 000000000..7ac637b33 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.m @@ -0,0 +1,423 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSearchViewController.h" + +#import "MXKSearchTableViewCell.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@interface MXKSearchViewController () +{ + /** + Optional bar buttons + */ + UIBarButtonItem *searchBarButton; + + /** + Search handling + */ + BOOL ignoreSearchRequest; +} +@end + +@implementation MXKSearchViewController +@synthesize dataSource, shouldScrollToBottomOnRefresh; + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKSearchViewController class]) + bundle:[NSBundle bundleForClass:[MXKSearchViewController class]]]; +} + ++ (instancetype)searchViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MXKSearchViewController class]) + bundle:[NSBundle bundleForClass:[MXKSearchViewController class]]]; +} + +#pragma mark - + +- (void)finalizeInit +{ + [super finalizeInit]; + + _enableBarButtonSearch = YES; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Check whether the view controller has been pushed via storyboard + if (!_searchTableView) + { + // Instantiate view controller objects + [[[self class] nib] instantiateWithOwner:self options:nil]; + } + + // Adjust Top and Bottom constraints to take into account potential navBar and tabBar. + [NSLayoutConstraint deactivateConstraints:@[_searchSearchBarTopConstraint, _searchTableViewBottomConstraint]]; + + _searchSearchBarTopConstraint = [NSLayoutConstraint constraintWithItem:self.topLayoutGuide + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self.searchSearchBar + attribute:NSLayoutAttributeTop + multiplier:1.0f + constant:0.0f]; + + _searchTableViewBottomConstraint = [NSLayoutConstraint constraintWithItem:self.bottomLayoutGuide + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.searchTableView + attribute:NSLayoutAttributeBottom + multiplier:1.0f + constant:0.0f]; + + [NSLayoutConstraint activateConstraints:@[_searchSearchBarTopConstraint, _searchTableViewBottomConstraint]]; + + // Hide search bar by default + self.searchSearchBar.hidden = YES; + self.searchSearchBarHeightConstraint.constant = 0; + [self.view setNeedsUpdateConstraints]; + + self.noResultsLabel.text = [MatrixKitL10n searchNoResults]; + self.noResultsLabel.hidden = YES; + + searchBarButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemSearch target:self action:@selector(showSearchBar:)]; + + // Apply search option in navigation bar + self.enableBarButtonSearch = _enableBarButtonSearch; + + // Finalize table view configuration + _searchTableView.delegate = self; + _searchTableView.dataSource = dataSource; // Note: dataSource may be nil here + + // Set up classes to use for cells + [self.searchTableView registerNib:MXKSearchTableViewCell.nib forCellReuseIdentifier:MXKSearchTableViewCell.defaultReuseIdentifier]; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + // Restore search mechanism (if enabled) + ignoreSearchRequest = NO; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + // The user may still press search button whereas the view disappears + ignoreSearchRequest = YES; +} + + +#pragma mark - Override MXKViewController + +- (void)onKeyboardShowAnimationComplete +{ + // Report the keyboard view in order to track keyboard frame changes + self.keyboardView = _searchSearchBar.inputAccessoryView.superview; +} + +- (void)setKeyboardHeight:(CGFloat)keyboardHeight +{ + // Deduce the bottom constraint for the table view (Don't forget the potential tabBar) + CGFloat tableViewBottomConst = keyboardHeight - self.bottomLayoutGuide.length; + // Check whether the keyboard is over the tabBar + if (tableViewBottomConst < 0) + { + tableViewBottomConst = 0; + } + + // Update constraints + _searchTableViewBottomConstraint.constant = tableViewBottomConst; + + // Force layout immediately to take into account new constraint + [self.view layoutIfNeeded]; +} + +- (void)destroy +{ + _searchTableView.dataSource = nil; + _searchTableView.delegate = nil; + _searchTableView = nil; + + dataSource.delegate = nil; + [dataSource destroy]; + dataSource = nil; + + [super destroy]; +} + +#pragma mark - + +- (void)displaySearch:(MXKSearchDataSource*)searchDataSource +{ + // Cancel registration on existing dataSource if any + if (dataSource) + { + dataSource.delegate = nil; + + // Remove associated matrix sessions + [self removeMatrixSession:dataSource.mxSession]; + + [dataSource destroy]; + } + + dataSource = searchDataSource; + dataSource.delegate = self; + + // Report the related matrix sessions at view controller level to update UI according to sessions state + [self addMatrixSession:searchDataSource.mxSession]; + + if (_searchTableView) + { + // Set up table data source + _searchTableView.dataSource = dataSource; + } +} + + +#pragma mark - UIBarButton handling + +- (void)setEnableBarButtonSearch:(BOOL)enableBarButtonSearch +{ + _enableBarButtonSearch = enableBarButtonSearch; + [self refreshUIBarButtons]; +} + +- (void)refreshUIBarButtons +{ + if (_enableBarButtonSearch) + { + self.navigationItem.rightBarButtonItems = @[searchBarButton]; + } + else + { + self.navigationItem.rightBarButtonItems = nil; + } +} + +#pragma mark - MXKDataSourceDelegate + +- (Class)cellViewClassForCellData:(MXKCellData*)cellData +{ + return MXKSearchTableViewCell.class; +} + +- (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData +{ + return MXKSearchTableViewCell.defaultReuseIdentifier; +} + +- (void)dataSource:(MXKDataSource *)dataSource didCellChange:(id)changes +{ + __block CGPoint tableViewOffset; + + if (!shouldScrollToBottomOnRefresh) + { + // Store current tableview scrolling point to restore it after [UITableView reloadData] + // This avoids unexpected scrolling for the user + tableViewOffset = _searchTableView.contentOffset; + } + + [_searchTableView reloadData]; + + if (shouldScrollToBottomOnRefresh) + { + [self scrollToBottomAnimated:NO]; + shouldScrollToBottomOnRefresh = NO; + } + else + { + // Restore the user scrolling point by computing the offset introduced by new cells + // New cells are always introduced at the top of the table + NSIndexSet *insertedIndexes = (NSIndexSet*)changes; + + // Get each new cell height + [insertedIndexes enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL * _Nonnull stop) { + + MXKCellData* cellData = [self.dataSource cellDataAtIndex:idx]; + Class class = [self cellViewClassForCellData:cellData]; + + tableViewOffset.y += [class heightForCellData:cellData withMaximumWidth:self->_searchTableView.frame.size.width]; + + }]; + + [_searchTableView setContentOffset:tableViewOffset animated:NO]; + } + + self.title = [NSString stringWithFormat:@"%@ (%tu)", self.dataSource.searchText, self.dataSource.serverCount]; +} + +- (void)dataSource:(MXKDataSource*)dataSource2 didStateChange:(MXKDataSourceState)state +{ + // MXKSearchDataSource comes back to the `MXKDataSourceStatePreparing` when searching + if (state == MXKDataSourceStatePreparing) + { + _noResultsLabel.hidden = YES; + [self startActivityIndicator]; + } + else + { + [self stopActivityIndicator]; + + // Display "No Results" if a search is active with an empty result + if (dataSource.searchText.length && ![dataSource tableView:_searchTableView numberOfRowsInSection:0]) + { + _noResultsLabel.hidden = NO; + _searchTableView.hidden = YES; + } + else + { + _noResultsLabel.hidden = YES; + _searchTableView.hidden = NO; + } + } +} + +#pragma mark - UITableView delegate + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + MXKCellData *cellData = [dataSource cellDataAtIndex:indexPath.row]; + + Class class = [self cellViewClassForCellData:cellData]; + return [class heightForCellData:cellData withMaximumWidth:tableView.frame.size.width]; +} + + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + // Must be implemented at app level +} + +- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath*)indexPath +{ + // Release here resources, and restore reusable cells + if ([cell respondsToSelector:@selector(didEndDisplay)]) + { + [(id)cell didEndDisplay]; + } +} + +- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset +{ + // Detect vertical bounce at the top of the tableview to trigger pagination + if (scrollView == _searchTableView) + { + // paginate ? + if (scrollView.contentOffset.y < -64) + { + [self triggerBackPagination]; + } + } +} + +#pragma mark - UISearchBarDelegate + +- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar +{ + // "Done" key has been pressed + [searchBar resignFirstResponder]; + + // Apply filter + if (searchBar.text.length) + { + shouldScrollToBottomOnRefresh = YES; + [dataSource searchMessages:searchBar.text force:NO]; + } +} + +- (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar +{ + // Leave search + [searchBar resignFirstResponder]; + + self.searchSearchBar.hidden = YES; + self.searchSearchBarHeightConstraint.constant = 0; + [self.view setNeedsUpdateConstraints]; + + self.searchSearchBar.text = nil; +} + +#pragma mark - Actions + +- (void)showSearchBar:(id)sender +{ + // The user may have pressed search button whereas the view controller was disappearing + if (ignoreSearchRequest) + { + return; + } + + if (self.searchSearchBar.isHidden) + { + self.searchSearchBar.hidden = NO; + self.searchSearchBarHeightConstraint.constant = 44; + [self.view setNeedsUpdateConstraints]; + + [self.searchSearchBar becomeFirstResponder]; + } + else + { + [self searchBarCancelButtonClicked: self.searchSearchBar]; + } +} + +#pragma mark - Private methods + +- (void)triggerBackPagination +{ + // Paginate only if possible + if (NO == dataSource.canPaginate) + { + return; + } + + [dataSource paginateBack]; +} + +- (void)scrollToBottomAnimated:(BOOL)animated +{ + if (_searchTableView.contentSize.height) + { + CGFloat visibleHeight = _searchTableView.frame.size.height - _searchTableView.adjustedContentInset.top - _searchTableView.adjustedContentInset.bottom; + if (visibleHeight < _searchTableView.contentSize.height) + { + CGFloat wantedOffsetY = _searchTableView.contentSize.height - visibleHeight - _searchTableView.adjustedContentInset.top; + CGFloat currentOffsetY = _searchTableView.contentOffset.y; + if (wantedOffsetY != currentOffsetY) + { + [_searchTableView setContentOffset:CGPointMake(0, wantedOffsetY) animated:animated]; + } + } + else + { + _searchTableView.contentOffset = CGPointMake(0, - _searchTableView.adjustedContentInset.top); + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.xib b/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.xib new file mode 100644 index 000000000..77a7c29f6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKSearchViewController.xib @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKTableViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKTableViewController.h new file mode 100644 index 000000000..76f196626 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKTableViewController.h @@ -0,0 +1,27 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKViewControllerHandling.h" + +/** + MXKViewController extends UITableViewController to handle requirements for + any matrixKit table view controllers (see MXKViewControllerHandling protocol). + */ + +@interface MXKTableViewController : UITableViewController + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKTableViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKTableViewController.m new file mode 100644 index 000000000..383672543 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKTableViewController.m @@ -0,0 +1,574 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewController.h" + +#import "UIViewController+MatrixKit.h" +#import "MXSession+MatrixKit.h" + +@interface MXKTableViewController () +{ + /** + Array of `MXSession` instances. + */ + NSMutableArray *mxSessionArray; + + /** + Keep reference on the pushed view controllers to release them correctly + */ + NSMutableArray *childViewControllers; +} +@end + +@implementation MXKTableViewController +@synthesize defaultBarTintColor, enableBarTintColorStatusChange; +@synthesize barTitleColor; +@synthesize mainSession; +@synthesize activityIndicator, rageShakeManager; +@synthesize childViewControllers; + +#pragma mark - + +- (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil +{ + self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; + if (self) + { + [self finalizeInit]; + } + + return self; +} + +- (nullable instancetype)initWithCoder:(NSCoder *)aDecoder +{ + self = [super initWithCoder:aDecoder]; + if (self) + { + [self finalizeInit]; + } + + return self; +} + +- (void)finalizeInit +{ + // Set default properties values + defaultBarTintColor = nil; + barTitleColor = nil; + enableBarTintColorStatusChange = YES; + rageShakeManager = nil; + + mxSessionArray = [NSMutableArray array]; + childViewControllers = [NSMutableArray array]; +} + +#pragma mark - + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + // Add default activity indicator + activityIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhite]; + activityIndicator.backgroundColor = [UIColor colorWithRed:0.8 green:0.8 blue:0.8 alpha:1.0]; + activityIndicator.autoresizingMask = UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin | UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin; + + CGRect frame = activityIndicator.frame; + frame.size.width += 30; + frame.size.height += 30; + activityIndicator.bounds = frame; + [activityIndicator.layer setCornerRadius:5]; + + activityIndicator.center = self.view.center; + [self.view addSubview:activityIndicator]; +} + +- (void)dealloc +{ + if (activityIndicator) + { + [activityIndicator removeFromSuperview]; + activityIndicator = nil; + } +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + if (self.rageShakeManager) + { + [self.rageShakeManager cancel:self]; + } + + // Update UI according to mxSession state, and add observer (if need) + if (mxSessionArray.count) + { + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMatrixSessionStateDidChange:) name:kMXSessionStateDidChangeNotification object:nil]; + } + [self onMatrixSessionChange]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionStateDidChangeNotification object:nil]; + + [activityIndicator stopAnimating]; + + if (self.rageShakeManager) + { + [self.rageShakeManager cancel:self]; + } +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + + MXLogDebug(@"[MXKTableViewController] %@ viewDidAppear", self.class); + + // Release properly pushed and/or presented view controller + if (childViewControllers.count) + { + for (id viewController in childViewControllers) + { + if ([viewController isKindOfClass:[UINavigationController class]]) + { + UINavigationController *navigationController = (UINavigationController*)viewController; + for (id subViewController in navigationController.viewControllers) + { + if ([subViewController respondsToSelector:@selector(destroy)]) + { + [subViewController destroy]; + } + } + } + else if ([viewController respondsToSelector:@selector(destroy)]) + { + [viewController destroy]; + } + } + + [childViewControllers removeAllObjects]; + } +} + +- (void)viewDidDisappear:(BOOL)animated +{ + [super viewDidDisappear:animated]; + + MXLogDebug(@"[MXKTableViewController] %@ viewDidDisappear", self.class); +} + +- (void)setEnableBarTintColorStatusChange:(BOOL)enable +{ + if (enableBarTintColorStatusChange != enable) + { + enableBarTintColorStatusChange = enable; + + [self onMatrixSessionChange]; + } +} + +- (void)setDefaultBarTintColor:(UIColor *)barTintColor +{ + defaultBarTintColor = barTintColor; + + if (enableBarTintColorStatusChange) + { + // Force update by taking into account the matrix session state. + [self onMatrixSessionChange]; + } + else + { + // Set default tintColor + self.navigationController.navigationBar.barTintColor = defaultBarTintColor; + self.mxk_mainNavigationController.navigationBar.barTintColor = defaultBarTintColor; + } +} + +- (void)setBarTitleColor:(UIColor *)titleColor +{ + barTitleColor = titleColor; + + // Retrieve the main navigation controller if the current view controller is embedded inside a split view controller. + UINavigationController *mainNavigationController = self.mxk_mainNavigationController; + + // Set navigation bar title color + NSDictionary *titleTextAttributes = self.navigationController.navigationBar.titleTextAttributes; + if (titleTextAttributes) + { + NSMutableDictionary *textAttributes = [NSMutableDictionary dictionaryWithDictionary:titleTextAttributes]; + textAttributes[NSForegroundColorAttributeName] = barTitleColor; + self.navigationController.navigationBar.titleTextAttributes = textAttributes; + } + else if (barTitleColor) + { + self.navigationController.navigationBar.titleTextAttributes = @{NSForegroundColorAttributeName: barTitleColor}; + } + + if (mainNavigationController) + { + titleTextAttributes = mainNavigationController.navigationBar.titleTextAttributes; + if (titleTextAttributes) + { + NSMutableDictionary *textAttributes = [NSMutableDictionary dictionaryWithDictionary:titleTextAttributes]; + textAttributes[NSForegroundColorAttributeName] = barTitleColor; + mainNavigationController.navigationBar.titleTextAttributes = textAttributes; + } + else if (barTitleColor) + { + mainNavigationController.navigationBar.titleTextAttributes = @{NSForegroundColorAttributeName: barTitleColor}; + } + } +} + +- (void)setView:(UIView *)view +{ + [super setView:view]; + + // Keep the activity indicator (if any) + if (view && activityIndicator) + { + [self.view addSubview:activityIndicator]; + } +} + +- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender +{ + // Keep ref on destinationViewController + [childViewControllers addObject:segue.destinationViewController]; +} + +- (void)presentViewController:(UIViewController *)viewControllerToPresent animated:(BOOL)flag completion:(void (^)(void))completion +{ + // Keep ref on presented view controller + [childViewControllers addObject:viewControllerToPresent]; + + [super presentViewController:viewControllerToPresent animated:flag completion:completion]; +} + +#pragma mark - + +- (void)addMatrixSession:(MXSession*)mxSession +{ + if (!mxSession || mxSession.state == MXSessionStateClosed) + { + return; + } + + if (!mxSessionArray.count) + { + [mxSessionArray addObject:mxSession]; + + // Add matrix sessions observer on first added session + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMatrixSessionStateDidChange:) name:kMXSessionStateDidChangeNotification object:nil]; + } + else if ([mxSessionArray indexOfObject:mxSession] == NSNotFound) + { + [mxSessionArray addObject:mxSession]; + } + + // Force update + [self onMatrixSessionChange]; +} + +- (void)removeMatrixSession:(MXSession*)mxSession +{ + if (!mxSession) + { + return; + } + + NSUInteger index = [mxSessionArray indexOfObject:mxSession]; + if (index != NSNotFound) + { + [mxSessionArray removeObjectAtIndex:index]; + + if (!mxSessionArray.count) + { + // Remove matrix sessions observer + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionStateDidChangeNotification object:nil]; + } + } + + // Force update + [self onMatrixSessionChange]; +} + +- (NSArray*)mxSessions +{ + return [NSArray arrayWithArray:mxSessionArray]; +} + +- (MXSession*)mainSession +{ + // We consider the first added session as the main one. + if (mxSessionArray.count) + { + return [mxSessionArray firstObject]; + } + return nil; +} + +#pragma mark - + +- (void)withdrawViewControllerAnimated:(BOOL)animated completion:(void (^)(void))completion +{ + // Check whether the view controller is embedded inside a navigation controller. + if (self.navigationController) + { + [self popViewController:self navigationController:self.navigationController animated:animated completion:completion]; + } + else + { + // Suppose here the view controller has been presented modally. We dismiss it + [self dismissViewControllerAnimated:animated completion:completion]; + } +} + +- (void)popViewController:(UIViewController*)viewController navigationController:(UINavigationController*)navigationController animated:(BOOL)animated completion:(void (^)(void))completion +{ + // We pop the view controller (except if it is the root view controller). + NSUInteger index = [navigationController.viewControllers indexOfObject:viewController]; + if (index != NSNotFound) + { + if (index > 0) + { + UIViewController *previousViewController = [navigationController.viewControllers objectAtIndex:(index - 1)]; + [navigationController popToViewController:previousViewController animated:animated]; + + if (completion) + { + completion(); + } + } + else + { + // Check whether the navigation controller is embedded inside a navigation controller, to pop it. + if (navigationController.navigationController) + { + [self popViewController:navigationController navigationController:navigationController.navigationController animated:animated completion:completion]; + } + else + { + // Remove the root view controller + navigationController.viewControllers = @[]; + // Suppose here the navigation controller has been presented modally. We dismiss it + [navigationController dismissViewControllerAnimated:animated completion:completion]; + } + } + } +} + +- (void)destroy +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + mxSessionArray = nil; + childViewControllers = nil; +} + +#pragma mark - Sessions handling + +- (void)onMatrixSessionStateDidChange:(NSNotification *)notif +{ + MXSession *mxSession = notif.object; + + NSUInteger index = [mxSessionArray indexOfObject:mxSession]; + if (index != NSNotFound) + { + if (mxSession.state == MXSessionStateClosed) + { + // Call here the dedicated method which may be overridden + [self removeMatrixSession:mxSession]; + } + else + { + [self onMatrixSessionChange]; + } + } +} + +- (void)onMatrixSessionChange +{ + // This method is called to refresh view controller appearance on session state change, + // It is called when the view will appear to update session array by removing closed sessions. + // Indeed 'kMXSessionStateDidChangeNotification' are observed only when the view controller is visible. + + // Retrieve the main navigation controller if the current view controller is embedded inside a split view controller. + UINavigationController *mainNavigationController = self.mxk_mainNavigationController; + + if (mxSessionArray.count) + { + // Check each session state + UIColor *barTintColor = defaultBarTintColor; + BOOL allHomeserverNotReachable = YES; + BOOL isActivityInProgress = NO; + for (NSUInteger index = 0; index < mxSessionArray.count;) + { + MXSession *mxSession = mxSessionArray[index]; + + // Remove here closed sessions + if (mxSession.state == MXSessionStateClosed) + { + // Call here the dedicated method which may be overridden. + // This method will call again [onMatrixSessionChange] when session is removed. + [self removeMatrixSession:mxSession]; + return; + } + else + { + if (mxSession.state == MXSessionStateHomeserverNotReachable) + { + barTintColor = [UIColor orangeColor]; + } + else + { + allHomeserverNotReachable = NO; + isActivityInProgress = mxSession.shouldShowActivityIndicator; + } + + index ++; + } + } + + // Check whether the navigation bar color depends on homeserver reachability. + if (enableBarTintColorStatusChange) + { + // The navigation bar tintColor reflects the matrix homeserver reachability status. + if (allHomeserverNotReachable) + { + self.navigationController.navigationBar.barTintColor = [UIColor redColor]; + if (mainNavigationController) + { + mainNavigationController.navigationBar.barTintColor = [UIColor redColor]; + } + } + else + { + self.navigationController.navigationBar.barTintColor = barTintColor; + if (mainNavigationController) + { + mainNavigationController.navigationBar.barTintColor = barTintColor; + } + } + } + + // Run activity indicator if need + if (isActivityInProgress) + { + [self startActivityIndicator]; + } + else + { + [self stopActivityIndicator]; + } + } + else + { + // Hide potential activity indicator + [self stopActivityIndicator]; + + // Check whether the navigation bar color depends on homeserver reachability. + if (enableBarTintColorStatusChange) + { + // Restore default tintColor + self.navigationController.navigationBar.barTintColor = defaultBarTintColor; + if (mainNavigationController) + { + mainNavigationController.navigationBar.barTintColor = defaultBarTintColor; + } + } + } +} + +#pragma mark - Activity indicator + +- (void)startActivityIndicator +{ + if (activityIndicator) + { + // Keep centering the loading wheel + CGPoint center = self.view.center; + center.y += self.tableView.contentOffset.y - self.tableView.adjustedContentInset.top; + activityIndicator.center = center; + [self.view bringSubviewToFront:activityIndicator]; + + [activityIndicator startAnimating]; + + // Show the loading wheel after a delay so that if the caller calls stopActivityIndicator + // in a short future, the loading wheel will not be displayed to the end user. + activityIndicator.alpha = 0; + [UIView animateWithDuration:0.3 delay:0.3 options:UIViewAnimationOptionBeginFromCurrentState animations:^{ + self->activityIndicator.alpha = 1; + } completion:^(BOOL finished) + { + }]; + } +} + +- (void)stopActivityIndicator +{ + // Check whether all conditions are satisfied before stopping loading wheel + BOOL isActivityInProgress = NO; + for (MXSession *mxSession in mxSessionArray) + { + if (mxSession.shouldShowActivityIndicator) + { + isActivityInProgress = YES; + } + } + if (!isActivityInProgress) + { + [activityIndicator stopAnimating]; + } +} + +#pragma mark - Shake handling + +- (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event +{ + if (motion == UIEventSubtypeMotionShake && self.rageShakeManager) + { + [self.rageShakeManager startShaking:self]; + } +} + +- (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent *)event +{ + [self motionEnded:motion withEvent:event]; +} + +- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event +{ + if (self.rageShakeManager) + { + [self.rageShakeManager stopShaking:self]; + } +} + +- (BOOL)canBecomeFirstResponder +{ + return (self.rageShakeManager != nil); +} + + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKViewController.h new file mode 100644 index 000000000..e75d42e11 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKViewController.h @@ -0,0 +1,52 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKViewControllerHandling.h" +#import "MXKActivityHandlingViewController.h" + +/** + MXKViewController extends UIViewController to handle requirements for + any matrixKit view controllers (see MXKViewControllerHandling protocol). + + This class provides some methods to ease keyboard handling. + */ + +@interface MXKViewController : MXKActivityHandlingViewController + + +#pragma mark - Keyboard handling + +/** + Call when keyboard display animation is complete. + + Override this method to set the actual keyboard view in 'keyboardView' property. + The 'MXKViewController' instance will then observe the keyboard frame changes, and update 'keyboardHeight' property. + */ +- (void)onKeyboardShowAnimationComplete; + +/** + The current keyboard view (This field is nil when keyboard is dismissed). + This property should be set when keyboard display animation is complete to track keyboard frame changes. + */ +@property (nonatomic) UIView *keyboardView; + +/** + The current keyboard height (This field is 0 when keyboard is dismissed). + */ +@property CGFloat keyboardHeight; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKViewController.m new file mode 100644 index 000000000..a4e559b4c --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKViewController.m @@ -0,0 +1,657 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKViewController.h" + +#import "UIViewController+MatrixKit.h" +#import "MXSession+MatrixKit.h" + +const CGFloat MXKViewControllerMaxExternalKeyboardHeight = 80; + +@interface MXKViewController () +{ + /** + Array of `MXSession` instances. + */ + NSMutableArray *mxSessionArray; + + /** + Keep reference on the pushed view controllers to release them correctly + */ + NSMutableArray *childViewControllers; +} +@end + +@implementation MXKViewController +@synthesize defaultBarTintColor, enableBarTintColorStatusChange; +@synthesize barTitleColor; +@synthesize mainSession; +@synthesize rageShakeManager; +@synthesize childViewControllers; + +#pragma mark - + +- (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil +{ + self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; + if (self) + { + [self finalizeInit]; + } + + return self; +} + +- (nullable instancetype)initWithCoder:(NSCoder *)aDecoder +{ + self = [super initWithCoder:aDecoder]; + if (self) + { + [self finalizeInit]; + } + + return self; +} + +- (void)finalizeInit +{ + // Set default properties values + defaultBarTintColor = nil; + barTitleColor = nil; + enableBarTintColorStatusChange = YES; + rageShakeManager = nil; + + mxSessionArray = [NSMutableArray array]; + childViewControllers = [NSMutableArray array]; +} + +#pragma mark - + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + if (self.rageShakeManager) + { + [self.rageShakeManager cancel:self]; + } + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onKeyboardWillShow:) name:UIKeyboardWillShowNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onKeyboardWillHide:) name:UIKeyboardWillHideNotification object:nil]; + + // Update UI according to mxSession state, and add observer (if need) + if (mxSessionArray.count) + { + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMatrixSessionStateDidChange:) name:kMXSessionStateDidChangeNotification object:nil]; + } + [self onMatrixSessionChange]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardWillHideNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardWillShowNotification object:nil]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionStateDidChangeNotification object:nil]; + + [self.activityIndicator stopAnimating]; + + if (self.rageShakeManager) + { + [self.rageShakeManager cancel:self]; + } + + // Remove keyboard view (if any) + self.keyboardView = nil; + self.keyboardHeight = 0; +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + + MXLogDebug(@"[MXKViewController] %@ viewDidAppear", self.class); + + // Release properly pushed and/or presented view controller + if (childViewControllers.count) + { + for (id viewController in childViewControllers) + { + if ([viewController isKindOfClass:[UINavigationController class]]) + { + UINavigationController *navigationController = (UINavigationController*)viewController; + for (id subViewController in navigationController.viewControllers) + { + if ([subViewController respondsToSelector:@selector(destroy)]) + { + [subViewController destroy]; + } + } + } + else if ([viewController respondsToSelector:@selector(destroy)]) + { + [viewController destroy]; + } + } + + [childViewControllers removeAllObjects]; + } +} + +- (void)viewDidDisappear:(BOOL)animated +{ + [super viewDidDisappear:animated]; + + MXLogDebug(@"[MXKViewController] %@ viewDidDisappear", self.class); +} + +- (void)setEnableBarTintColorStatusChange:(BOOL)enable +{ + if (enableBarTintColorStatusChange != enable) + { + enableBarTintColorStatusChange = enable; + + [self onMatrixSessionChange]; + } +} + +- (void)setDefaultBarTintColor:(UIColor *)barTintColor +{ + defaultBarTintColor = barTintColor; + + if (enableBarTintColorStatusChange) + { + // Force update by taking into account the matrix session state. + [self onMatrixSessionChange]; + } + else + { + // Set default tintColor + self.navigationController.navigationBar.barTintColor = defaultBarTintColor; + self.mxk_mainNavigationController.navigationBar.barTintColor = defaultBarTintColor; + } +} + +- (void)setBarTitleColor:(UIColor *)titleColor +{ + barTitleColor = titleColor; + + // Retrieve the main navigation controller if the current view controller is embedded inside a split view controller. + UINavigationController *mainNavigationController = self.mxk_mainNavigationController; + + // Set navigation bar title color + NSDictionary *titleTextAttributes = self.navigationController.navigationBar.titleTextAttributes; + if (titleTextAttributes) + { + NSMutableDictionary *textAttributes = [NSMutableDictionary dictionaryWithDictionary:titleTextAttributes]; + textAttributes[NSForegroundColorAttributeName] = barTitleColor; + self.navigationController.navigationBar.titleTextAttributes = textAttributes; + } + else if (barTitleColor) + { + self.navigationController.navigationBar.titleTextAttributes = @{NSForegroundColorAttributeName: barTitleColor}; + } + + if (mainNavigationController) + { + titleTextAttributes = mainNavigationController.navigationBar.titleTextAttributes; + if (titleTextAttributes) + { + NSMutableDictionary *textAttributes = [NSMutableDictionary dictionaryWithDictionary:titleTextAttributes]; + textAttributes[NSForegroundColorAttributeName] = barTitleColor; + mainNavigationController.navigationBar.titleTextAttributes = textAttributes; + } + else if (barTitleColor) + { + mainNavigationController.navigationBar.titleTextAttributes = @{NSForegroundColorAttributeName: barTitleColor}; + } + } +} + +- (void)setView:(UIView *)view +{ + [super setView:view]; + + // Keep the activity indicator (if any) + if (self.activityIndicator) + { + self.activityIndicator.center = self.view.center; + [self.view addSubview:self.activityIndicator]; + } +} + +- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender +{ + // Keep ref on destinationViewController + [childViewControllers addObject:segue.destinationViewController]; +} + +- (void)presentViewController:(UIViewController *)viewControllerToPresent animated:(BOOL)flag completion:(void (^)(void))completion +{ + // Keep ref on presented view controller + [childViewControllers addObject:viewControllerToPresent]; + + [super presentViewController:viewControllerToPresent animated:flag completion:completion]; +} + +#pragma mark - + +- (void)addMatrixSession:(MXSession*)mxSession +{ + if (!mxSession || mxSession.state == MXSessionStateClosed) + { + return; + } + + if (!mxSessionArray.count) + { + [mxSessionArray addObject:mxSession]; + + // Add matrix sessions observer on first added session + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMatrixSessionStateDidChange:) name:kMXSessionStateDidChangeNotification object:nil]; + } + else if ([mxSessionArray indexOfObject:mxSession] == NSNotFound) + { + [mxSessionArray addObject:mxSession]; + } + + // Force update + [self onMatrixSessionChange]; +} + +- (void)removeMatrixSession:(MXSession*)mxSession +{ + if (!mxSession) + { + return; + } + + NSUInteger index = [mxSessionArray indexOfObject:mxSession]; + if (index != NSNotFound) + { + [mxSessionArray removeObjectAtIndex:index]; + + if (!mxSessionArray.count) + { + // Remove matrix sessions observer + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionStateDidChangeNotification object:nil]; + } + } + + // Force update + [self onMatrixSessionChange]; +} + +- (NSArray*)mxSessions +{ + return [NSArray arrayWithArray:mxSessionArray]; +} + +- (MXSession*)mainSession +{ + // We consider the first added session as the main one. + if (mxSessionArray.count) + { + return [mxSessionArray firstObject]; + } + return nil; +} + +#pragma mark - + +- (void)withdrawViewControllerAnimated:(BOOL)animated completion:(void (^)(void))completion +{ + // Check whether the view controller is embedded inside a navigation controller. + if (self.navigationController) + { + [self popViewController:self navigationController:self.navigationController animated:animated completion:completion]; + } + else + { + // Suppose here the view controller has been presented modally. We dismiss it + [self dismissViewControllerAnimated:animated completion:completion]; + } +} + +- (void)popViewController:(UIViewController*)viewController navigationController:(UINavigationController*)navigationController animated:(BOOL)animated completion:(void (^)(void))completion +{ + // We pop the view controller (except if it is the root view controller). + NSUInteger index = [navigationController.viewControllers indexOfObject:viewController]; + if (index != NSNotFound) + { + if (index > 0) + { + UIViewController *previousViewController = [navigationController.viewControllers objectAtIndex:(index - 1)]; + [navigationController popToViewController:previousViewController animated:animated]; + + if (completion) + { + completion(); + } + } + else + { + // Check whether the navigation controller is embedded inside a navigation controller, to pop it. + if (navigationController.navigationController) + { + [self popViewController:navigationController navigationController:navigationController.navigationController animated:animated completion:completion]; + } + else + { + // Remove the root view controller + navigationController.viewControllers = @[]; + // Suppose here the navigation controller has been presented modally. We dismiss it + [navigationController dismissViewControllerAnimated:animated completion:completion]; + } + } + } +} + +- (void)destroy +{ + // Remove properly keyboard view (remove related key observers) + self.keyboardView = nil; + + // Remove observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + mxSessionArray = nil; + childViewControllers = nil; +} + +#pragma mark - Sessions handling + +- (void)onMatrixSessionStateDidChange:(NSNotification *)notif +{ + MXSession *mxSession = notif.object; + + NSUInteger index = [mxSessionArray indexOfObject:mxSession]; + if (index != NSNotFound) + { + if (mxSession.state == MXSessionStateClosed) + { + // Call here the dedicated method which may be overridden + [self removeMatrixSession:mxSession]; + } + else + { + [self onMatrixSessionChange]; + } + } +} + +- (void)onMatrixSessionChange +{ + // This method is called to refresh view controller appearance on session state change, + // It is called when the view will appear to update session array by removing closed sessions. + // Indeed 'kMXSessionStateDidChangeNotification' are observed only when the view controller is visible. + + // Retrieve the main navigation controller if the current view controller is embedded inside a split view controller. + UINavigationController *mainNavigationController = self.mxk_mainNavigationController; + + if (mxSessionArray.count) + { + // Check each session state. + UIColor *barTintColor = defaultBarTintColor; + BOOL allHomeserverNotReachable = YES; + BOOL isActivityInProgress = NO; + for (NSUInteger index = 0; index < mxSessionArray.count;) + { + MXSession *mxSession = mxSessionArray[index]; + + // Remove here closed sessions + if (mxSession.state == MXSessionStateClosed) + { + // Call here the dedicated method which may be overridden. + // This method will call again [onMatrixSessionChange] when session is removed. + [self removeMatrixSession:mxSession]; + return; + } + else + { + if (mxSession.state == MXSessionStateHomeserverNotReachable) + { + barTintColor = [UIColor orangeColor]; + } + else + { + allHomeserverNotReachable = NO; + isActivityInProgress = mxSession.shouldShowActivityIndicator; + } + + index++; + } + } + + // Check whether the navigation bar color depends on homeserver reachability. + if (enableBarTintColorStatusChange) + { + // The navigation bar tintColor reflects the matrix homeserver reachability status. + if (allHomeserverNotReachable) + { + self.navigationController.navigationBar.barTintColor = [UIColor redColor]; + if (mainNavigationController) + { + mainNavigationController.navigationBar.barTintColor = [UIColor redColor]; + } + } + else + { + self.navigationController.navigationBar.barTintColor = barTintColor; + if (mainNavigationController) + { + mainNavigationController.navigationBar.barTintColor = barTintColor; + } + } + } + + // Run activity indicator if need + if (isActivityInProgress) + { + [self startActivityIndicator]; + } + else + { + [self stopActivityIndicator]; + } + } + else + { + // Hide potential activity indicator + [self stopActivityIndicator]; + + // Check whether the navigation bar color depends on homeserver reachability. + if (enableBarTintColorStatusChange) + { + // Restore default tintColor + self.navigationController.navigationBar.barTintColor = defaultBarTintColor; + if (mainNavigationController) + { + mainNavigationController.navigationBar.barTintColor = defaultBarTintColor; + } + + } + } +} + +#pragma mark - Activity indicator + +- (void)stopActivityIndicator +{ + // Check whether all conditions are satisfied before stopping loading wheel + BOOL isActivityInProgress = NO; + for (MXSession *mxSession in mxSessionArray) + { + if (mxSession.shouldShowActivityIndicator) + { + isActivityInProgress = YES; + } + } + if (!isActivityInProgress) + { + [super stopActivityIndicator]; + } +} + +#pragma mark - Shake handling + +- (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event +{ + if (motion == UIEventSubtypeMotionShake && self.rageShakeManager) + { + [self.rageShakeManager startShaking:self]; + } +} + +- (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent *)event +{ + [self motionEnded:motion withEvent:event]; +} + +- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event +{ + if (self.rageShakeManager) + { + [self.rageShakeManager stopShaking:self]; + } +} + +- (BOOL)canBecomeFirstResponder +{ + return (self.rageShakeManager != nil); +} + +#pragma mark - Keyboard handling + +- (void)onKeyboardShowAnimationComplete +{ + // Do nothing here - `MXKViewController-inherited` instance must override this method. +} + +- (void)setKeyboardView:(UIView *)keyboardView +{ + // Remove previous keyboardView if any + if (_keyboardView) + { + // Restore UIKeyboardWillShowNotification observer + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onKeyboardWillShow:) name:UIKeyboardWillShowNotification object:nil]; + + // Remove keyboard view observers + [_keyboardView removeObserver:self forKeyPath:NSStringFromSelector(@selector(frame))]; + [_keyboardView removeObserver:self forKeyPath:NSStringFromSelector(@selector(center))]; + + _keyboardView = nil; + } + + if (keyboardView) + { + // Add observers to detect keyboard drag down + [keyboardView addObserver:self forKeyPath:NSStringFromSelector(@selector(frame)) options:0 context:nil]; + [keyboardView addObserver:self forKeyPath:NSStringFromSelector(@selector(center)) options:0 context:nil]; + + // Remove UIKeyboardWillShowNotification observer to ignore this notification until keyboard is dismissed. + // Note: UIKeyboardWillShowNotification may be triggered several times before keyboard is dismissed, + // because the keyboard height is updated (switch to a Chinese keyboard for example). + [[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardWillShowNotification object:nil]; + + _keyboardView = keyboardView; + } +} + +- (void)onKeyboardWillShow:(NSNotification *)notif +{ + MXLogDebug(@"[MXKViewController] %@ onKeyboardWillShow", self.class); + + // Get the keyboard size + NSValue *rectVal = notif.userInfo[UIKeyboardFrameEndUserInfoKey]; + CGRect endRect = rectVal.CGRectValue; + + // IOS 8 triggers some unexpected keyboard events + if ((endRect.size.height == 0) || (endRect.size.width == 0)) + { + return; + } + + // Detect if an external keyboard is used + CGRect keyboard = [self.view convertRect:endRect fromView:self.view.window]; + CGFloat height = self.view.frame.size.height; + BOOL hasExternalKeyboard = keyboard.size.height <= MXKViewControllerMaxExternalKeyboardHeight; + + // Get the animation info + NSNumber *curveValue = [[notif userInfo] objectForKey:UIKeyboardAnimationCurveUserInfoKey]; + UIViewAnimationCurve animationCurve = curveValue.intValue; + // The duration is ignored but it is better to define it + double animationDuration = [[[notif userInfo] objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + + // Apply keyboard animation + [UIView animateWithDuration:animationDuration delay:0 options:UIViewAnimationOptionBeginFromCurrentState | (animationCurve << 16) animations:^{ + if (!hasExternalKeyboard) + { + // Set the new virtual keyboard height by checking screen orientation + self.keyboardHeight = (endRect.origin.y == 0) ? endRect.size.width : endRect.size.height; + } + else + { + // The virtual keyboard is not shown on the screen but its toolbar is still displayed. + // Manage the height of this one + self.keyboardHeight = height - keyboard.origin.y; + } + } completion:^(BOOL finished) + { + [self onKeyboardShowAnimationComplete]; + }]; +} + +- (void)onKeyboardWillHide:(NSNotification *)notif +{ + MXLogDebug(@"[MXKViewController] %@ onKeyboardWillHide", self.class); + + // Remove keyboard view + self.keyboardView = nil; + + // Get the animation info + NSNumber *curveValue = [[notif userInfo] objectForKey:UIKeyboardAnimationCurveUserInfoKey]; + UIViewAnimationCurve animationCurve = curveValue.intValue; + // the duration is ignored but it is better to define it + double animationDuration = [[[notif userInfo] objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + + // Apply keyboard animation + [UIView animateWithDuration:animationDuration delay:0 options:UIViewAnimationOptionBeginFromCurrentState | (animationCurve << 16) animations:^{ + self.keyboardHeight = 0; + } completion:^(BOOL finished) + { + }]; +} + +#pragma mark - KVO + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +{ + if ((object == _keyboardView) && ([keyPath isEqualToString:NSStringFromSelector(@selector(frame))] || [keyPath isEqualToString:NSStringFromSelector(@selector(center))])) + { + + // The keyboard view has been modified (Maybe the user drag it down), we update the input toolbar bottom constraint to adjust layout. + + // Compute keyboard height (on IOS 8 and later, the screen size is oriented) + CGSize screenSize = [[UIScreen mainScreen] bounds].size; + self.keyboardHeight = screenSize.height - _keyboardView.frame.origin.y; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKViewControllerActivityHandling.h b/Riot/Modules/MatrixKit/Controllers/MXKViewControllerActivityHandling.h new file mode 100644 index 000000000..299071230 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKViewControllerActivityHandling.h @@ -0,0 +1,50 @@ +// +// Copyright 2021 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#ifndef MXKViewControllerActivityHandling_h +#define MXKViewControllerActivityHandling_h + +/** + `MXKViewControllerActivityHandling` defines a protocol to handle requirements for + all matrixKit view controllers and table view controllers. + + It manages the following points: + - stop/start activity indicator according to the state of the associated matrix sessions. + */ +@protocol MXKViewControllerActivityHandling + +/** + Activity indicator view. + By default this activity indicator is centered inside the view controller view. It automatically + starts if `shouldShowActivityIndicator `returns true for the session. + It is stopped on other states. + Set nil to disable activity indicator animation. + */ +@property (nonatomic) UIActivityIndicatorView *activityIndicator; + +/** + Bring the activity indicator to the front and start it. + */ +- (void)startActivityIndicator; + +/** + Stop the activity indicator if all conditions are satisfied. + */ +- (void)stopActivityIndicator; + +@end + +#endif /* MXKViewControllerActivityHandling_h */ diff --git a/Riot/Modules/MatrixKit/Controllers/MXKViewControllerHandling.h b/Riot/Modules/MatrixKit/Controllers/MXKViewControllerHandling.h new file mode 100644 index 000000000..ee9143508 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKViewControllerHandling.h @@ -0,0 +1,148 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import + +#import "MXKResponderRageShaking.h" +#import "MXKViewControllerActivityHandling.h" + +/** + `MXKViewControllerHandling` defines a protocol to handle requirements for + all matrixKit view controllers and table view controllers. + + It manages the following points: + - matrix sessions handling, one or more sessions are supported. + - stop/start activity indicator according to the state of the associated matrix sessions. + - update view appearance on matrix session state change. + - support rage shake mechanism (depend on `rageShakeManager` property). + */ +@protocol MXKViewControllerHandling + +/** + The default navigation bar tint color (nil by default). + */ +@property (nonatomic) UIColor *defaultBarTintColor; + +/** + The color of the title in the navigation bar (nil by default). + */ +@property (nonatomic) UIColor *barTitleColor; + +/** + Enable the change of the navigation bar tint color according to the matrix homeserver reachability status (YES by default). + Set NO this property to disable navigation tint color change. + */ +@property (nonatomic) BOOL enableBarTintColorStatusChange; + +/** + List of associated matrix sessions (empty by default). + This property is used to update view appearance according to the session(s) state. + */ +@property (nonatomic, readonly) NSArray* mxSessions; + +/** + The first associated matrix session is considered as the main session (nil by default). + */ +@property (nonatomic, readonly) MXSession *mainSession; + +/** + Keep reference on the pushed and/or presented view controllers. + */ +@property (nonatomic, readonly) NSArray *childViewControllers; + +/** + An object implementing the `MXKResponderRageShaking` protocol. + The view controller uses this object (if any) to report beginning and end of potential + rage shake when it is the first responder. + + This property is nil by default. + */ +@property (nonatomic) id rageShakeManager; + +/** + Called during UIViewController initialization to set the default + properties values (see [initWithNibName:bundle:] and [initWithCoder:]). + + You should not call this method directly. + + Subclasses can override this method as needed to customize the initialization. + */ +- (void)finalizeInit; + +/** + Add a matrix session in the list of associated sessions (see 'mxSessions' property). + + The session is ignored if its state is 'MXSessionStateClosed'. + In other case, the session is stored, and an observer on 'kMXSessionStateDidChangeNotification' is added if it's not already done. + A session is automatically removed when its state returns to 'MXSessionStateClosed'. + + @param mxSession a Matrix session. + */ +- (void)addMatrixSession:(MXSession*)mxSession; + +/** + Remove a matrix session from the list of associated sessions (see 'mxSessions' property). + + Remove the session. The 'kMXSessionStateDidChangeNotification' observer is removed if there is no more matrix session. + + @param mxSession a Matrix session. + */ +- (void)removeMatrixSession:(MXSession*)mxSession; + +/** + The method specified as notification selector during 'kMXSessionStateDidChangeNotification' observer creation. + + By default this method consider ONLY notifications related to associated sessions (see 'mxSessions' property). + A session is automatically removed from the list when its state is 'MXSessionStateClosed'. Else [self onMatrixSessionChange] is called. + + Override it to handle state change on associated sessions AND others. + */ +- (void)onMatrixSessionStateDidChange:(NSNotification *)notif; + +/** + This method is called on the following matrix session changes: + - a new session is added. + - a session is removed. + - the state of an associated session changed (according to `MXSessionStateDidChangeNotification`). + + This method is called to refresh the display when the view controller will appear too. + + By default view controller appearance is updated according to the state of associated sessions: + - starts activity indicator as soon as when `shouldShowActivityIndicator `returns true for the session. + - switches in red the navigation bar tintColor when all sessions are in `MXSessionStateHomeserverNotReachable` state. + - switches in orange the navigation bar tintColor when at least one session is in `MXSessionStateHomeserverNotReachable` state. + + Override it to customize view appearance according to associated session(s). + */ +- (void)onMatrixSessionChange; + +/** + Pop or dismiss the view controller. It depends if the view controller is embedded inside a navigation controller or not. + + @param animated YES to animate the transition. + @param completion the block to execute after the view controller is popped or dismissed. This block has no return value and takes no parameters. You may specify nil for this parameter. + */ +- (void)withdrawViewControllerAnimated:(BOOL)animated completion:(void (^)(void))completion; + +/** + Dispose of any resources, and remove event observers. + */ +- (void)destroy; + +@end + diff --git a/Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.h b/Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.h new file mode 100644 index 000000000..30306e024 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.h @@ -0,0 +1,69 @@ +/* + Copyright 2016 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKViewController.h" +#import + +/** + 'MXKWebViewViewController' instance is used to display a webview. + */ +@interface MXKWebViewViewController : MXKViewController +{ +@protected + /** + The back button displayed as the right bar button item. + */ + UIBarButtonItem *backButton; + +@public + /** + The content of this screen is fully displayed by this webview + */ + WKWebView *webView; +} + +/** + Init 'MXKWebViewViewController' instance with a web content url. + + @param URL the url to open + */ +- (id)initWithURL:(NSString*)URL; + +/** + Init 'MXKWebViewViewController' instance with a local HTML file path. + + @param localHTMLFile The path of the local HTML file. + */ +- (id)initWithLocalHTMLFile:(NSString*)localHTMLFile; + +/** + Route javascript logs to NSLog. + */ +- (void)enableDebug; + +/** + Define the web content url to open + Don’t use this property to load local HTML files, instead use 'localHTMLFile'. + */ +@property (nonatomic) NSString *URL; + +/** + Define the local HTML file path to load + */ +@property (nonatomic) NSString *localHTMLFile; + +@end diff --git a/Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.m b/Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.m new file mode 100644 index 000000000..07c23b452 --- /dev/null +++ b/Riot/Modules/MatrixKit/Controllers/MXKWebViewViewController.m @@ -0,0 +1,349 @@ +/* + Copyright 2016 OpenMarket Ltd + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKWebViewViewController.h" + +#import "NSBundle+MatrixKit.h" + +#import + +#import "MXKSwiftHeader.h" + +NSString *const kMXKWebViewViewControllerPostMessageJSLog = @"jsLog"; + +// Override console.* logs methods to send WebKit postMessage events to native code. +// Note: this code has a minimal support of multiple parameters in console.log() +NSString *const kMXKWebViewViewControllerJavaScriptEnableLog = +@"console.debug = console.log; console.info = console.log; console.warn = console.log; console.error = console.log;" \ +@"console.log = function() {" \ +@" var msg = arguments[0];" \ +@" for (var i = 1; i < arguments.length; i++) {" \ +@" msg += ' ' + arguments[i];" \ +@" }" \ +@" window.webkit.messageHandlers.%@.postMessage(msg);" \ +@"};"; + +@interface MXKWebViewViewController () +{ + BOOL enableDebug; + + // Right buttons bar state before loading the webview + NSArray *originalRightBarButtonItems; +} + +@end + +@implementation MXKWebViewViewController + +- (instancetype)init +{ + self = [super init]; + if (self) + { + enableDebug = NO; + } + return self; +} + +- (id)initWithURL:(NSString*)URL +{ + self = [self init]; + if (self) + { + _URL = URL; + } + return self; +} + +- (id)initWithLocalHTMLFile:(NSString*)localHTMLFile +{ + self = [self init]; + if (self) + { + _localHTMLFile = localHTMLFile; + } + return self; +} + +- (void)enableDebug +{ + // We can only call addScriptMessageHandler on a given message only once + if (enableDebug) + { + return; + } + enableDebug = YES; + + // Redirect all console.* logging methods into a WebKit postMessage event with name "jsLog" + [webView.configuration.userContentController addScriptMessageHandler:self name:kMXKWebViewViewControllerPostMessageJSLog]; + + NSString *javaScriptString = [NSString stringWithFormat:kMXKWebViewViewControllerJavaScriptEnableLog, kMXKWebViewViewControllerPostMessageJSLog]; + + [webView evaluateJavaScript:javaScriptString completionHandler:nil]; +} + +- (void)finalizeInit +{ + [super finalizeInit]; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + + originalRightBarButtonItems = self.navigationItem.rightBarButtonItems; + + // Init the webview + webView = [[WKWebView alloc] initWithFrame:self.view.frame]; + webView.backgroundColor= [UIColor whiteColor]; + webView.navigationDelegate = self; + webView.UIDelegate = self; + + [webView setTranslatesAutoresizingMaskIntoConstraints:NO]; + [self.view addSubview:webView]; + + // Force webview in full width (to handle auto-layout in case of screen rotation) + NSLayoutConstraint *leftConstraint = [NSLayoutConstraint constraintWithItem:webView + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:self.view + attribute:NSLayoutAttributeLeading + multiplier:1.0 + constant:0]; + NSLayoutConstraint *rightConstraint = [NSLayoutConstraint constraintWithItem:webView + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:self.view + attribute:NSLayoutAttributeTrailing + multiplier:1.0 + constant:0]; + // Force webview in full height + NSLayoutConstraint *topConstraint = [NSLayoutConstraint constraintWithItem:webView + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.topLayoutGuide + attribute:NSLayoutAttributeBottom + multiplier:1.0 + constant:0]; + NSLayoutConstraint *bottomConstraint = [NSLayoutConstraint constraintWithItem:webView + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self.bottomLayoutGuide + attribute:NSLayoutAttributeTop + multiplier:1.0 + constant:0]; + + [NSLayoutConstraint activateConstraints:@[leftConstraint, rightConstraint, topConstraint, bottomConstraint]]; + + backButton = [[UIBarButtonItem alloc] initWithTitle:[MatrixKitL10n back] style:UIBarButtonItemStylePlain target:self action:@selector(goBack)]; + + if (_URL.length) + { + self.URL = _URL; + } + else if (_localHTMLFile.length) + { + self.localHTMLFile = _localHTMLFile; + } +} + +- (void)destroy +{ + if (webView) + { + webView.navigationDelegate = nil; + [webView stopLoading]; + [webView removeFromSuperview]; + webView = nil; + } + + backButton = nil; + + _URL = nil; + _localHTMLFile = nil; + + [super destroy]; +} + +- (void)dealloc +{ + [self destroy]; +} + +- (void)setURL:(NSString *)URL +{ + [webView stopLoading]; + + _URL = URL; + _localHTMLFile = nil; + + if (URL.length) + { + NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:URL]]; + [webView loadRequest:request]; + } +} + +- (void)setLocalHTMLFile:(NSString *)localHTMLFile +{ + [webView stopLoading]; + + _localHTMLFile = localHTMLFile; + _URL = nil; + + if (localHTMLFile.length) + { + NSString* htmlString = [NSString stringWithContentsOfFile:localHTMLFile encoding:NSUTF8StringEncoding error:nil]; + [webView loadHTMLString:htmlString baseURL:nil]; + } +} + +- (void)goBack +{ + if (webView.canGoBack) + { + [webView goBack]; + } + else if (_localHTMLFile.length) + { + // Reload local html file + self.localHTMLFile = _localHTMLFile; + } +} + +#pragma mark - WKNavigationDelegate + +- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation +{ + // Handle back button visibility here + BOOL canGoBack = webView.canGoBack; + + if (_localHTMLFile.length && !canGoBack) + { + // Check whether the current content is not the local html file + canGoBack = (![webView.URL.absoluteString isEqualToString:@"about:blank"]); + } + + if (canGoBack) + { + self.navigationItem.rightBarButtonItem = backButton; + } + else + { + // Reset the original state + self.navigationItem.rightBarButtonItems = originalRightBarButtonItems; + } +} + +- (void)webView:(WKWebView *)webView didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * _Nullable credential))completionHandler +{ + NSURLProtectionSpace *protectionSpace = [challenge protectionSpace]; + + // We handle here only the server trust authentication. + // We fallback to the default logic for other cases. + if (protectionSpace.authenticationMethod != NSURLAuthenticationMethodServerTrust || !protectionSpace.serverTrust) + { + completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil); + return; + } + + SecTrustRef serverTrust = [protectionSpace serverTrust]; + + // Check first whether there are some pinned certificates (certificate included in the bundle). + NSArray *paths = [[NSBundle mainBundle] pathsForResourcesOfType:@"cer" inDirectory:@"."]; + if (paths.count) + { + NSMutableArray *pinnedCertificates = [NSMutableArray array]; + for (NSString *path in paths) + { + NSData *certificateData = [NSData dataWithContentsOfFile:path]; + [pinnedCertificates addObject:(__bridge_transfer id)SecCertificateCreateWithData(NULL, (__bridge CFDataRef)certificateData)]; + } + // Only use these certificates to pin against, and do not trust the built-in anchor certificates. + SecTrustSetAnchorCertificates(serverTrust, (__bridge CFArrayRef)pinnedCertificates); + } + else + { + // Check whether some certificates have been trusted by the user (self-signed certificates support). + NSSet *certificates = [MXAllowedCertificates sharedInstance].certificates; + if (certificates.count) + { + NSMutableArray *allowedCertificates = [NSMutableArray array]; + for (NSData *certificateData in certificates) + { + [allowedCertificates addObject:(__bridge_transfer id)SecCertificateCreateWithData(NULL, (__bridge CFDataRef)certificateData)]; + } + // Add all the allowed certificates to the chain of trust + SecTrustSetAnchorCertificates(serverTrust, (__bridge CFArrayRef)allowedCertificates); + // Reenable trusting the built-in anchor certificates in addition to those passed in via the SecTrustSetAnchorCertificates API. + SecTrustSetAnchorCertificatesOnly(serverTrust, false); + } + } + + // Re-evaluate the trust policy + SecTrustResultType secresult = kSecTrustResultInvalid; + if (SecTrustEvaluate(serverTrust, &secresult) != errSecSuccess) + { + // Reject the server auth if an error occurs + completionHandler(NSURLSessionAuthChallengeRejectProtectionSpace, nil); + } + else + { + switch (secresult) + { + case kSecTrustResultUnspecified: // The OS trusts this certificate implicitly. + case kSecTrustResultProceed: // The user explicitly told the OS to trust it. + { + NSURLCredential *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust]; + completionHandler(NSURLSessionAuthChallengeUseCredential, credential); + break; + } + + default: + { + completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil); + break; + } + } + } +} + +#pragma mark - WKUIDelegate + +- (WKWebView *)webView:(WKWebView *)webView createWebViewWithConfiguration:(nonnull WKWebViewConfiguration *)configuration forNavigationAction:(nonnull WKNavigationAction *)navigationAction windowFeatures:(nonnull WKWindowFeatures *)windowFeatures +{ + // Make sure we open links with `target="_blank"` within this webview + if (!navigationAction.targetFrame.isMainFrame) + { + [webView loadRequest:navigationAction.request]; + } + + return nil; +} + +#pragma mark - WKScriptMessageHandler + +- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message +{ + if ([message.name isEqualToString:kMXKWebViewViewControllerPostMessageJSLog]) + { + MXLogDebug(@"-- JavaScript: %@", message.body); + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Libs/SwiftUTI/LICENSE b/Riot/Modules/MatrixKit/Libs/SwiftUTI/LICENSE new file mode 100644 index 000000000..a0bf476a7 --- /dev/null +++ b/Riot/Modules/MatrixKit/Libs/SwiftUTI/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Libs/SwiftUTI/SWIFT_UTI_README.md b/Riot/Modules/MatrixKit/Libs/SwiftUTI/SWIFT_UTI_README.md new file mode 100644 index 000000000..d23bf7763 --- /dev/null +++ b/Riot/Modules/MatrixKit/Libs/SwiftUTI/SWIFT_UTI_README.md @@ -0,0 +1,4 @@ +Original source: +https://github.com/mkeiser/SwiftUTI + +The pod of this library is no more maintained. At the time of writing this README, the official pod version is 1.0.6 whereas the last release of the library is 2.0.3. This last release has a podspec that points to 2.0.2. diff --git a/Riot/Modules/MatrixKit/Libs/SwiftUTI/UTI.swift b/Riot/Modules/MatrixKit/Libs/SwiftUTI/UTI.swift new file mode 100644 index 000000000..c3d66c481 --- /dev/null +++ b/Riot/Modules/MatrixKit/Libs/SwiftUTI/UTI.swift @@ -0,0 +1,517 @@ +// +// UTI.swift +// fseventstool +// +// Created by Matthias Keiser on 09.01.17. +// Copyright © 2017 Tristan Inc. All rights reserved. +// + +import Foundation + +#if os(iOS) || os(watchOS) + import MobileCoreServices +#elseif os(macOS) + import CoreServices +#endif + +/// Instances of the UTI class represent a specific Universal Type Identifier, e.g. kUTTypeMPEG4. + +public class UTI: RawRepresentable, Equatable { + + /** + The TagClass enum represents the supported tag classes. + + - fileExtension: kUTTagClassFilenameExtension + - mimeType: kUTTagClassMIMEType + - pbType: kUTTagClassNSPboardType + - osType: kUTTagClassOSType + */ + public enum TagClass: String { + + /// Equivalent to kUTTagClassFilenameExtension + case fileExtension = "public.filename-extension" + + /// Equivalent to kUTTagClassMIMEType + case mimeType = "public.mime-type" + + #if os (macOS) + + /// Equivalent to kUTTagClassNSPboardType + case pbType = "com.apple.nspboard-type" + + /// Equivalent to kUTTagClassOSType + case osType = "com.apple.ostype" + #endif + + /// Convenience variable for internal use. + + fileprivate var rawCFValue: CFString { + return self.rawValue as CFString + } + } + + public typealias RawValue = String + public let rawValue: String + + + /// Convenience variable for internal use. + + private var rawCFValue: CFString { + + return self.rawValue as CFString + } + + // MARK: Initialization + + + /** + + This is the designated initializer of the UTI class. + + - Parameters: + - rawValue: A string that is a Universal Type Identifier, i.e. "com.foobar.baz" or a constant like kUTTypeMP3. + - Returns: + An UTI instance representing the specified rawValue. + - Note: + You should rarely use this method. The preferred way to initialize a known UTI is to use its static variable (i.e. UTI.pdf). You should make an extension to make your own types available as static variables. + + */ + + public required init(rawValue: UTI.RawValue) { + + self.rawValue = rawValue + } + + /** + + Initialize an UTI with a tag of a specified class. + + - Parameters: + - tagClass: The class of the tag. + - value: The value of the tag. + - conformingTo: If specified, the returned UTI must conform to this UTI. If nil is specified, this parameter is ignored. The default is nil. + - Returns: + An UTI instance representing the specified rawValue. If no known UTI with the specified tags is found, a dynamic UTI is created. + - Note: + You should rarely need this method. It's usually simpler to use one of the specialized initialzers like + ```convenience init?(withExtension fileExtension: String, conformingTo conforming: UTI? = nil)``` + */ + + public convenience init(withTagClass tagClass: TagClass, value: String, conformingTo conforming: UTI? = nil) { + + let unmanagedIdentifier = UTTypeCreatePreferredIdentifierForTag(tagClass.rawCFValue, value as CFString, conforming?.rawCFValue) + + // UTTypeCreatePreferredIdentifierForTag only returns nil if the tag class is unknwown, which can't happen to us since we use an + // enum of known values. Hence we can force-cast the result. + + let identifier = (unmanagedIdentifier?.takeRetainedValue() as String?)! + + self.init(rawValue: identifier) + } + + /** + + Initialize an UTI with a file extension. + + - Parameters: + - withExtension: The file extension (e.g. "txt"). + - conformingTo: If specified, the returned UTI must conform to this UTI. If nil is specified, this parameter is ignored. The default is nil. + - Returns: + An UTI corresponding to the specified values. + **/ + + public convenience init(withExtension fileExtension: String, conformingTo conforming: UTI? = nil) { + + self.init(withTagClass:.fileExtension, value: fileExtension, conformingTo: conforming) + } + + /** + + Initialize an UTI with a MIME type. + + - Parameters: + - mimeType: The MIME type (e.g. "text/plain"). + - conformingTo: If specified, the returned UTI must conform to this UTI. If nil is specified, this parameter is ignored. The default is nil. + - Returns: + An UTI corresponding to the specified values. + */ + + public convenience init(withMimeType mimeType: String, conformingTo conforming: UTI? = nil) { + + self.init(withTagClass:.mimeType, value: mimeType, conformingTo: conforming) + } + + #if os(macOS) + + /** + + Initialize an UTI with a pasteboard type. + - Important: **This function is de-facto deprecated!** The old cocoa pasteboard types ( `NSStringPboardType`, `NSPDFPboardType`, etc) have been deprecated in favour of actual UTIs, and the constants are not available anymore in Swift. This function only works correctly with the values of these old constants, but _not_ with the replacement values (like `NSPasteboardTypeString` etc), since these already are UTIs. + - Parameters: + - pbType: The pasteboard type (e.g. NSPDFPboardType). + - conformingTo: If specified, the returned UTI must conform to this UTI. If nil is specified, this parameter is ignored. The default is nil. + - Returns: + An UTI corresponding to the specified values. + */ + public convenience init(withPBType pbType: String, conformingTo conforming: UTI? = nil) { + + self.init(withTagClass:.pbType, value: pbType, conformingTo: conforming) + } + + /** + Initialize an UTI with a OSType. + + - Parameters: + - osType: The OSType type as a string (e.g. "PDF "). + - conformingTo: If specified, the returned UTI must conform to this UTI. If nil is specified, this parameter is ignored. The default is nil. + - Returns: + An UTI corresponding to the specified values. + - Note: + You can use the variable ```OSType.string``` to get a string from an actual OSType. + */ + + public convenience init(withOSType osType: String, conformingTo conforming: UTI? = nil) { + + self.init(withTagClass:.osType, value: osType, conformingTo: conforming) + } + + #endif + + // MARK: Accessing Tags + + /** + + Returns the tag with the specified class. + + - Parameters: + - tagClass: The tag class to return. + - Returns: + The requested tag, or nil if there is no tag of the specified class. + */ + + public func tag(with tagClass: TagClass) -> String? { + + let unmanagedTag = UTTypeCopyPreferredTagWithClass(self.rawCFValue, tagClass.rawCFValue) + + guard let tag = unmanagedTag?.takeRetainedValue() as String? else { + return nil + } + + return tag + } + + /// Return the file extension that corresponds the the UTI. Returns nil if not available. + + public var fileExtension: String? { + + return self.tag(with: .fileExtension) + } + + /// Return the MIME type that corresponds the the UTI. Returns nil if not available. + + public var mimeType: String? { + + return self.tag(with: .mimeType) + } + + #if os(macOS) + + /// Return the pasteboard type that corresponds the the UTI. Returns nil if not available. + + public var pbType: String? { + + return self.tag(with: .pbType) + } + + /// Return the OSType as a string that corresponds the the UTI. Returns nil if not available. + /// - Note: you can use the ```init(with string: String)``` initializer to construct an actual OSType from the returnes string. + + public var osType: String? { + + return self.tag(with: .osType) + } + + #endif + + /** + + Returns all tags of the specified tag class. + + - Parameters: + - tagClass: The class of the requested tags. + - Returns: + An array of all tags of the receiver of the specified class. + */ + + public func tags(with tagClass: TagClass) -> Array { + + let unmanagedTags = UTTypeCopyAllTagsWithClass(self.rawCFValue, tagClass.rawCFValue) + + guard let tags = unmanagedTags?.takeRetainedValue() as? Array else { + return [] + } + + return tags as Array + } + + // MARK: List all UTIs associated with a tag + + + /** + Returns all UTIs that are associated with a specified tag. + + - Parameters: + - tag: The class of the specified tag. + - value: The value of the tag. + - conforming: If specified, the returned UTIs must conform to this UTI. If nil is specified, this parameter is ignored. The default is nil. + - Returns: + An array of all UTIs that satisfy the specified parameters. + */ + + public static func utis(for tag: TagClass, value: String, conformingTo conforming: UTI? = nil) -> Array { + + let unmanagedIdentifiers = UTTypeCreateAllIdentifiersForTag(tag.rawCFValue, value as CFString, conforming?.rawCFValue) + + + guard let identifiers = unmanagedIdentifiers?.takeRetainedValue() as? Array else { + return [] + } + + return identifiers.compactMap { UTI(rawValue: $0 as String) } + } + + // MARK: Equality and Conformance to other UTIs + + /** + + Checks if the receiver conforms to a specified UTI. + + - Parameters: + - otherUTI: The UTI to which the receiver is compared. + - Returns: + ```true``` if the receiver conforms to the specified UTI, ```false```otherwise. + */ + + public func conforms(to otherUTI: UTI) -> Bool { + + return UTTypeConformsTo(self.rawCFValue, otherUTI.rawCFValue) as Bool + } + + public static func ==(lhs: UTI, rhs: UTI) -> Bool { + + return UTTypeEqual(lhs.rawCFValue, rhs.rawCFValue) as Bool + } + + // MARK: Accessing Information about an UTI + + /// Returns the localized, user-readable type description string associated with a uniform type identifier. + + public var description: String? { + + let unmanagedDescription = UTTypeCopyDescription(self.rawCFValue) + + guard let description = unmanagedDescription?.takeRetainedValue() as String? else { + return nil + } + + return description + } + + /// Returns a uniform type’s declaration as a Dictionary, or nil if if no declaration for that type can be found. + + public var declaration: [AnyHashable:Any]? { + + let unmanagedDeclaration = UTTypeCopyDeclaration(self.rawCFValue) + + guard let declaration = unmanagedDeclaration?.takeRetainedValue() as? [AnyHashable:Any] else { + return nil + } + + return declaration + } + + /// Returns the location of a bundle containing the declaration for a type, or nil if the bundle could not be located. + + public var declaringBundleURL: URL? { + + let unmanagedURL = UTTypeCopyDeclaringBundleURL(self.rawCFValue) + + guard let url = unmanagedURL?.takeRetainedValue() as URL? else { + return nil + } + + return url + } + + /// Returns ```true``` if the receiver is a dynamic UTI. + + public var isDynamic: Bool { + + return UTTypeIsDynamic(self.rawCFValue) + } +} + + +// MARK: System defined UTIs + +public extension UTI { + + static let item = UTI(rawValue: kUTTypeItem as String) + static let content = UTI(rawValue: kUTTypeContent as String) + static let compositeContent = UTI(rawValue: kUTTypeCompositeContent as String) + static let message = UTI(rawValue: kUTTypeMessage as String) + static let contact = UTI(rawValue: kUTTypeContact as String) + static let archive = UTI(rawValue: kUTTypeArchive as String) + static let diskImage = UTI(rawValue: kUTTypeDiskImage as String) + static let data = UTI(rawValue: kUTTypeData as String) + static let directory = UTI(rawValue: kUTTypeDirectory as String) + static let resolvable = UTI(rawValue: kUTTypeResolvable as String) + static let symLink = UTI(rawValue: kUTTypeSymLink as String) + static let executable = UTI(rawValue: kUTTypeExecutable as String) + static let mountPoint = UTI(rawValue: kUTTypeMountPoint as String) + static let aliasFile = UTI(rawValue: kUTTypeAliasFile as String) + static let aliasRecord = UTI(rawValue: kUTTypeAliasRecord as String) + static let urlBookmarkData = UTI(rawValue: kUTTypeURLBookmarkData as String) + static let url = UTI(rawValue: kUTTypeURL as String) + static let fileURL = UTI(rawValue: kUTTypeFileURL as String) + static let text = UTI(rawValue: kUTTypeText as String) + static let plainText = UTI(rawValue: kUTTypePlainText as String) + static let utf8PlainText = UTI(rawValue: kUTTypeUTF8PlainText as String) + static let utf16ExternalPlainText = UTI(rawValue: kUTTypeUTF16ExternalPlainText as String) + static let utf16PlainText = UTI(rawValue: kUTTypeUTF16PlainText as String) + static let delimitedText = UTI(rawValue: kUTTypeDelimitedText as String) + static let commaSeparatedText = UTI(rawValue: kUTTypeCommaSeparatedText as String) + static let tabSeparatedText = UTI(rawValue: kUTTypeTabSeparatedText as String) + static let utf8TabSeparatedText = UTI(rawValue: kUTTypeUTF8TabSeparatedText as String) + static let rtf = UTI(rawValue: kUTTypeRTF as String) + static let html = UTI(rawValue: kUTTypeHTML as String) + static let xml = UTI(rawValue: kUTTypeXML as String) + static let sourceCode = UTI(rawValue: kUTTypeSourceCode as String) + static let assemblyLanguageSource = UTI(rawValue: kUTTypeAssemblyLanguageSource as String) + static let cSource = UTI(rawValue: kUTTypeCSource as String) + static let objectiveCSource = UTI(rawValue: kUTTypeObjectiveCSource as String) + @available( OSX 10.11, iOS 9.0, * ) + static let swiftSource = UTI(rawValue: kUTTypeSwiftSource as String) + static let cPlusPlusSource = UTI(rawValue: kUTTypeCPlusPlusSource as String) + static let objectiveCPlusPlusSource = UTI(rawValue: kUTTypeObjectiveCPlusPlusSource as String) + static let cHeader = UTI(rawValue: kUTTypeCHeader as String) + static let cPlusPlusHeader = UTI(rawValue: kUTTypeCPlusPlusHeader as String) + static let javaSource = UTI(rawValue: kUTTypeJavaSource as String) + static let script = UTI(rawValue: kUTTypeScript as String) + static let appleScript = UTI(rawValue: kUTTypeAppleScript as String) + static let osaScript = UTI(rawValue: kUTTypeOSAScript as String) + static let osaScriptBundle = UTI(rawValue: kUTTypeOSAScriptBundle as String) + static let javaScript = UTI(rawValue: kUTTypeJavaScript as String) + static let shellScript = UTI(rawValue: kUTTypeShellScript as String) + static let perlScript = UTI(rawValue: kUTTypePerlScript as String) + static let pythonScript = UTI(rawValue: kUTTypePythonScript as String) + static let rubyScript = UTI(rawValue: kUTTypeRubyScript as String) + static let phpScript = UTI(rawValue: kUTTypePHPScript as String) + static let json = UTI(rawValue: kUTTypeJSON as String) + static let propertyList = UTI(rawValue: kUTTypePropertyList as String) + static let xmlPropertyList = UTI(rawValue: kUTTypeXMLPropertyList as String) + static let binaryPropertyList = UTI(rawValue: kUTTypeBinaryPropertyList as String) + static let pdf = UTI(rawValue: kUTTypePDF as String) + static let rtfd = UTI(rawValue: kUTTypeRTFD as String) + static let flatRTFD = UTI(rawValue: kUTTypeFlatRTFD as String) + static let txnTextAndMultimediaData = UTI(rawValue: kUTTypeTXNTextAndMultimediaData as String) + static let webArchive = UTI(rawValue: kUTTypeWebArchive as String) + static let image = UTI(rawValue: kUTTypeImage as String) + static let jpeg = UTI(rawValue: kUTTypeJPEG as String) + static let jpeg2000 = UTI(rawValue: kUTTypeJPEG2000 as String) + static let tiff = UTI(rawValue: kUTTypeTIFF as String) + static let pict = UTI(rawValue: kUTTypePICT as String) + static let gif = UTI(rawValue: kUTTypeGIF as String) + static let png = UTI(rawValue: kUTTypePNG as String) + static let quickTimeImage = UTI(rawValue: kUTTypeQuickTimeImage as String) + static let appleICNS = UTI(rawValue: kUTTypeAppleICNS as String) + static let bmp = UTI(rawValue: kUTTypeBMP as String) + static let ico = UTI(rawValue: kUTTypeICO as String) + static let rawImage = UTI(rawValue: kUTTypeRawImage as String) + static let scalableVectorGraphics = UTI(rawValue: kUTTypeScalableVectorGraphics as String) + @available(OSX 10.12, iOS 9.1, watchOS 2.1, *) + static let livePhoto = UTI(rawValue: kUTTypeLivePhoto as String) + @available(OSX 10.12, iOS 9.1, *) + static let audiovisualContent = UTI(rawValue: kUTTypeAudiovisualContent as String) + static let movie = UTI(rawValue: kUTTypeMovie as String) + static let video = UTI(rawValue: kUTTypeVideo as String) + static let audio = UTI(rawValue: kUTTypeAudio as String) + static let quickTimeMovie = UTI(rawValue: kUTTypeQuickTimeMovie as String) + static let mpeg = UTI(rawValue: kUTTypeMPEG as String) + static let mpeg2Video = UTI(rawValue: kUTTypeMPEG2Video as String) + static let mpeg2TransportStream = UTI(rawValue: kUTTypeMPEG2TransportStream as String) + static let mp3 = UTI(rawValue: kUTTypeMP3 as String) + static let mpeg4 = UTI(rawValue: kUTTypeMPEG4 as String) + static let mpeg4Audio = UTI(rawValue: kUTTypeMPEG4Audio as String) + static let appleProtectedMPEG4Audio = UTI(rawValue: kUTTypeAppleProtectedMPEG4Audio as String) + static let appleProtectedMPEG4Video = UTI(rawValue: kUTTypeAppleProtectedMPEG4Video as String) + static let aviMovie = UTI(rawValue: kUTTypeAVIMovie as String) + static let audioInterchangeFileFormat = UTI(rawValue: kUTTypeAudioInterchangeFileFormat as String) + static let waveformAudio = UTI(rawValue: kUTTypeWaveformAudio as String) + static let midiAudio = UTI(rawValue: kUTTypeMIDIAudio as String) + static let playlist = UTI(rawValue: kUTTypePlaylist as String) + static let m3UPlaylist = UTI(rawValue: kUTTypeM3UPlaylist as String) + static let folder = UTI(rawValue: kUTTypeFolder as String) + static let volume = UTI(rawValue: kUTTypeVolume as String) + static let package = UTI(rawValue: kUTTypePackage as String) + static let bundle = UTI(rawValue: kUTTypeBundle as String) + static let pluginBundle = UTI(rawValue: kUTTypePluginBundle as String) + static let spotlightImporter = UTI(rawValue: kUTTypeSpotlightImporter as String) + static let quickLookGenerator = UTI(rawValue: kUTTypeQuickLookGenerator as String) + static let xpcService = UTI(rawValue: kUTTypeXPCService as String) + static let framework = UTI(rawValue: kUTTypeFramework as String) + static let application = UTI(rawValue: kUTTypeApplication as String) + static let applicationBundle = UTI(rawValue: kUTTypeApplicationBundle as String) + static let applicationFile = UTI(rawValue: kUTTypeApplicationFile as String) + static let unixExecutable = UTI(rawValue: kUTTypeUnixExecutable as String) + static let windowsExecutable = UTI(rawValue: kUTTypeWindowsExecutable as String) + static let javaClass = UTI(rawValue: kUTTypeJavaClass as String) + static let javaArchive = UTI(rawValue: kUTTypeJavaArchive as String) + static let systemPreferencesPane = UTI(rawValue: kUTTypeSystemPreferencesPane as String) + static let gnuZipArchive = UTI(rawValue: kUTTypeGNUZipArchive as String) + static let bzip2Archive = UTI(rawValue: kUTTypeBzip2Archive as String) + static let zipArchive = UTI(rawValue: kUTTypeZipArchive as String) + static let spreadsheet = UTI(rawValue: kUTTypeSpreadsheet as String) + static let presentation = UTI(rawValue: kUTTypePresentation as String) + static let database = UTI(rawValue: kUTTypeDatabase as String) + static let vCard = UTI(rawValue: kUTTypeVCard as String) + static let toDoItem = UTI(rawValue: kUTTypeToDoItem as String) + static let calendarEvent = UTI(rawValue: kUTTypeCalendarEvent as String) + static let emailMessage = UTI(rawValue: kUTTypeEmailMessage as String) + static let internetLocation = UTI(rawValue: kUTTypeInternetLocation as String) + static let inkText = UTI(rawValue: kUTTypeInkText as String) + static let font = UTI(rawValue: kUTTypeFont as String) + static let bookmark = UTI(rawValue: kUTTypeBookmark as String) + static let _3DContent = UTI(rawValue: kUTType3DContent as String) + static let pkcs12 = UTI(rawValue: kUTTypePKCS12 as String) + static let x509Certificate = UTI(rawValue: kUTTypeX509Certificate as String) + static let electronicPublication = UTI(rawValue: kUTTypeElectronicPublication as String) + static let log = UTI(rawValue: kUTTypeLog as String) +} + +#if os(OSX) + + extension OSType { + + + /// Returns the OSType encoded as a String. + + var string: String { + + let unmanagedString = UTCreateStringForOSType(self) + + return unmanagedString.takeRetainedValue() as String + } + + + /// Initializes a OSType from a String. + /// + /// - Parameter string: A String representing an OSType. + + init(with string: String) { + + self = UTGetOSTypeFromString(string as CFString) + } + } + +#endif diff --git a/Riot/Modules/MatrixKit/MatrixKit.h b/Riot/Modules/MatrixKit/MatrixKit.h new file mode 100644 index 000000000..b6e5806f4 --- /dev/null +++ b/Riot/Modules/MatrixKit/MatrixKit.h @@ -0,0 +1,155 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKConstants.h" + +#import "MXKAppSettings.h" + +#import "MXAggregatedReactions+MatrixKit.h" +#import "MXEvent+MatrixKit.h" +#import "MXRoom+Sync.h" +#import "NSBundle+MatrixKit.h" +#import "NSBundle+MXKLanguage.h" +#import "UIAlertController+MatrixKit.h" +#import "UIViewController+MatrixKit.h" + +#import "MXKEventFormatter.h" + +#import "MXKTools.h" + +#import "MXKErrorPresentation.h" +#import "MXKErrorPresentable.h" +#import "MXKErrorViewModel.h" +#import "MXKErrorPresentableBuilder.h" +#import "MXKErrorAlertPresentation.h" + +#import "MXKViewController.h" +#import "MXKRoomViewController.h" +#import "MXKRecentListViewController.h" +#import "MXKRoomMemberListViewController.h" +#import "MXKSearchViewController.h" +#import "MXKCallViewController.h" +#import "MXKContactListViewController.h" +#import "MXKAccountDetailsViewController.h" +#import "MXKContactDetailsViewController.h" +#import "MXKRoomMemberDetailsViewController.h" +#import "MXKNotificationSettingsViewController.h" +#import "MXKAttachmentsViewController.h" +#import "MXKRoomSettingsViewController.h" +#import "MXKWebViewViewController.h" + +#import "MXKAuthenticationViewController.h" +#import "MXKAuthInputsPasswordBasedView.h" +#import "MXKAuthInputsEmailCodeBasedView.h" +#import "MXKAuthenticationFallbackWebView.h" +#import "MXKAuthenticationRecaptchaWebView.h" + +#import "MXKView.h" + +#import "MXKRoomCreationInputs.h" + +#import "MXKInterleavedRecentsDataSource.h" + +#import "MXKRoomCreationView.h" + +#import "MXKRoomInputToolbarView.h" +#import "MXKRoomInputToolbarViewWithHPGrowingText.h" + +#import "MXKRoomDataSourceManager.h" + +#import "MXKRoomBubbleCellData.h" +#import "MXKRoomBubbleCellDataWithAppendingMode.h" + +#import "MXKAttachment.h" + +#import "MXKRecentTableViewCell.h" +#import "MXKInterleavedRecentTableViewCell.h" + +#import "MXKPublicRoomTableViewCell.h" + +#import "MXKDirectoryServersDataSource.h" +#import "MXKDirectoryServerCellDataStoring.h" +#import "MXKDirectoryServerCellData.h" + +#import "MXKRoomMemberTableViewCell.h" +#import "MXKAccountTableViewCell.h" +#import "MXKReadReceiptTableViewCell.h" +#import "MXKPushRuleTableViewCell.h" +#import "MXKPushRuleCreationTableViewCell.h" + +#import "MXKTableViewCellWithButton.h" +#import "MXKTableViewCellWithButtons.h" +#import "MXKTableViewCellWithLabelAndButton.h" +#import "MXKTableViewCellWithLabelAndImageView.h" +#import "MXKTableViewCellWithLabelAndMXKImageView.h" +#import "MXKTableViewCellWithLabelAndSlider.h" +#import "MXKTableViewCellWithLabelAndSubLabel.h" +#import "MXKTableViewCellWithLabelAndSwitch.h" +#import "MXKTableViewCellWithLabelAndTextField.h" +#import "MXKTableViewCellWithLabelTextFieldAndButton.h" +#import "MXKTableViewCellWithPicker.h" +#import "MXKTableViewCellWithSearchBar.h" +#import "MXKTableViewCellWithTextFieldAndButton.h" +#import "MXKTableViewCellWithTextView.h" + +#import "MXKTableViewHeaderFooterWithLabel.h" + +#import "MXKMediaCollectionViewCell.h" +#import "MXKPieChartView.h" +#import "MXKPieChartHUD.h" + +#import "MXKRoomTitleView.h" +#import "MXKRoomTitleViewWithTopic.h" + +#import "MXKRoomEmptyBubbleTableViewCell.h" + +#import "MXKRoomIncomingBubbleTableViewCell.h" +#import "MXKRoomIncomingTextMsgBubbleCell.h" +#import "MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.h" +#import "MXKRoomIncomingAttachmentBubbleCell.h" +#import "MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.h" + +#import "MXKRoomOutgoingBubbleTableViewCell.h" +#import "MXKRoomOutgoingTextMsgBubbleCell.h" +#import "MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.h" +#import "MXKRoomOutgoingAttachmentBubbleCell.h" +#import "MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.h" + +#import "MXKSearchCellData.h" +#import "MXKSearchTableViewCell.h" + +#import "MXKAccountManager.h" + +#import "MXKContactManager.h" + +#import "MXK3PID.h" + +#import "MXKDeviceView.h" +#import "MXKEncryptionInfoView.h" +#import "MXKEncryptionKeysExportView.h" + +#import "MXKCountryPickerViewController.h" +#import "MXKLanguagePickerViewController.h" + +#import "MXKGroupCellData.h" +#import "MXKSessionGroupsDataSource.h" +#import "MXKGroupListViewController.h" +#import "MXKGroupTableViewCell.h" + +#import "MXKSlashCommands.h" diff --git a/Riot/Modules/MatrixKit/MatrixKitVersion.m b/Riot/Modules/MatrixKit/MatrixKitVersion.m new file mode 100644 index 000000000..1ba29cf68 --- /dev/null +++ b/Riot/Modules/MatrixKit/MatrixKitVersion.m @@ -0,0 +1,19 @@ +/* + Copyright 2020 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +NSString *const MatrixKitVersion = @"0.16.10"; diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h new file mode 100644 index 000000000..8d21f6e72 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h @@ -0,0 +1,435 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +@class MXKAccount; + +/** + Posted when account user information (display name, picture, presence) has been updated. + The notification object is the matrix user id of the account. + */ +extern NSString *const kMXKAccountUserInfoDidChangeNotification; + +/** + Posted when the activity of the Apple Push Notification Service has been changed. + The notification object is the matrix user id of the account. + */ +extern NSString *const kMXKAccountAPNSActivityDidChangeNotification; + +/** + Posted when the activity of the Push notification based on PushKit has been changed. + The notification object is the matrix user id of the account. + */ +extern NSString *const kMXKAccountPushKitActivityDidChangeNotification; + +/** + MXKAccount error domain + */ +extern NSString *const kMXKAccountErrorDomain; + +/** + Block called when a certificate change is observed during authentication challenge from a server. + + @param mxAccount the account concerned by this certificate change. + @param certificate the server certificate to evaluate. + @return YES to accept/trust this certificate, NO to cancel/ignore it. + */ +typedef BOOL (^MXKAccountOnCertificateChange)(MXKAccount *mxAccount, NSData *certificate); + +/** + `MXKAccount` object contains the credentials of a logged matrix user. It is used to handle matrix + session and presence for this user. + */ +@interface MXKAccount : NSObject + +/** + The account's credentials: homeserver, access token, user id. + */ +@property (nonatomic, readonly) MXCredentials *mxCredentials; + +/** + The identity server URL. + */ +@property (nonatomic) NSString *identityServerURL; + +/** + The antivirus server URL, if any (nil by default). + Set a non-null url to configure the antivirus scanner use. + */ +@property (nonatomic) NSString *antivirusServerURL; + +/** + The Push Gateway URL used to send event notifications to (nil by default). + This URL should be over HTTPS and never over HTTP. + */ +@property (nonatomic) NSString *pushGatewayURL; + +/** + The matrix REST client used to make matrix API requests. + */ +@property (nonatomic, readonly) MXRestClient *mxRestClient; + +/** + The matrix session opened with the account (nil by default). + */ +@property (nonatomic, readonly) MXSession *mxSession; + +/** + The account user's display name (nil by default, available if matrix session `mxSession` is opened). + The notification `kMXKAccountUserInfoDidChangeNotification` is posted in case of change of this property. + */ +@property (nonatomic, readonly) NSString *userDisplayName; + +/** + The account user's avatar url (nil by default, available if matrix session `mxSession` is opened). + The notification `kMXKAccountUserInfoDidChangeNotification` is posted in case of change of this property. + */ +@property (nonatomic, readonly) NSString *userAvatarUrl; + +/** + The account display name based on user id and user displayname (if any). + */ +@property (nonatomic, readonly) NSString *fullDisplayName; + +/** + The 3PIDs linked to this account. + [self load3PIDs] must be called to update the property. + */ +@property (nonatomic, readonly) NSArray *threePIDs; + +/** + The email addresses linked to this account. + This is a subset of self.threePIDs. + */ +@property (nonatomic, readonly) NSArray *linkedEmails; + +/** + The phone numbers linked to this account. + This is a subset of self.threePIDs. + */ +@property (nonatomic, readonly) NSArray *linkedPhoneNumbers; + +/** + The account user's device. + [self loadDeviceInformation] must be called to update the property. + */ +@property (nonatomic, readonly) MXDevice *device; + +/** + The account user's presence (`MXPresenceUnknown` by default, available if matrix session `mxSession` is opened). + The notification `kMXKAccountUserInfoDidChangeNotification` is posted in case of change of this property. + */ +@property (nonatomic, readonly) MXPresence userPresence; + +/** + The account user's tint color: a unique color fixed by the user id. This tint color may be used to highlight + rooms which belong to this account's user. + */ +@property (nonatomic, readonly) UIColor *userTintColor; + +/** + The Apple Push Notification Service activity for this account. YES when APNS is turned on (locally available and synced with server). + */ +@property (nonatomic, readonly) BOOL pushNotificationServiceIsActive; + +/** + Transient information storage. + */ +@property (nonatomic, strong, readonly) NSMutableDictionary> *others; + +/** + Enable Push notification based on Apple Push Notification Service (APNS). + + This method creates or removes a pusher on the homeserver. + + @param enable YES to enable it. + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)enablePushNotifications:(BOOL)enable + success:(void (^)(void))success + failure:(void (^)(NSError *))failure; + +/** + Flag to indicate that an APNS pusher has been set on the homeserver for this device. + */ +@property (nonatomic, readonly) BOOL hasPusherForPushNotifications; + +/** + The Push notification activity (based on PushKit) for this account. + YES when Push is turned on (locally available and enabled homeserver side). + */ +@property (nonatomic, readonly) BOOL isPushKitNotificationActive; + +/** + Enable Push notification based on PushKit. + + This method creates or removes a pusher on the homeserver. + + @param enable YES to enable it. + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)enablePushKitNotifications:(BOOL)enable + success:(void (^)(void))success + failure:(void (^)(NSError *))failure; + +/** + Flag to indicate that a PushKit pusher has been set on the homeserver for this device. + */ +@property (nonatomic, readonly) BOOL hasPusherForPushKitNotifications; + + +/** + Enable In-App notifications based on Remote notifications rules. + NO by default. + */ +@property (nonatomic) BOOL enableInAppNotifications; + +/** + Disable the account without logging out (NO by default). + + A matrix session is automatically opened for the account when this property is toggled from YES to NO. + The session is closed when this property is set to YES. + */ +@property (nonatomic,getter=isDisabled) BOOL disabled; + +/** + Manage the online presence event. + + The presence event must not be sent if the application is launched by a push notification. + */ +@property (nonatomic) BOOL hideUserPresence; + +/** + Flag indicating if the end user has been warned about encryption and its limitations. + */ +@property (nonatomic,getter=isWarnedAboutEncryption) BOOL warnedAboutEncryption; + +/** + Register the MXKAccountOnCertificateChange block that will be used to handle certificate change during account use. + This block is nil by default, any new certificate is ignored/untrusted (this will abort the connection to the server). + + @param onCertificateChangeBlock the block that will be used to handle certificate change. + */ ++ (void)registerOnCertificateChangeBlock:(MXKAccountOnCertificateChange)onCertificateChangeBlock; + +/** + Get the color code related to a specific presence. + + @param presence a user presence + @return color defined for the provided presence (nil if no color is defined). + */ ++ (UIColor*)presenceColor:(MXPresence)presence; + +/** + Init `MXKAccount` instance with credentials. No matrix session is opened by default. + + @param credentials user's credentials + */ +- (instancetype)initWithCredentials:(MXCredentials*)credentials; + +/** + Create a matrix session based on the provided store. + When store data is ready, the live stream is automatically launched by synchronising the session with the server. + + In case of failure during server sync, the method is reiterated until the data is up-to-date with the server. + This loop is stopped if you call [MXCAccount closeSession:], it is suspended if you call [MXCAccount pauseInBackgroundTask]. + + @param store the store to use for the session. + */ +-(void)openSessionWithStore:(id)store; + +/** + Close the matrix session. + + @param clearStore set YES to delete all store data. + */ +- (void)closeSession:(BOOL)clearStore; + +/** + Invalidate the access token, close the matrix session and delete all store data. + + @note This method is equivalent to `logoutSendingServerRequest:completion:` with `sendLogoutServerRequest` parameter to YES + + @param completion the block to execute at the end of the operation (independently if it succeeded or not). + */ +- (void)logout:(void (^)(void))completion; + +/** + Invalidate the access token, close the matrix session and delete all store data. + + @param sendLogoutServerRequest indicate to send logout request to homeserver. + @param completion the block to execute at the end of the operation (independently if it succeeded or not). + */ +- (void)logoutSendingServerRequest:(BOOL)sendLogoutServerRequest + completion:(void (^)(void))completion; + + +#pragma mark - Soft logout + +/** + Flag to indicate if the account has been logged out by the homeserver admin. + */ +@property (nonatomic, readonly) BOOL isSoftLogout; + +/** + Soft logout the account. + + The matix session is stopped but the data is kept. + */ +- (void)softLogout; + +/** + Hydrate the account using the credentials provided. + + @param credentials the new credentials. +*/ +- (void)hydrateWithCredentials:(MXCredentials*)credentials; + +/** + Pause the current matrix session. + + @warning: This matrix session is paused without using background task if no background mode handler + is set in the MXSDKOptions sharedInstance (see `backgroundModeHandler`). + */ +- (void)pauseInBackgroundTask; + +/** + Perform a background sync by keeping the user offline. + + @warning: This operation failed when no background mode handler is set in the + MXSDKOptions sharedInstance (see `backgroundModeHandler`). + + @param timeout the timeout in milliseconds. + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)backgroundSync:(unsigned int)timeout success:(void (^)(void))success failure:(void (^)(NSError *))failure; + +/** + Resume the current matrix session. + */ +- (void)resume; + +/** + Close the potential matrix session and open a new one if the account is not disabled. + + @param clearCache set YES to delete all store data. + */ +- (void)reload:(BOOL)clearCache; + +/** + Set the display name of the account user. + + @param displayname the new display name. + + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)setUserDisplayName:(NSString*)displayname success:(void (^)(void))success failure:(void (^)(NSError *error))failure; + +/** + Set the avatar url of the account user. + + @param avatarUrl the new avatar url. + + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)setUserAvatarUrl:(NSString*)avatarUrl success:(void (^)(void))success failure:(void (^)(NSError *error))failure; + +/** + Update the account password. + + @param oldPassword the old password. + @param newPassword the new password. + + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)changePassword:(NSString*)oldPassword with:(NSString*)newPassword success:(void (^)(void))success failure:(void (^)(NSError *error))failure; + +/** + Load the 3PIDs linked to this account. + This method must be called to refresh self.threePIDs and self.linkedEmails. + + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)load3PIDs:(void (^)(void))success failure:(void (^)(NSError *error))failure; + +/** + Load the current device information for this account. + This method must be called to refresh self.device. + + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)loadDeviceInformation:(void (^)(void))success failure:(void (^)(NSError *error))failure; + +#pragma mark - Push notification listeners +/** + Register a listener to push notifications for the account's session. + + The listener will be called when a push rule matches a live event. + Note: only one listener is supported. Potential existing listener is removed. + + You may use `[MXCAccount updateNotificationListenerForRoomId:]` to disable/enable all notifications from a specific room. + + @param onNotification the block that will be called once a live event matches a push rule. + */ +- (void)listenToNotifications:(MXOnNotification)onNotification; + +/** + Unregister the listener. + */ +- (void)removeNotificationListener; + +/** + Update the listener to ignore or restore notifications from a specific room. + + @param roomID the id of the concerned room. + @param isIgnored YES to disable notifications from the specified room. NO to restore them. + */ +- (void)updateNotificationListenerForRoomId:(NSString*)roomID ignore:(BOOL)isIgnored; + +#pragma mark - Crypto +/** + Delete the device id. + + Call this method when the current device id cannot be used anymore. + */ +- (void)resetDeviceId; + +#pragma mark - Sync filter +/** + Check if the homeserver supports room members lazy loading. + @param completion the check result. + */ +- (void)supportLazyLoadOfRoomMembers:(void (^)(BOOL supportLazyLoadOfRoomMembers))completion; + +/** + Call this method at an appropriate time to attempt dehydrating to a new backup device + */ +- (void)attemptDeviceDehydrationWithKeyData:(NSData *)keyData + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m new file mode 100644 index 000000000..c30867251 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m @@ -0,0 +1,2228 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAccount.h" + +#import "MXKAccountManager.h" +#import "MXKRoomDataSourceManager.h" +#import "MXKEventFormatter.h" + +#import "MXKTools.h" +#import "MXKContactManager.h" + +#import "MXKConstants.h" + +#import "NSBundle+MatrixKit.h" + +#import + +#import + +#import "MXKSwiftHeader.h" + +NSString *const kMXKAccountUserInfoDidChangeNotification = @"kMXKAccountUserInfoDidChangeNotification"; +NSString *const kMXKAccountAPNSActivityDidChangeNotification = @"kMXKAccountAPNSActivityDidChangeNotification"; +NSString *const kMXKAccountPushKitActivityDidChangeNotification = @"kMXKAccountPushKitActivityDidChangeNotification"; + +NSString *const kMXKAccountErrorDomain = @"kMXKAccountErrorDomain"; + +static MXKAccountOnCertificateChange _onCertificateChangeBlock; +/** + HTTP status codes for error cases on initial sync requests, for which errors will not be propagated to the client. + */ +static NSArray *initialSyncSilentErrorsHTTPStatusCodes; + +@interface MXKAccount () +{ + // We will notify user only once on session failure + BOOL notifyOpenSessionFailure; + + // The timer used to postpone server sync on failure + NSTimer* initialServerSyncTimer; + + // Reachability observer + id reachabilityObserver; + + // Session state observer + id sessionStateObserver; + + // Handle user's settings change + id userUpdateListener; + + // Used for logging application start up + NSDate *openSessionStartDate; + + // Event notifications listener + id notificationCenterListener; + + // Internal list of ignored rooms + NSMutableArray* ignoredRooms; + + // If a server sync is in progress, the pause is delayed at the end of sync (except if resume is called). + BOOL isPauseRequested; + + // Background sync management + MXOnBackgroundSyncDone backgroundSyncDone; + MXOnBackgroundSyncFail backgroundSyncFails; + NSTimer* backgroundSyncTimer; + + // Observe UIApplicationSignificantTimeChangeNotification to refresh MXRoomSummaries on time formatting change. + id UIApplicationSignificantTimeChangeNotificationObserver; + + // Observe NSCurrentLocaleDidChangeNotification to refresh MXRoomSummaries on time formatting change. + id NSCurrentLocaleDidChangeNotificationObserver; +} + +@property (nonatomic, strong) id backgroundTask; +@property (nonatomic, strong) id backgroundSyncBgTask; + +@property (nonatomic, strong) NSMutableDictionary> *others; + +@end + +@implementation MXKAccount +@synthesize mxCredentials, mxSession, mxRestClient; +@synthesize threePIDs; +@synthesize userPresence; +@synthesize userTintColor; +@synthesize hideUserPresence; + ++ (void)load +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + initialSyncSilentErrorsHTTPStatusCodes = @[ + @(504), + @(522), + @(524), + @(599) + ]; + }); +} + ++ (void)registerOnCertificateChangeBlock:(MXKAccountOnCertificateChange)onCertificateChangeBlock +{ + _onCertificateChangeBlock = onCertificateChangeBlock; +} + ++ (UIColor*)presenceColor:(MXPresence)presence +{ + switch (presence) + { + case MXPresenceOnline: + return [[MXKAppSettings standardAppSettings] presenceColorForOnlineUser]; + case MXPresenceUnavailable: + return [[MXKAppSettings standardAppSettings] presenceColorForUnavailableUser]; + case MXPresenceOffline: + return [[MXKAppSettings standardAppSettings] presenceColorForOfflineUser]; + case MXPresenceUnknown: + default: + return nil; + } +} + +- (instancetype)initWithCredentials:(MXCredentials*)credentials +{ + if (self = [super init]) + { + notifyOpenSessionFailure = YES; + + // Report credentials and alloc REST client. + mxCredentials = credentials; + [self prepareRESTClient]; + + userPresence = MXPresenceUnknown; + + // Refresh device information + [self loadDeviceInformation:nil failure:nil]; + + [self registerAccountDataDidChangeIdentityServerNotification]; + [self registerIdentityServiceDidChangeAccessTokenNotification]; + } + return self; +} + +- (void)dealloc +{ + [self closeSession:NO]; + mxSession = nil; + + [mxRestClient close]; + mxRestClient = nil; +} + +#pragma mark - NSCoding + +- (id)initWithCoder:(NSCoder *)coder +{ + self = [super init]; + + if (self) + { + notifyOpenSessionFailure = YES; + + NSString *homeServerURL = [coder decodeObjectForKey:@"homeserverurl"]; + NSString *userId = [coder decodeObjectForKey:@"userid"]; + NSString *accessToken = [coder decodeObjectForKey:@"accesstoken"]; + _identityServerURL = [coder decodeObjectForKey:@"identityserverurl"]; + NSString *identityServerAccessToken = [coder decodeObjectForKey:@"identityserveraccesstoken"]; + + mxCredentials = [[MXCredentials alloc] initWithHomeServer:homeServerURL + userId:userId + accessToken:accessToken]; + + mxCredentials.identityServer = _identityServerURL; + mxCredentials.identityServerAccessToken = identityServerAccessToken; + mxCredentials.deviceId = [coder decodeObjectForKey:@"deviceId"]; + mxCredentials.allowedCertificate = [coder decodeObjectForKey:@"allowedCertificate"]; + + [self prepareRESTClient]; + + [self registerAccountDataDidChangeIdentityServerNotification]; + [self registerIdentityServiceDidChangeAccessTokenNotification]; + + if ([coder decodeObjectForKey:@"threePIDs"]) + { + threePIDs = [coder decodeObjectForKey:@"threePIDs"]; + } + + if ([coder decodeObjectForKey:@"device"]) + { + _device = [coder decodeObjectForKey:@"device"]; + } + + userPresence = MXPresenceUnknown; + + if ([coder decodeObjectForKey:@"antivirusserverurl"]) + { + _antivirusServerURL = [coder decodeObjectForKey:@"antivirusserverurl"]; + } + + if ([coder decodeObjectForKey:@"pushgatewayurl"]) + { + _pushGatewayURL = [coder decodeObjectForKey:@"pushgatewayurl"]; + } + + _hasPusherForPushNotifications = [coder decodeBoolForKey:@"_enablePushNotifications"]; + _hasPusherForPushKitNotifications = [coder decodeBoolForKey:@"enablePushKitNotifications"]; + _enableInAppNotifications = [coder decodeBoolForKey:@"enableInAppNotifications"]; + + _disabled = [coder decodeBoolForKey:@"disabled"]; + _isSoftLogout = [coder decodeBoolForKey:@"isSoftLogout"]; + + _warnedAboutEncryption = [coder decodeBoolForKey:@"warnedAboutEncryption"]; + + _others = [coder decodeObjectForKey:@"others"]; + + // Refresh device information + [self loadDeviceInformation:nil failure:nil]; + } + + return self; +} + +- (void)encodeWithCoder:(NSCoder *)coder +{ + [coder encodeObject:mxCredentials.homeServer forKey:@"homeserverurl"]; + [coder encodeObject:mxCredentials.userId forKey:@"userid"]; + [coder encodeObject:mxCredentials.accessToken forKey:@"accesstoken"]; + [coder encodeObject:mxCredentials.identityServerAccessToken forKey:@"identityserveraccesstoken"]; + + if (mxCredentials.deviceId) + { + [coder encodeObject:mxCredentials.deviceId forKey:@"deviceId"]; + } + + if (mxCredentials.allowedCertificate) + { + [coder encodeObject:mxCredentials.allowedCertificate forKey:@"allowedCertificate"]; + } + + if (self.threePIDs) + { + [coder encodeObject:threePIDs forKey:@"threePIDs"]; + } + + if (self.device) + { + [coder encodeObject:_device forKey:@"device"]; + } + + if (self.identityServerURL) + { + [coder encodeObject:_identityServerURL forKey:@"identityserverurl"]; + } + + if (self.antivirusServerURL) + { + [coder encodeObject:_antivirusServerURL forKey:@"antivirusserverurl"]; + } + + if (self.pushGatewayURL) + { + [coder encodeObject:_pushGatewayURL forKey:@"pushgatewayurl"]; + } + + [coder encodeBool:_hasPusherForPushNotifications forKey:@"_enablePushNotifications"]; + [coder encodeBool:_hasPusherForPushKitNotifications forKey:@"enablePushKitNotifications"]; + [coder encodeBool:_enableInAppNotifications forKey:@"enableInAppNotifications"]; + + [coder encodeBool:_disabled forKey:@"disabled"]; + [coder encodeBool:_isSoftLogout forKey:@"isSoftLogout"]; + + [coder encodeBool:_warnedAboutEncryption forKey:@"warnedAboutEncryption"]; + + [coder encodeObject:_others forKey:@"others"]; +} + +#pragma mark - Properties + +- (void)setIdentityServerURL:(NSString *)identityServerURL +{ + if (identityServerURL.length) + { + _identityServerURL = identityServerURL; + mxCredentials.identityServer = identityServerURL; + + // Update services used in MXSession + [mxSession setIdentityServer:mxCredentials.identityServer andAccessToken:mxCredentials.identityServerAccessToken]; + } + else + { + _identityServerURL = nil; + [mxSession setIdentityServer:nil andAccessToken:nil]; + } + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; +} + +- (void)setAntivirusServerURL:(NSString *)antivirusServerURL +{ + _antivirusServerURL = antivirusServerURL; + // Update the current session if any + [mxSession setAntivirusServerURL:antivirusServerURL]; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; +} + +- (void)setPushGatewayURL:(NSString *)pushGatewayURL +{ + _pushGatewayURL = pushGatewayURL.length ? pushGatewayURL : nil; + + MXLogDebug(@"[MXKAccount][Push] setPushGatewayURL: %@", _pushGatewayURL); + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; +} + +- (NSString*)userDisplayName +{ + if (mxSession) + { + return mxSession.myUser.displayname; + } + return nil; +} + +- (NSString*)userAvatarUrl +{ + if (mxSession) + { + return mxSession.myUser.avatarUrl; + } + return nil; +} + +- (NSString*)fullDisplayName +{ + if (self.userDisplayName.length) + { + return [NSString stringWithFormat:@"%@ (%@)", self.userDisplayName, mxCredentials.userId]; + } + else + { + return mxCredentials.userId; + } +} + +- (NSArray *)threePIDs +{ + return threePIDs; +} + +- (NSArray *)linkedEmails +{ + NSMutableArray *linkedEmails = [NSMutableArray array]; + + for (MXThirdPartyIdentifier *threePID in threePIDs) + { + if ([threePID.medium isEqualToString:kMX3PIDMediumEmail]) + { + [linkedEmails addObject:threePID.address]; + } + } + + return linkedEmails; +} + +- (NSArray *)linkedPhoneNumbers +{ + NSMutableArray *linkedPhoneNumbers = [NSMutableArray array]; + + for (MXThirdPartyIdentifier *threePID in threePIDs) + { + if ([threePID.medium isEqualToString:kMX3PIDMediumMSISDN]) + { + [linkedPhoneNumbers addObject:threePID.address]; + } + } + + return linkedPhoneNumbers; +} + +- (UIColor*)userTintColor +{ + if (!userTintColor) + { + userTintColor = [MXKTools colorWithRGBValue:[mxCredentials.userId hash]]; + } + + return userTintColor; +} + +- (BOOL)pushNotificationServiceIsActive +{ + BOOL pushNotificationServiceIsActive = ([[MXKAccountManager sharedManager] isAPNSAvailable] && _hasPusherForPushNotifications && mxSession); + MXLogDebug(@"[MXKAccount][Push] pushNotificationServiceIsActive: %@", @(pushNotificationServiceIsActive)); + + return pushNotificationServiceIsActive; +} + +- (void)enablePushNotifications:(BOOL)enable + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: %@", @(enable)); + + if (enable) + { + if ([[MXKAccountManager sharedManager] isAPNSAvailable]) + { + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Enable Push for %@ account", self.mxCredentials.userId); + + // Create/restore the pusher + [self enableAPNSPusher:YES success:^{ + + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Enable Push: Success"); + if (success) + { + success(); + } + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Enable Push: Error: %@", error); + if (failure) + { + failure(error); + } + }]; + } + else + { + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Error: Cannot enable Push"); + + NSError *error = [NSError errorWithDomain:kMXKAccountErrorDomain + code:0 + userInfo:@{ + NSLocalizedDescriptionKey: + [MatrixKitL10n accountErrorPushNotAllowed] + }]; + if (failure) + { + failure (error); + } + } + } + else if (_hasPusherForPushNotifications) + { + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Disable APNS for %@ account", self.mxCredentials.userId); + + // Delete the pusher, report the new value only on success. + [self enableAPNSPusher:NO + success:^{ + + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Disable Push: Success"); + if (success) + { + success(); + } + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Disable Push: Error: %@", error); + if (failure) + { + failure(error); + } + }]; + } +} + +- (BOOL)isPushKitNotificationActive +{ + BOOL isPushKitNotificationActive = ([[MXKAccountManager sharedManager] isPushAvailable] && _hasPusherForPushKitNotifications && mxSession); + MXLogDebug(@"[MXKAccount][Push] isPushKitNotificationActive: %@", @(isPushKitNotificationActive)); + + return isPushKitNotificationActive; +} + +- (void)enablePushKitNotifications:(BOOL)enable + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: %@", @(enable)); + + if (enable) + { + if ([[MXKAccountManager sharedManager] isPushAvailable]) + { + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: Enable Push for %@ account", self.mxCredentials.userId); + + // Create/restore the pusher + [self enablePushKitPusher:YES success:^{ + + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: Enable Push: Success"); + if (success) + { + success(); + } + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: Enable Push: Error: %@", error); + if (failure) + { + failure(error); + } + }]; + } + else + { + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: Error: Cannot enable Push"); + + NSError *error = [NSError errorWithDomain:kMXKAccountErrorDomain + code:0 + userInfo:@{ + NSLocalizedDescriptionKey: + [MatrixKitL10n accountErrorPushNotAllowed] + }]; + failure (error); + } + } + else if (_hasPusherForPushKitNotifications) + { + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: Disable Push for %@ account", self.mxCredentials.userId); + + // Delete the pusher, report the new value only on success. + [self enablePushKitPusher:NO success:^{ + + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: Disable Push: Success"); + if (success) + { + success(); + } + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: Disable Push: Error: %@", error); + if (failure) + { + failure(error); + } + }]; + } + else + { + MXLogDebug(@"[MXKAccount][Push] enablePushKitNotifications: PushKit is already disabled for %@", self.mxCredentials.userId); + if (success) + { + success(); + } + } +} + +- (void)setEnableInAppNotifications:(BOOL)enableInAppNotifications +{ + MXLogDebug(@"[MXKAccount] setEnableInAppNotifications: %@", @(enableInAppNotifications)); + + _enableInAppNotifications = enableInAppNotifications; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; +} + +- (void)setDisabled:(BOOL)disabled +{ + if (_disabled != disabled) + { + _disabled = disabled; + + if (_disabled) + { + [self deletePusher]; + [self enablePushKitNotifications:NO success:nil failure:nil]; + + // Close session (keep the storage). + [self closeSession:NO]; + } + else if (!mxSession) + { + // Open a new matrix session + id store = [[[MXKAccountManager sharedManager].storeClass alloc] init]; + + [self openSessionWithStore:store]; + } + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; + } +} + +- (void)setWarnedAboutEncryption:(BOOL)warnedAboutEncryption +{ + _warnedAboutEncryption = warnedAboutEncryption; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; +} + +- (NSMutableDictionary> *)others +{ + if(_others == nil) + { + _others = [NSMutableDictionary dictionary]; + } + + return _others; +} + +#pragma mark - Matrix user's profile + +- (void)setUserDisplayName:(NSString*)displayname success:(void (^)(void))success failure:(void (^)(NSError *error))failure +{ + if (mxSession && mxSession.myUser) + { + [mxSession.myUser setDisplayName:displayname + success:^{ + if (success) { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountUserInfoDidChangeNotification object:self->mxCredentials.userId]; + } + failure:failure]; + } + else if (failure) + { + failure ([NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey: [MatrixKitL10n accountErrorMatrixSessionIsNotOpened]}]); + } +} + +- (void)setUserAvatarUrl:(NSString*)avatarUrl success:(void (^)(void))success failure:(void (^)(NSError *error))failure +{ + if (mxSession && mxSession.myUser) + { + [mxSession.myUser setAvatarUrl:avatarUrl + success:^{ + if (success) { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountUserInfoDidChangeNotification object:self->mxCredentials.userId]; + } + failure:failure]; + } + else if (failure) + { + failure ([NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey: [MatrixKitL10n accountErrorMatrixSessionIsNotOpened]}]); + } +} + +- (void)changePassword:(NSString*)oldPassword with:(NSString*)newPassword success:(void (^)(void))success failure:(void (^)(NSError *error))failure +{ + if (mxSession) + { + [mxRestClient changePassword:oldPassword + with:newPassword + success:^{ + + if (success) { + success(); + } + + } + failure:failure]; + } + else if (failure) + { + failure ([NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey: [MatrixKitL10n accountErrorMatrixSessionIsNotOpened]}]); + } +} + +- (void)load3PIDs:(void (^)(void))success failure:(void (^)(NSError *))failure +{ + [mxRestClient threePIDs:^(NSArray *threePIDs2) { + + self->threePIDs = threePIDs2; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; + + if (success) + { + success(); + } + + } failure:^(NSError *error) { + if (failure) + { + failure(error); + } + }]; +} + +- (void)loadDeviceInformation:(void (^)(void))success failure:(void (^)(NSError *error))failure +{ + if (mxCredentials.deviceId) + { + [mxRestClient deviceByDeviceId:mxCredentials.deviceId success:^(MXDevice *device) { + + self->_device = device; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; + + if (success) + { + success(); + } + + } failure:^(NSError *error) { + + if (failure) + { + failure(error); + } + + }]; + } + else + { + _device = nil; + if (success) + { + success(); + } + } +} + +- (void)setUserPresence:(MXPresence)presence andStatusMessage:(NSString *)statusMessage completion:(void (^)(void))completion +{ + userPresence = presence; + + if (mxSession && !hideUserPresence) + { + // Update user presence on server side + [mxSession.myUser setPresence:userPresence + andStatusMessage:statusMessage + success:^{ + MXLogDebug(@"[MXKAccount] %@: set user presence (%lu) succeeded", self->mxCredentials.userId, (unsigned long)self->userPresence); + if (completion) + { + completion(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountUserInfoDidChangeNotification object:self->mxCredentials.userId]; + } + failure:^(NSError *error) { + MXLogDebug(@"[MXKAccount] %@: set user presence (%lu) failed", self->mxCredentials.userId, (unsigned long)self->userPresence); + }]; + } + else if (hideUserPresence) + { + MXLogDebug(@"[MXKAccount] %@: set user presence is disabled.", mxCredentials.userId); + } +} + +#pragma mark - + +/** + Create a matrix session based on the provided store. + When store data is ready, the live stream is automatically launched by synchronising the session with the server. + + In case of failure during server sync, the method is reiterated until the data is up-to-date with the server. + This loop is stopped if you call [MXCAccount closeSession:], it is suspended if you call [MXCAccount pauseInBackgroundTask]. + + @param store the store to use for the session. + */ +-(void)openSessionWithStore:(id)store +{ + // Sanity check + if (!mxCredentials || !mxRestClient) + { + MXLogDebug(@"[MXKAccount] Matrix session cannot be created without credentials"); + return; + } + + // Close potential session (keep associated store). + [self closeSession:NO]; + + openSessionStartDate = [NSDate date]; + + // Instantiate new session + mxSession = [[MXSession alloc] initWithMatrixRestClient:mxRestClient]; + + // Check whether an antivirus url is defined. + if (_antivirusServerURL) + { + // Enable the antivirus scanner in the current session. + [mxSession setAntivirusServerURL:_antivirusServerURL]; + } + + // Set default MXEvent -> NSString formatter + MXKEventFormatter *eventFormatter = [[MXKEventFormatter alloc] initWithMatrixSession:self.mxSession]; + eventFormatter.isForSubtitle = YES; + + // Apply the event types filter to display only the wanted event types. + eventFormatter.eventTypesFilterForMessages = [MXKAppSettings standardAppSettings].eventsFilterForMessages; + + mxSession.roomSummaryUpdateDelegate = eventFormatter; + + // Observe UIApplicationSignificantTimeChangeNotification to refresh to MXRoomSummaries if date/time are shown. + // UIApplicationSignificantTimeChangeNotification is posted if DST is updated, carrier time is updated + UIApplicationSignificantTimeChangeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationSignificantTimeChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + [self onDateTimeFormatUpdate]; + }]; + + + // Observe NSCurrentLocaleDidChangeNotification to refresh MXRoomSummaries if date/time are shown. + // NSCurrentLocaleDidChangeNotification is triggered when the time swicthes to AM/PM to 24h time format + NSCurrentLocaleDidChangeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:NSCurrentLocaleDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + [self onDateTimeFormatUpdate]; + }]; + + // Force a date refresh for all the last messages. + [self onDateTimeFormatUpdate]; + + // Register session state observer + sessionStateObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionStateDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + // Check whether the concerned session is the associated one + if (notif.object == self->mxSession) + { + [self onMatrixSessionStateChange]; + } + }]; + + MXWeakify(self); + + [mxSession setStore:store success:^{ + + // Complete session registration by launching live stream + MXStrongifyAndReturnIfNil(self); + + // Validate the availability of local contact sync for any changes to the + // authorization of contacts access that may have occurred since the last launch. + // The session is passed in as the contacts manager may not have had a session added yet. + [MXKContactManager.sharedManager validateSyncLocalContactsStateForSession:self.mxSession]; + + // Refresh pusher state + [self refreshAPNSPusher]; + [self refreshPushKitPusher]; + + // Launch server sync + [self launchInitialServerSync]; + + } failure:^(NSError *error) { + + // This cannot happen. Loading of MXFileStore cannot fail. + MXStrongifyAndReturnIfNil(self); + self->mxSession = nil; + + [[NSNotificationCenter defaultCenter] removeObserver:self->sessionStateObserver]; + self->sessionStateObserver = nil; + + }]; +} + +/** + Close the matrix session. + + @param clearStore set YES to delete all store data. + */ +- (void)closeSession:(BOOL)clearStore +{ + MXLogDebug(@"[MXKAccount] closeSession (%u)", clearStore); + + if (NSCurrentLocaleDidChangeNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:NSCurrentLocaleDidChangeNotificationObserver]; + NSCurrentLocaleDidChangeNotificationObserver = nil; + } + + if (UIApplicationSignificantTimeChangeNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:UIApplicationSignificantTimeChangeNotificationObserver]; + UIApplicationSignificantTimeChangeNotificationObserver = nil; + } + + [self removeNotificationListener]; + + if (reachabilityObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:reachabilityObserver]; + reachabilityObserver = nil; + } + + if (sessionStateObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:sessionStateObserver]; + sessionStateObserver = nil; + } + + [initialServerSyncTimer invalidate]; + initialServerSyncTimer = nil; + + if (userUpdateListener) + { + [mxSession.myUser removeListener:userUpdateListener]; + userUpdateListener = nil; + } + + if (mxSession) + { + // Reset room data stored in memory + [MXKRoomDataSourceManager removeSharedManagerForMatrixSession:mxSession]; + + if (clearStore) + { + // Force a reload of device keys at the next session start. + // This will fix potential UISIs other peoples receive for our messages. + [mxSession.crypto resetDeviceKeys]; + + // Clean other stores + [mxSession.scanManager deleteAllAntivirusScans]; + [mxSession.aggregations resetData]; + } + else + { + // For recomputing of room summaries as they are a cache of computed data + [mxSession resetRoomsSummariesLastMessage]; + } + + // Close session + [mxSession close]; + + if (clearStore) + { + [mxSession.store deleteAllData]; + } + + mxSession = nil; + } + + notifyOpenSessionFailure = YES; +} + +- (void)logout:(void (^)(void))completion +{ + if (!mxSession) + { + MXLogDebug(@"[MXKAccount] logout: Need to open the closed session to make a logout request"); + id store = [[[MXKAccountManager sharedManager].storeClass alloc] init]; + mxSession = [[MXSession alloc] initWithMatrixRestClient:mxRestClient]; + + MXWeakify(self); + [mxSession setStore:store success:^{ + MXStrongifyAndReturnIfNil(self); + + [self logout:completion]; + + } failure:^(NSError *error) { + completion(); + }]; + return; + } + + [self deletePusher]; + [self enablePushKitNotifications:NO success:nil failure:nil]; + + MXHTTPOperation *operation = [mxSession logout:^{ + + [self closeSession:YES]; + if (completion) + { + completion(); + } + + } failure:^(NSError *error) { + + // Close the session even if the logout request failed + [self closeSession:YES]; + if (completion) + { + completion(); + } + + }]; + + // Do not retry on failure. + operation.maxNumberOfTries = 1; +} + +// Logout locally, do not send server request +- (void)logoutLocally:(void (^)(void))completion +{ + [self deletePusher]; + [self enablePushKitNotifications:NO success:nil failure:nil]; + + [mxSession enableCrypto:NO success:^{ + [self closeSession:YES]; + if (completion) + { + completion(); + } + + } failure:^(NSError *error) { + + // Close the session even if the logout request failed + [self closeSession:YES]; + if (completion) + { + completion(); + } + + }]; +} + +- (void)logoutSendingServerRequest:(BOOL)sendLogoutServerRequest + completion:(void (^)(void))completion +{ + if (sendLogoutServerRequest) + { + [self logout:completion]; + } + else + { + [self logoutLocally:completion]; + } +} + + +#pragma mark - Soft logout + +- (void)softLogout +{ + _isSoftLogout = YES; + [[MXKAccountManager sharedManager] saveAccounts]; + + // Stop SDK making requests to the homeserver + [mxSession close]; +} + +- (void)hydrateWithCredentials:(MXCredentials*)credentials +{ + // Sanity check + if ([mxCredentials.userId isEqualToString:credentials.userId]) + { + mxCredentials = credentials; + _isSoftLogout = NO; + [[MXKAccountManager sharedManager] saveAccounts]; + + [self prepareRESTClient]; + } + else + { + MXLogDebug(@"[MXKAccount] hydrateWithCredentials: Error: users ids mismatch: %@ vs %@", credentials.userId, mxCredentials.userId); + } +} + + +- (void)deletePusher +{ + if (self.pushNotificationServiceIsActive) + { + [self enableAPNSPusher:NO success:nil failure:nil]; + } +} + +- (void)pauseInBackgroundTask +{ + // Reset internal flag + isPauseRequested = NO; + + if (mxSession && mxSession.isPauseable) + { + id handler = [MXSDKOptions sharedInstance].backgroundModeHandler; + if (handler) + { + if (!self.backgroundTask.isRunning) + { + self.backgroundTask = [handler startBackgroundTaskWithName:@"[MXKAccount] pauseInBackgroundTask" expirationHandler:nil]; + } + } + + // Pause SDK + [mxSession pause]; + + // Update user presence + __weak typeof(self) weakSelf = self; + [self setUserPresence:MXPresenceUnavailable andStatusMessage:nil completion:^{ + + if (weakSelf) + { + typeof(self) self = weakSelf; + + if (self.backgroundTask.isRunning) + { + [self.backgroundTask stop]; + self.backgroundTask = nil; + } + } + + }]; + } + else + { + // Cancel pending actions + [[NSNotificationCenter defaultCenter] removeObserver:reachabilityObserver]; + reachabilityObserver = nil; + [initialServerSyncTimer invalidate]; + initialServerSyncTimer = nil; + + if (mxSession.state == MXSessionStateSyncInProgress || mxSession.state == MXSessionStateInitialised || mxSession.state == MXSessionStateStoreDataReady) + { + // Make sure the SDK finish its work before the app goes sleeping in background + id handler = [MXSDKOptions sharedInstance].backgroundModeHandler; + if (handler) + { + if (!self.backgroundTask.isRunning) + { + self.backgroundTask = [handler startBackgroundTaskWithName:@"[MXKAccount] pauseInBackgroundTask" expirationHandler:nil]; + } + } + + MXLogDebug(@"[MXKAccount] Pause is delayed at the end of sync (current state %tu)", mxSession.state); + isPauseRequested = YES; + } + } +} + +- (void)resume +{ + isPauseRequested = NO; + + if (mxSession) + { + MXLogVerbose(@"[MXKAccount] resume with session state: %tu", mxSession.state); + + [self cancelBackgroundSync]; + + if (mxSession.state == MXSessionStatePaused || mxSession.state == MXSessionStatePauseRequested) + { + // Resume SDK and update user presence + [mxSession resume:^{ + [self setUserPresence:MXPresenceOnline andStatusMessage:nil completion:nil]; + + [self refreshAPNSPusher]; + [self refreshPushKitPusher]; + }]; + } + else if (mxSession.state == MXSessionStateStoreDataReady || mxSession.state == MXSessionStateInitialSyncFailed) + { + // The session initialisation was uncompleted, we try to complete it here. + [self launchInitialServerSync]; + + [self refreshAPNSPusher]; + [self refreshPushKitPusher]; + } + else if (mxSession.state == MXSessionStateSyncInProgress) + { + [self refreshAPNSPusher]; + [self refreshPushKitPusher]; + } + + // Cancel background task + if (self.backgroundTask.isRunning) + { + [self.backgroundTask stop]; + self.backgroundTask = nil; + } + } +} + +- (void)reload:(BOOL)clearCache +{ + // close potential session + [self closeSession:clearCache]; + + if (!_disabled) + { + // Open a new matrix session + id store = [[[MXKAccountManager sharedManager].storeClass alloc] init]; + [self openSessionWithStore:store]; + } +} + +#pragma mark - Push notifications + +// Refresh the APNS pusher state for this account on this device. +- (void)refreshAPNSPusher +{ + MXLogDebug(@"[MXKAccount][Push] refreshAPNSPusher"); + + // Check the conditions required to run the pusher + if (self.pushNotificationServiceIsActive) + { + MXLogDebug(@"[MXKAccount][Push] refreshAPNSPusher: Refresh APNS pusher for %@ account", self.mxCredentials.userId); + + // Create/restore the pusher + [self enableAPNSPusher:YES + success:nil + failure:^(NSError *error) { + MXLogDebug(@"[MXKAccount][Push] ;: Error: %@", error); + }]; + } + else if (_hasPusherForPushNotifications) + { + if ([MXKAccountManager sharedManager].apnsDeviceToken) + { + if (mxSession) + { + // Turn off pusher if user denied remote notification. + MXLogDebug(@"[MXKAccount][Push] refreshAPNSPusher: Disable APNS pusher for %@ account (notifications are denied)", self.mxCredentials.userId); + [self enableAPNSPusher:NO success:nil failure:nil]; + } + } + else + { + MXLogDebug(@"[MXKAccount][Push] refreshAPNSPusher: APNS pusher for %@ account is already disabled. Reset _hasPusherForPushNotifications", self.mxCredentials.userId); + _hasPusherForPushNotifications = NO; + [[MXKAccountManager sharedManager] saveAccounts]; + } + } +} + +// Enable/Disable the APNS pusher for this account on this device on the homeserver. +- (void)enableAPNSPusher:(BOOL)enabled success:(void (^)(void))success failure:(void (^)(NSError *))failure +{ + MXLogDebug(@"[MXKAccount][Push] enableAPNSPusher: %@", @(enabled)); + +#ifdef DEBUG + NSString *appId = [[NSUserDefaults standardUserDefaults] objectForKey:@"pusherAppIdDev"]; +#else + NSString *appId = [[NSUserDefaults standardUserDefaults] objectForKey:@"pusherAppIdProd"]; +#endif + + NSString *locKey = MXKAppSettings.standardAppSettings.notificationBodyLocalizationKey; + + NSDictionary *pushData = @{ + @"url": self.pushGatewayURL, + @"format": @"event_id_only", + @"default_payload": @{@"aps": @{@"mutable-content": @(1), @"alert": @{@"loc-key": locKey, @"loc-args": @[]}}} + }; + + [self enablePusher:enabled appId:appId token:[MXKAccountManager sharedManager].apnsDeviceToken pushData:pushData success:^{ + + MXLogDebug(@"[MXKAccount][Push] enableAPNSPusher: Succeeded to update APNS pusher for %@ (%d)", self.mxCredentials.userId, enabled); + + self->_hasPusherForPushNotifications = enabled; + [[MXKAccountManager sharedManager] saveAccounts]; + + if (success) + { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountAPNSActivityDidChangeNotification object:self->mxCredentials.userId]; + + } failure:^(NSError *error) { + + // Ignore error if the client try to disable an unknown token + if (!enabled) + { + // Check whether the token was unknown + MXError *mxError = [[MXError alloc] initWithNSError:error]; + if (mxError && [mxError.errcode isEqualToString:kMXErrCodeStringUnknown]) + { + MXLogDebug(@"[MXKAccount][Push] enableAPNSPusher: APNS was already disabled for %@!", self.mxCredentials.userId); + + // Ignore the error + if (success) + { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountAPNSActivityDidChangeNotification object:self->mxCredentials.userId]; + + return; + } + + MXLogDebug(@"[MXKAccount][Push] enableAPNSPusher: Failed to disable APNS %@! (%@)", self.mxCredentials.userId, error); + } + else + { + MXLogDebug(@"[MXKAccount][Push] enableAPNSPusher: Failed to send APNS token for %@! (%@)", self.mxCredentials.userId, error); + } + + if (failure) + { + failure(error); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountAPNSActivityDidChangeNotification object:self->mxCredentials.userId]; + }]; +} + +// Refresh the PushKit pusher state for this account on this device. +- (void)refreshPushKitPusher +{ + MXLogDebug(@"[MXKAccount][Push] refreshPushKitPusher"); + + // Check the conditions required to run the pusher + if (![MXKAppSettings standardAppSettings].allowPushKitPushers) + { + // Turn off pusher if PushKit pushers are not allowed + MXLogDebug(@"[MXKAccount][Push] refreshPushKitPusher: Disable PushKit pusher for %@ account (pushers are not allowed)", self.mxCredentials.userId); + [self enablePushKitPusher:NO success:nil failure:nil]; + } + else if (self.isPushKitNotificationActive) + { + MXLogDebug(@"[MXKAccount][Push] refreshPushKitPusher: Refresh PushKit pusher for %@ account", self.mxCredentials.userId); + + // Create/restore the pusher + [self enablePushKitPusher:YES + success:nil + failure:^(NSError *error) { + MXLogDebug(@"[MXKAccount][Push] refreshPushKitPusher: Error: %@", error); + }]; + } + else if (_hasPusherForPushKitNotifications) + { + if ([MXKAccountManager sharedManager].pushDeviceToken) + { + if (mxSession) + { + // Turn off pusher if user denied remote notification. + MXLogDebug(@"[MXKAccount][Push] refreshPushKitPusher: Disable PushKit pusher for %@ account (notifications are denied)", self.mxCredentials.userId); + [self enablePushKitPusher:NO success:nil failure:nil]; + } + } + else + { + MXLogDebug(@"[MXKAccount][Push] refreshPushKitPusher: PushKit pusher for %@ account is already disabled. Reset _hasPusherForPushKitNotifications", self.mxCredentials.userId); + _hasPusherForPushKitNotifications = NO; + [[MXKAccountManager sharedManager] saveAccounts]; + } + } +} + +// Enable/Disable the pusher based on PushKit for this account on this device on the homeserver. +- (void)enablePushKitPusher:(BOOL)enabled success:(void (^)(void))success failure:(void (^)(NSError *))failure +{ + MXLogDebug(@"[MXKAccount][Push] enablePushKitPusher: %@", @(enabled)); + + if (enabled && ![MXKAppSettings standardAppSettings].allowPushKitPushers) + { + // sanity check, if accidently try to enable the pusher + MXLogDebug(@"[MXKAccount][Push] enablePushKitPusher: Do not enable it because PushKit pushers not allowed"); + if (failure) + { + failure([NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:nil]); + } + return; + } + + NSString *appIdKey; + #ifdef DEBUG + appIdKey = @"pushKitAppIdDev"; + #else + appIdKey = @"pushKitAppIdProd"; + #endif + + NSString *appId = [[NSUserDefaults standardUserDefaults] objectForKey:appIdKey]; + + NSMutableDictionary *pushData = [NSMutableDictionary dictionaryWithDictionary:@{@"url": self.pushGatewayURL}]; + + NSDictionary *options = [MXKAccountManager sharedManager].pushOptions; + if (options.count) + { + [pushData addEntriesFromDictionary:options]; + } + + NSData *token = [MXKAccountManager sharedManager].pushDeviceToken; + if (!token) + { + // sanity check, if no token there is no point of calling the endpoint + MXLogDebug(@"[MXKAccount][Push] enablePushKitPusher: Failed to update PushKit pusher to %@ for %@. (token is missing)", @(enabled), self.mxCredentials.userId); + if (failure) + { + failure([NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:nil]); + } + return; + } + [self enablePusher:enabled appId:appId token:token pushData:pushData success:^{ + + MXLogDebug(@"[MXKAccount][Push] enablePushKitPusher: Succeeded to update PushKit pusher for %@. Enabled: %@. Token: %@", self.mxCredentials.userId, @(enabled), [MXKTools logForPushToken:token]); + + self->_hasPusherForPushKitNotifications = enabled; + [[MXKAccountManager sharedManager] saveAccounts]; + + if (success) + { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountPushKitActivityDidChangeNotification object:self->mxCredentials.userId]; + + } failure:^(NSError *error) { + + // Ignore error if the client try to disable an unknown token + if (!enabled) + { + // Check whether the token was unknown + MXError *mxError = [[MXError alloc] initWithNSError:error]; + if (mxError && [mxError.errcode isEqualToString:kMXErrCodeStringUnknown]) + { + MXLogDebug(@"[MXKAccount][Push] enablePushKitPusher: Push was already disabled for %@!", self.mxCredentials.userId); + + // Ignore the error + if (success) + { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountPushKitActivityDidChangeNotification object:self->mxCredentials.userId]; + + return; + } + + MXLogDebug(@"[MXKAccount][Push] enablePushKitPusher: Failed to disable Push %@! (%@)", self.mxCredentials.userId, error); + } + else + { + MXLogDebug(@"[MXKAccount][Push] enablePushKitPusher: Failed to send Push token for %@! (%@)", self.mxCredentials.userId, error); + } + + if (failure) + { + failure(error); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountPushKitActivityDidChangeNotification object:self->mxCredentials.userId]; + }]; +} + +- (void)enablePusher:(BOOL)enabled appId:(NSString*)appId token:(NSData*)token pushData:(NSDictionary*)pushData success:(void (^)(void))success failure:(void (^)(NSError *))failure +{ + MXLogDebug(@"[MXKAccount][Push] enablePusher: %@", @(enabled)); + + // Refuse to try & turn push on if we're not logged in, it's nonsensical. + if (!mxCredentials) + { + MXLogDebug(@"[MXKAccount][Push] enablePusher: Not setting push token because we're not logged in"); + return; + } + + // Check whether the Push Gateway URL has been configured. + if (!self.pushGatewayURL) + { + MXLogDebug(@"[MXKAccount][Push] enablePusher: Not setting pusher because the Push Gateway URL is undefined"); + return; + } + + if (!appId) + { + MXLogDebug(@"[MXKAccount][Push] enablePusher: Not setting pusher because pusher app id is undefined"); + return; + } + + NSString *appDisplayName = [NSString stringWithFormat:@"%@ (iOS)", [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleDisplayName"]]; + + NSString *b64Token = [token base64EncodedStringWithOptions:0]; + + NSString *deviceLang = [NSLocale preferredLanguages][0]; + + NSString * profileTag = [[NSUserDefaults standardUserDefaults] valueForKey:@"pusherProfileTag"]; + if (!profileTag) + { + profileTag = @""; + NSString *alphabet = @"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for (int i = 0; i < 16; ++i) + { + unsigned char c = [alphabet characterAtIndex:arc4random() % alphabet.length]; + profileTag = [profileTag stringByAppendingFormat:@"%c", c]; + } + MXLogDebug(@"[MXKAccount][Push] enablePusher: Generated fresh profile tag: %@", profileTag); + [[NSUserDefaults standardUserDefaults] setValue:profileTag forKey:@"pusherProfileTag"]; + } + else + { + MXLogDebug(@"[MXKAccount][Push] enablePusher: Using existing profile tag: %@", profileTag); + } + + NSObject *kind = enabled ? @"http" : [NSNull null]; + + // Use the append flag to handle multiple accounts registration. + BOOL append = NO; + // Check whether a pusher is running for another account + NSArray *activeAccounts = [MXKAccountManager sharedManager].activeAccounts; + for (MXKAccount *account in activeAccounts) + { + if (![account.mxCredentials.userId isEqualToString:self.mxCredentials.userId] && account.pushNotificationServiceIsActive) + { + append = YES; + break; + } + } + MXLogDebug(@"[MXKAccount][Push] enablePusher: append flag: %d", append); + + MXRestClient *restCli = self.mxRestClient; + + [restCli setPusherWithPushkey:b64Token kind:kind appId:appId appDisplayName:appDisplayName deviceDisplayName:[[UIDevice currentDevice] name] profileTag:profileTag lang:deviceLang data:pushData append:append success:success failure:failure]; +} + +#pragma mark - InApp notifications + +- (void)listenToNotifications:(MXOnNotification)onNotification +{ + // Check conditions required to add notification listener + if (!mxSession || !onNotification) + { + return; + } + + // Remove existing listener (if any) + [self removeNotificationListener]; + + // Register on notification center + notificationCenterListener = [self.mxSession.notificationCenter listenToNotifications:^(MXEvent *event, MXRoomState *roomState, MXPushRule *rule) + { + // Apply first the event filter defined in the related room data source + MXKRoomDataSourceManager *roomDataSourceManager = [MXKRoomDataSourceManager sharedManagerForMatrixSession:self->mxSession]; + [roomDataSourceManager roomDataSourceForRoom:event.roomId create:NO onComplete:^(MXKRoomDataSource *roomDataSource) { + if (roomDataSource) + { + if (!roomDataSource.eventFormatter.eventTypesFilterForMessages || [roomDataSource.eventFormatter.eventTypesFilterForMessages indexOfObject:event.type] != NSNotFound) + { + // Check conditions to report this notification + if (nil == self->ignoredRooms || [self->ignoredRooms indexOfObject:event.roomId] == NSNotFound) + { + onNotification(event, roomState, rule); + } + } + } + }]; + }]; +} + +- (void)removeNotificationListener +{ + if (notificationCenterListener) + { + [self.mxSession.notificationCenter removeListener:notificationCenterListener]; + notificationCenterListener = nil; + } + ignoredRooms = nil; +} + +- (void)updateNotificationListenerForRoomId:(NSString*)roomID ignore:(BOOL)isIgnored +{ + if (isIgnored) + { + if (!ignoredRooms) + { + ignoredRooms = [[NSMutableArray alloc] init]; + } + [ignoredRooms addObject:roomID]; + } + else if (ignoredRooms) + { + [ignoredRooms removeObject:roomID]; + } +} + +#pragma mark - Internals + +- (void)launchInitialServerSync +{ + // Complete the session registration when store data is ready. + + // Cancel potential reachability observer and pending action + [[NSNotificationCenter defaultCenter] removeObserver:reachabilityObserver]; + reachabilityObserver = nil; + [initialServerSyncTimer invalidate]; + initialServerSyncTimer = nil; + + // Sanity check + if (!mxSession || (mxSession.state != MXSessionStateStoreDataReady && mxSession.state != MXSessionStateInitialSyncFailed)) + { + MXLogDebug(@"[MXKAccount] Initial server sync is applicable only when store data is ready to complete session initialisation"); + return; + } + + // Use /sync filter corresponding to current settings and homeserver capabilities + MXWeakify(self); + [self buildSyncFilter:^(MXFilterJSONModel *syncFilter) { + MXStrongifyAndReturnIfNil(self); + + // Make sure the filter is compatible with the previously used one + MXWeakify(self); + [self checkSyncFilterCompatibility:syncFilter completion:^(BOOL compatible) { + MXStrongifyAndReturnIfNil(self); + + if (!compatible) + { + // Else clear the cache + MXLogDebug(@"[MXKAccount] New /sync filter not compatible with previous one. Clear cache"); + + [self reload:YES]; + return; + } + + // Launch mxSession + MXWeakify(self); + [self.mxSession startWithSyncFilter:syncFilter onServerSyncDone:^{ + MXStrongifyAndReturnIfNil(self); + + MXLogDebug(@"[MXKAccount] %@: The session is ready. Matrix SDK session has been started in %0.fms.", self->mxCredentials.userId, [[NSDate date] timeIntervalSinceDate:self->openSessionStartDate] * 1000); + + [self setUserPresence:MXPresenceOnline andStatusMessage:nil completion:nil]; + + } failure:^(NSError *error) { + MXStrongifyAndReturnIfNil(self); + + MXLogDebug(@"[MXKAccount] Initial Sync failed. Error: %@", error); + + BOOL isClientTimeout = [error.domain isEqualToString:NSURLErrorDomain] && error.code == NSURLErrorTimedOut; + NSHTTPURLResponse *httpResponse = [MXHTTPOperation urlResponseFromError:error]; + BOOL isServerTimeout = httpResponse && [initialSyncSilentErrorsHTTPStatusCodes containsObject:@(httpResponse.statusCode)]; + + if (isClientTimeout || isServerTimeout) + { + // do not propagate this error to the client + // the request will be retried or postponed according to the reachability status + MXLogDebug(@"[MXKAccount] Initial sync failure did not propagated"); + } + else if (self->notifyOpenSessionFailure && error) + { + // Notify MatrixKit user only once + self->notifyOpenSessionFailure = NO; + NSString *myUserId = self.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + } + + // Check if it is a network connectivity issue + AFNetworkReachabilityManager *networkReachabilityManager = [AFNetworkReachabilityManager sharedManager]; + MXLogDebug(@"[MXKAccount] Network reachability: %d", networkReachabilityManager.isReachable); + + if (networkReachabilityManager.isReachable) + { + // The problem is not the network + // Postpone a new attempt in 10 sec + self->initialServerSyncTimer = [NSTimer scheduledTimerWithTimeInterval:10 target:self selector:@selector(launchInitialServerSync) userInfo:self repeats:NO]; + } + else + { + // The device is not connected to the internet, wait for the connection to be up again before retrying + // Add observer to launch a new attempt according to reachability. + self->reachabilityObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AFNetworkingReachabilityDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) { + + NSNumber *statusItem = note.userInfo[AFNetworkingReachabilityNotificationStatusItem]; + if (statusItem) + { + AFNetworkReachabilityStatus reachabilityStatus = statusItem.integerValue; + if (reachabilityStatus == AFNetworkReachabilityStatusReachableViaWiFi || reachabilityStatus == AFNetworkReachabilityStatusReachableViaWWAN) + { + // New attempt + [self launchInitialServerSync]; + } + } + + }]; + } + }]; + }]; + }]; +} + +- (void)attemptDeviceDehydrationWithKeyData:(NSData *)keyData + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + [self attemptDeviceDehydrationWithKeyData:keyData retry:YES success:success failure:failure]; +} + +- (void)attemptDeviceDehydrationWithKeyData:(NSData *)keyData + retry:(BOOL)retry + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + if (keyData == nil) + { + MXLogWarning(@"[MXKAccount] attemptDeviceDehydrationWithRetry: no key provided for device dehydration"); + + if (failure) + { + failure(nil); + } + + return; + } + + MXLogDebug(@"[MXKAccount] attemptDeviceDehydrationWithRetry: starting device dehydration"); + [[MXKAccountManager sharedManager].dehydrationService dehydrateDeviceWithMatrixRestClient:mxRestClient crypto:mxSession.crypto dehydrationKey:keyData success:^(NSString *deviceId) { + MXLogDebug(@"[MXKAccount] attemptDeviceDehydrationWithRetry: device successfully dehydrated"); + + if (success) + { + success(); + } + } failure:^(NSError *error) { + if (retry) + { + [self attemptDeviceDehydrationWithKeyData:keyData retry:NO success:success failure:failure]; + MXLogError(@"[MXKAccount] attemptDeviceDehydrationWithRetry: device dehydration failed due to error: %@. Retrying.", error); + } + else + { + MXLogError(@"[MXKAccount] attemptDeviceDehydrationWithRetry: device dehydration failed due to error: %@", error); + + if (failure) + { + failure(error); + } + } + }]; +} + +- (void)onMatrixSessionStateChange +{ + if (mxSession.state == MXSessionStateRunning) + { + // Check if pause has been requested + if (isPauseRequested) + { + MXLogDebug(@"[MXKAccount] Apply the pending pause."); + [self pauseInBackgroundTask]; + return; + } + + // Check whether the session was not already running + if (!userUpdateListener) + { + // Register listener to user's information change + userUpdateListener = [mxSession.myUser listenToUserUpdate:^(MXEvent *event) { + // Consider events related to user's presence + if (event.eventType == MXEventTypePresence) + { + self->userPresence = [MXTools presence:event.content[@"presence"]]; + } + + // Here displayname or other information have been updated, post update notification. + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountUserInfoDidChangeNotification object:self->mxCredentials.userId]; + }]; + + // User information are just up-to-date (`mxSession` is running), post update notification. + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountUserInfoDidChangeNotification object:mxCredentials.userId]; + } + } + else if (mxSession.state == MXSessionStateStoreDataReady || mxSession.state == MXSessionStateSyncInProgress) + { + // Remove listener (if any), this action is required to handle correctly matrix sdk handler reload (see clear cache) + if (userUpdateListener) + { + [mxSession.myUser removeListener:userUpdateListener]; + userUpdateListener = nil; + } + else + { + // Here the initial server sync is in progress. The session is not running yet, but some user's information are available (from local storage). + // We post update notification to let observer take into account this user's information even if they may not be up-to-date. + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountUserInfoDidChangeNotification object:mxCredentials.userId]; + } + } + else if (mxSession.state == MXSessionStatePaused) + { + isPauseRequested = NO; + } + else if (mxSession.state == MXSessionStateUnknownToken) + { + // Logout this account + [[MXKAccountManager sharedManager] removeAccount:self completion:nil]; + } + else if (mxSession.state == MXSessionStateSoftLogout) + { + // Soft logout this account + [[MXKAccountManager sharedManager] softLogout:self]; + } +} + +- (void)prepareRESTClient +{ + if (!mxCredentials) + { + return; + } + + mxRestClient = [[MXRestClient alloc] initWithCredentials:mxCredentials andOnUnrecognizedCertificateBlock:^BOOL(NSData *certificate) { + + if (_onCertificateChangeBlock) + { + if (_onCertificateChangeBlock (self, certificate)) + { + // Update the certificate in credentials + self->mxCredentials.allowedCertificate = certificate; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; + + return YES; + } + + self->mxCredentials.ignoredCertificate = certificate; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; + } + return NO; + + }]; +} + +- (void)onDateTimeFormatUpdate +{ + if ([mxSession.roomSummaryUpdateDelegate isKindOfClass:MXKEventFormatter.class]) + { + MXKEventFormatter *eventFormatter = (MXKEventFormatter*)mxSession.roomSummaryUpdateDelegate; + + // Update the date and time formatters + [eventFormatter initDateTimeFormatters]; + + dispatch_group_t dispatchGroup = dispatch_group_create(); + + for (MXRoomSummary *summary in mxSession.roomsSummaries) + { + dispatch_group_enter(dispatchGroup); + [summary.mxSession eventWithEventId:summary.lastMessage.eventId + inRoom:summary.roomId + success:^(MXEvent *event) { + + if (event) + { + if (summary.lastMessage.others == nil) + { + summary.lastMessage.others = [NSMutableDictionary dictionary]; + } + summary.lastMessage.others[@"lastEventDate"] = [eventFormatter dateStringFromEvent:event withTime:YES]; + [self->mxSession.store storeSummaryForRoom:summary.roomId summary:summary]; + } + + dispatch_group_leave(dispatchGroup); + } failure:^(NSError *error) { + dispatch_group_leave(dispatchGroup); + }]; + } + + dispatch_group_notify(dispatchGroup, dispatch_get_main_queue(), ^{ + + // Commit store changes done + if ([self->mxSession.store respondsToSelector:@selector(commit)]) + { + [self->mxSession.store commit]; + } + + // Broadcast the change which concerns all the room summaries. + [[NSNotificationCenter defaultCenter] postNotificationName:kMXRoomSummaryDidChangeNotification object:nil userInfo:nil]; + + }); + } +} + +#pragma mark - Crypto +- (void)resetDeviceId +{ + mxCredentials.deviceId = nil; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; +} + +#pragma mark - backgroundSync management + +- (void)cancelBackgroundSync +{ + if (self.backgroundSyncBgTask.isRunning) + { + MXLogDebug(@"[MXKAccount] The background Sync is cancelled."); + + if (mxSession) + { + if (mxSession.state == MXSessionStateBackgroundSyncInProgress) + { + [mxSession pause]; + } + } + + [self onBackgroundSyncDone:[NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:nil]]; + } +} + +- (void)onBackgroundSyncDone:(NSError*)error +{ + if (backgroundSyncTimer) + { + [backgroundSyncTimer invalidate]; + backgroundSyncTimer = nil; + } + + if (backgroundSyncFails && error) + { + backgroundSyncFails(error); + } + + if (backgroundSyncDone && !error) + { + backgroundSyncDone(); + } + + backgroundSyncDone = nil; + backgroundSyncFails = nil; + + // End background task + if (self.backgroundSyncBgTask.isRunning) + { + [self.backgroundSyncBgTask stop]; + self.backgroundSyncBgTask = nil; + } +} + +- (void)onBackgroundSyncTimerOut +{ + [self cancelBackgroundSync]; +} + +- (void)backgroundSync:(unsigned int)timeout success:(void (^)(void))success failure:(void (^)(NSError *))failure +{ + // Check whether a background mode handler has been set. + id handler = [MXSDKOptions sharedInstance].backgroundModeHandler; + if (handler) + { + // Only work when the application is suspended. + // Check conditions before launching background sync + if (mxSession && mxSession.state == MXSessionStatePaused) + { + MXLogDebug(@"[MXKAccount] starts a background Sync"); + + backgroundSyncDone = success; + backgroundSyncFails = failure; + + MXWeakify(self); + + self.backgroundSyncBgTask = [handler startBackgroundTaskWithName:@"[MXKAccount] backgroundSync:success:failure:" expirationHandler:^{ + + MXStrongifyAndReturnIfNil(self); + + MXLogDebug(@"[MXKAccount] the background Sync fails because of the bg task timeout"); + [self cancelBackgroundSync]; + }]; + + // ensure that the backgroundSync will be really done in the expected time + // the request could be done but the treatment could be long so add a timer to cancel it + // if it takes too much time + backgroundSyncTimer = [[NSTimer alloc] initWithFireDate:[NSDate dateWithTimeIntervalSinceNow:(timeout - 1) / 1000] + interval:0 + target:self + selector:@selector(onBackgroundSyncTimerOut) + userInfo:nil + repeats:NO]; + + [[NSRunLoop mainRunLoop] addTimer:backgroundSyncTimer forMode:NSDefaultRunLoopMode]; + + [mxSession backgroundSync:timeout success:^{ + MXLogDebug(@"[MXKAccount] the background Sync succeeds"); + [self onBackgroundSyncDone:nil]; + + } + failure:^(NSError* error) { + + MXLogDebug(@"[MXKAccount] the background Sync fails"); + [self onBackgroundSyncDone:error]; + + } + + ]; + } + else + { + MXLogDebug(@"[MXKAccount] cannot start background Sync (invalid state %tu)", mxSession.state); + failure([NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:nil]); + } + } + else + { + MXLogDebug(@"[MXKAccount] cannot start background Sync"); + failure([NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:nil]); + } +} + +#pragma mark - Sync filter + +- (void)supportLazyLoadOfRoomMembers:(void (^)(BOOL supportLazyLoadOfRoomMembers))completion +{ + void(^onUnsupportedLazyLoadOfRoomMembers)(NSError *) = ^(NSError *error) { + completion(NO); + }; + + // Check if the server supports LL sync filter + MXFilterJSONModel *filter = [self syncFilterWithLazyLoadOfRoomMembers:YES]; + [mxSession.store filterIdForFilter:filter success:^(NSString * _Nullable filterId) { + + if (filterId) + { + // The LL filter is already in the store. The HS supports LL + completion(YES); + } + else + { + // Check the Matrix versions supported by the HS + [self.mxSession supportedMatrixVersions:^(MXMatrixVersions *matrixVersions) { + + if (matrixVersions.supportLazyLoadMembers) + { + // The HS supports LL + completion(YES); + } + else + { + onUnsupportedLazyLoadOfRoomMembers(nil); + } + + } failure:onUnsupportedLazyLoadOfRoomMembers]; + } + } failure:onUnsupportedLazyLoadOfRoomMembers]; +} + +/** + Build the sync filter according to application settings and HS capability. + + @param completion the block providing the sync filter to use. + */ +- (void)buildSyncFilter:(void (^)(MXFilterJSONModel *syncFilter))completion +{ + // Check settings + BOOL syncWithLazyLoadOfRoomMembersSetting = [MXKAppSettings standardAppSettings].syncWithLazyLoadOfRoomMembers; + + if (syncWithLazyLoadOfRoomMembersSetting) + { + // Check if the server supports LL sync filter before enabling it + [self supportLazyLoadOfRoomMembers:^(BOOL supportLazyLoadOfRoomMembers) { + + if (supportLazyLoadOfRoomMembers) + { + completion([self syncFilterWithLazyLoadOfRoomMembers:YES]); + } + else + { + // No support from the HS + // Disable the setting. That will avoid to make a request at every startup + [MXKAppSettings standardAppSettings].syncWithLazyLoadOfRoomMembers = NO; + completion([self syncFilterWithLazyLoadOfRoomMembers:NO]); + } + }]; + } + else + { + completion([self syncFilterWithLazyLoadOfRoomMembers:NO]); + } +} + +/** + Compute the sync filter to use according to the device screen size. + + @param syncWithLazyLoadOfRoomMembers enable LL support. + @return the sync filter to use. + */ +- (MXFilterJSONModel *)syncFilterWithLazyLoadOfRoomMembers:(BOOL)syncWithLazyLoadOfRoomMembers +{ + MXFilterJSONModel *syncFilter; + NSUInteger limit = 10; + + // Define a message limit for /sync requests that is high enough so that + // a full page of room messages can be displayed without an additional + // server request. + + // This limit value depends on the device screen size. So, the rough rule is: + // - use 10 for small phones (5S/SE) + // - use 15 for phones (6/6S/7/8) + // - use 20 for phablets (.Plus/X/XR/XS/XSMax) + // - use 30 for iPads + UIUserInterfaceIdiom userInterfaceIdiom = [[UIDevice currentDevice] userInterfaceIdiom]; + if (userInterfaceIdiom == UIUserInterfaceIdiomPhone) + { + CGFloat screenHeight = [[UIScreen mainScreen] nativeBounds].size.height; + if (screenHeight == 1334) // 6/6S/7/8 screen height + { + limit = 15; + } + else if (screenHeight > 1334) + { + limit = 20; + } + } + else if (userInterfaceIdiom == UIUserInterfaceIdiomPad) + { + limit = 30; + } + + // Set that limit in the filter + if (syncWithLazyLoadOfRoomMembers) + { + syncFilter = [MXFilterJSONModel syncFilterForLazyLoadingWithMessageLimit:limit]; + } + else + { + syncFilter = [MXFilterJSONModel syncFilterWithMessageLimit:limit]; + } + + // TODO: We could extend the filter to match other settings (self.showAllEventsInRoomHistory, + // self.eventsFilterForMessages, etc). + + return syncFilter; +} + + +/** + Check the sync filter we want to use is compatible with the one previously used. + + @param syncFilter the sync filter to use. + @param completion the block called to indicated the compatibility. + */ +- (void)checkSyncFilterCompatibility:(MXFilterJSONModel*)syncFilter completion:(void (^)(BOOL compatible))completion +{ + // There is no compatibility issue if no /sync was done before + if (!mxSession.store.eventStreamToken) + { + completion(YES); + } + + // Check the filter we want to use is compatible with the one previously used + else if (!syncFilter && !mxSession.syncFilterId) + { + // A nil filter implies a nil mxSession.syncFilterId. So, there is no filter change + completion(YES); + } + else if (!syncFilter || !mxSession.syncFilterId) + { + // Change from no filter with using a filter or vice-versa. So, there is a filter change + MXLogDebug(@"[MXKAccount] checkSyncFilterCompatibility: Incompatible filter. New or old is nil. mxSession.syncFilterId: %@ - syncFilter: %@", + mxSession.syncFilterId, syncFilter.JSONDictionary); + completion(NO); + } + else + { + // Check the filter is the one previously set + // It must be already in the store + MXWeakify(self); + [mxSession.store filterIdForFilter:syncFilter success:^(NSString * _Nullable filterId) { + MXStrongifyAndReturnIfNil(self); + + // Note: We could be more tolerant here + // We could accept filter hot change if the change is limited to the `limit` filter value + // But we do not have this requirement yet + BOOL compatible = [filterId isEqualToString:self.mxSession.syncFilterId]; + if (!compatible) + { + MXLogDebug(@"[MXKAccount] checkSyncFilterCompatibility: Incompatible filter ids. mxSession.syncFilterId: %@ - store.filterId: %@ - syncFilter: %@", + self.mxSession.syncFilterId, filterId, syncFilter.JSONDictionary); + } + completion(compatible); + + } failure:^(NSError * _Nullable error) { + // Should never happen + completion(NO); + }]; + } +} + + +#pragma mark - Identity server updates + +- (void)registerAccountDataDidChangeIdentityServerNotification +{ + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleAccountDataDidChangeIdentityServerNotification:) name:kMXSessionAccountDataDidChangeIdentityServerNotification object:nil]; +} + +- (void)handleAccountDataDidChangeIdentityServerNotification:(NSNotification*)notification +{ + MXSession *mxSession = notification.object; + if (mxSession == self.mxSession) + { + if (![mxCredentials.identityServer isEqualToString:self.mxSession.accountDataIdentityServer]) + { + _identityServerURL = self.mxSession.accountDataIdentityServer; + mxCredentials.identityServer = _identityServerURL; + mxCredentials.identityServerAccessToken = nil; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; + } + } +} + + +#pragma mark - Identity server Access Token updates + +- (void)identityService:(MXIdentityService *)identityService didUpdateAccessToken:(NSString *)accessToken +{ + mxCredentials.identityServerAccessToken = accessToken; +} + +- (void)registerIdentityServiceDidChangeAccessTokenNotification +{ + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleIdentityServiceDidChangeAccessTokenNotification:) name:MXIdentityServiceDidChangeAccessTokenNotification object:nil]; +} + +- (void)handleIdentityServiceDidChangeAccessTokenNotification:(NSNotification*)notification +{ + NSDictionary *userInfo = notification.userInfo; + + NSString *userId = userInfo[MXIdentityServiceNotificationUserIdKey]; + NSString *identityServer = userInfo[MXIdentityServiceNotificationIdentityServerKey]; + NSString *accessToken = userInfo[MXIdentityServiceNotificationAccessTokenKey]; + + if (userId && identityServer && accessToken && [mxCredentials.identityServer isEqualToString:identityServer]) + { + mxCredentials.identityServerAccessToken = accessToken; + + // Archive updated field + [[MXKAccountManager sharedManager] saveAccounts]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccountManager.h b/Riot/Modules/MatrixKit/Models/Account/MXKAccountManager.h new file mode 100644 index 000000000..70c4857c9 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccountManager.h @@ -0,0 +1,218 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKAccount.h" + +/** + Posted when the user logged in with a matrix account. + The notification object is the new added account. + */ +extern NSString *const kMXKAccountManagerDidAddAccountNotification; + +/** + Posted when an existing account is logged out. + The notification object is the removed account. + */ +extern NSString *const kMXKAccountManagerDidRemoveAccountNotification; + +/** + Posted when an existing account is soft logged out. + The notification object is the account. + */ +extern NSString *const kMXKAccountManagerDidSoftlogoutAccountNotification; + +/** + Used to identify the type of data when requesting MXKeyProvider + */ +extern NSString *const MXKAccountManagerDataType; + +/** + `MXKAccountManager` manages a pool of `MXKAccount` instances. + */ +@interface MXKAccountManager : NSObject + +/** + The class of store used to open matrix session for the accounts. This class must be conformed to MXStore protocol. + By default this class is MXFileStore. + */ +@property (nonatomic) Class storeClass; + +/** + List of all available accounts (enabled and disabled). + */ +@property (nonatomic, readonly) NSArray *accounts; + +/** + List of active accounts (only enabled accounts) + */ +@property (nonatomic, readonly) NSArray *activeAccounts; + +/** + The device token used for Apple Push Notification Service registration. + */ +@property (nonatomic, copy) NSData *apnsDeviceToken; + +/** + The APNS status: YES when app is registered for remote notif, and device token is known. + */ +@property (nonatomic) BOOL isAPNSAvailable; + +/** + The device token used for Push notifications registration (PushKit support). + */ +@property (nonatomic, copy, readonly) NSData *pushDeviceToken; + +/** + The current options of the Push notifications based on PushKit. + */ +@property (nonatomic, copy, readonly) NSDictionary *pushOptions; + +/** + Set the push token and the potential push options. + For example, for clients that want to go & fetch the body of the event themselves anyway, + the key-value `format: event_id_only` may be used in `pushOptions` dictionary to tell the + HTTP pusher to send just the event_id of the event it's notifying about, the room id and + the notification counts. + + @param pushDeviceToken the push token. + @param pushOptions dictionary of the push options (may be nil). + */ +- (void)setPushDeviceToken:(NSData *)pushDeviceToken withPushOptions:(NSDictionary *)pushOptions; + +/** + The PushKit status: YES when app is registered for push notif, and push token is known. + */ +@property (nonatomic) BOOL isPushAvailable; + +@property (nonatomic, readonly) MXDehydrationService *dehydrationService; + +/** + Retrieve the MXKAccounts manager. + + @return the MXKAccounts manager. + */ ++ (MXKAccountManager *)sharedManager; + +/** + Check for each enabled account if a matrix session is already opened. + Open a matrix session for each enabled account which doesn't have a session. + The developper must set 'storeClass' before the first call of this method + if the default class is not suitable. + */ +- (void)prepareSessionForActiveAccounts; + +/** + Save a snapshot of the current accounts. + */ +- (void)saveAccounts; + +/** + Add an account and save the new account list. Optionally a matrix session may be opened for the provided account. + + @param account a matrix account. + @param openSession YES to open a matrix session (this value is ignored if the account is disabled). + */ +- (void)addAccount:(MXKAccount *)account andOpenSession:(BOOL)openSession; + +/** + Remove the provided account and save the new account list. This method is used in case of logout. + + @note equivalent to `removeAccount:sendLogoutRequest:completion:` method with `sendLogoutRequest` parameter to YES + + @param account a matrix account. + @param completion the block to execute at the end of the operation. + */ +- (void)removeAccount:(MXKAccount *)account completion:(void (^)(void))completion; + + +/** + Remove the provided account and save the new account list. This method is used in case of logout or account deactivation. + + @param account a matrix account. + @param sendLogoutRequest Indicate whether send logout request to homeserver. + @param completion the block to execute at the end of the operation. + */ +- (void)removeAccount:(MXKAccount*)account + sendLogoutRequest:(BOOL)sendLogoutRequest + completion:(void (^)(void))completion; + +/** + Log out and remove all the existing accounts + + @param completion the block to execute at the end of the operation. + */ +- (void)logoutWithCompletion:(void (^)(void))completion; + +/** + Soft logout an account. + + @param account a matrix account. + */ +- (void)softLogout:(MXKAccount*)account; + +/** + Hydrate an existing account by using the credentials provided. + + This updates account credentials and restarts the account session + + If the credentials belong to a different user from the account already stored, + the old account will be cleared automatically. + + @param account a matrix account. + @param credentials the new credentials. + */ +- (void)hydrateAccount:(MXKAccount*)account withCredentials:(MXCredentials*)credentials; + +/** + Retrieve the account for a user id. + + @param userId the user id. + @return the user's account (nil if no account exist). + */ +- (MXKAccount *)accountForUserId:(NSString *)userId; + +/** + Retrieve an account that knows the room with the passed id or alias. + + Note: The method is not accurate as it returns the first account that matches. + + @param roomIdOrAlias the room id or alias. + @return the user's account. Nil if no account matches. + */ +- (MXKAccount *)accountKnowingRoomWithRoomIdOrAlias:(NSString *)roomIdOrAlias; + +/** + Retrieve an account that knows the user with the passed id. + + Note: The method is not accurate as it returns the first account that matches. + + @param userId the user id. + @return the user's account. Nil if no account matches. + */ +- (MXKAccount *)accountKnowingUserWithUserId:(NSString *)userId; + +/** + Force the account manager to reload existing accounts from the local storage. + The account manager is supposed to handle itself the list of the accounts. + Call this method only when an account has been changed from an other application from the same group. + */ +- (void)forceReloadAccounts; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccountManager.m b/Riot/Modules/MatrixKit/Models/Account/MXKAccountManager.m new file mode 100644 index 000000000..0763dab53 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccountManager.m @@ -0,0 +1,726 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAccountManager.h" +#import "MXKAppSettings.h" + +#import "MXKTools.h" + +static NSString *const kMXKAccountsKeyOld = @"accounts"; +static NSString *const kMXKAccountsKey = @"accountsV2"; + +NSString *const kMXKAccountManagerDidAddAccountNotification = @"kMXKAccountManagerDidAddAccountNotification"; +NSString *const kMXKAccountManagerDidRemoveAccountNotification = @"kMXKAccountManagerDidRemoveAccountNotification"; +NSString *const kMXKAccountManagerDidSoftlogoutAccountNotification = @"kMXKAccountManagerDidSoftlogoutAccountNotification"; +NSString *const MXKAccountManagerDataType = @"org.matrix.kit.MXKAccountManagerDataType"; + +@interface MXKAccountManager() +{ + /** + The list of all accounts (enabled and disabled). Each value is a `MXKAccount` instance. + */ + NSMutableArray *mxAccounts; +} + +@end + +@implementation MXKAccountManager + ++ (MXKAccountManager *)sharedManager +{ + static MXKAccountManager *sharedAccountManager = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedAccountManager = [[super allocWithZone:NULL] init]; + }); + + return sharedAccountManager; +} + +- (instancetype)init +{ + self = [super init]; + if (self) + { + _storeClass = [MXFileStore class]; + _dehydrationService = [MXDehydrationService new]; + + // Migrate old account file to new format + [self migrateAccounts]; + + // Load existing accounts from local storage + [self loadAccounts]; + } + return self; +} + +- (void)dealloc +{ + mxAccounts = nil; +} + +#pragma mark - + +- (void)prepareSessionForActiveAccounts +{ + for (MXKAccount *account in mxAccounts) + { + // Check whether the account is enabled. Open a new matrix session if none. + if (!account.isDisabled && !account.isSoftLogout && !account.mxSession) + { + MXLogDebug(@"[MXKAccountManager] openSession for %@ account", account.mxCredentials.userId); + + id store = [[_storeClass alloc] init]; + [account openSessionWithStore:store]; + } + } +} + +- (void)saveAccounts +{ + NSDate *startDate = [NSDate date]; + + MXLogDebug(@"[MXKAccountManager] saveAccounts..."); + + NSMutableData *data = [NSMutableData data]; + NSKeyedArchiver *encoder = [[NSKeyedArchiver alloc] initForWritingWithMutableData:data]; + + [encoder encodeObject:mxAccounts forKey:@"mxAccounts"]; + + [encoder finishEncoding]; + + [data setData:[self encryptData:data]]; + + BOOL result = [data writeToFile:[self accountFile] atomically:YES]; + + MXLogDebug(@"[MXKAccountManager] saveAccounts. Done (result: %@) in %.0fms", @(result), [[NSDate date] timeIntervalSinceDate:startDate] * 1000); +} + +- (void)addAccount:(MXKAccount *)account andOpenSession:(BOOL)openSession +{ + MXLogDebug(@"[MXKAccountManager] login (%@)", account.mxCredentials.userId); + + [mxAccounts addObject:account]; + [self saveAccounts]; + + // Check conditions to open a matrix session + if (openSession && !account.disabled) + { + // Open a new matrix session by default + MXLogDebug(@"[MXKAccountManager] openSession for %@ account (device %@)", account.mxCredentials.userId, account.mxCredentials.deviceId); + id store = [[_storeClass alloc] init]; + [account openSessionWithStore:store]; + } + + // Post notification + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountManagerDidAddAccountNotification object:account userInfo:nil]; +} + +- (void)removeAccount:(MXKAccount*)theAccount completion:(void (^)(void))completion +{ + [self removeAccount:theAccount sendLogoutRequest:YES completion:completion]; +} + +- (void)removeAccount:(MXKAccount*)theAccount + sendLogoutRequest:(BOOL)sendLogoutRequest + completion:(void (^)(void))completion +{ + MXLogDebug(@"[MXKAccountManager] logout (%@), send logout request to homeserver: %d", theAccount.mxCredentials.userId, sendLogoutRequest); + + // Close session and clear associated store. + [theAccount logoutSendingServerRequest:sendLogoutRequest completion:^{ + + // Retrieve the corresponding account in the internal array + MXKAccount* removedAccount = nil; + + for (MXKAccount *account in self->mxAccounts) + { + if ([account.mxCredentials.userId isEqualToString:theAccount.mxCredentials.userId]) + { + removedAccount = account; + break; + } + } + + if (removedAccount) + { + [self->mxAccounts removeObject:removedAccount]; + + [self saveAccounts]; + + // Post notification + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountManagerDidRemoveAccountNotification object:removedAccount userInfo:nil]; + } + + if (completion) + { + completion(); + } + + }]; +} + + +- (void)logoutWithCompletion:(void (^)(void))completion +{ + // Logout one by one the existing accounts + if (mxAccounts.count) + { + [self removeAccount:mxAccounts.lastObject completion:^{ + + // loop: logout the next existing account (if any) + [self logoutWithCompletion:completion]; + + }]; + + return; + } + + NSUserDefaults *sharedUserDefaults = [MXKAppSettings standardAppSettings].sharedUserDefaults; + + // Remove APNS device token + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"apnsDeviceToken"]; + + // Remove Push device token + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"pushDeviceToken"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"pushOptions"]; + + // Be sure that no account survive in local storage + [[NSUserDefaults standardUserDefaults] removeObjectForKey:kMXKAccountsKey]; + [sharedUserDefaults removeObjectForKey:kMXKAccountsKey]; + [[NSFileManager defaultManager] removeItemAtPath:[self accountFile] error:nil]; + + if (completion) + { + completion(); + } +} + +- (void)softLogout:(MXKAccount*)account +{ + [account softLogout]; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountManagerDidSoftlogoutAccountNotification + object:account + userInfo:nil]; +} + +- (void)hydrateAccount:(MXKAccount*)account withCredentials:(MXCredentials*)credentials +{ + MXLogDebug(@"[MXKAccountManager] hydrateAccount: %@", account.mxCredentials.userId); + + if ([account.mxCredentials.userId isEqualToString:credentials.userId]) + { + // Restart the account + [account hydrateWithCredentials:credentials]; + + MXLogDebug(@"[MXKAccountManager] hydrateAccount: Open session"); + + id store = [[_storeClass alloc] init]; + [account openSessionWithStore:store]; + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountManagerDidAddAccountNotification + object:account + userInfo:nil]; + } + else + { + MXLogDebug(@"[MXKAccountManager] hydrateAccount: Credentials given for another account: %@", credentials.userId); + + // Logout the old account and create a new one with the new credentials + [self removeAccount:account sendLogoutRequest:YES completion:nil]; + + MXKAccount *newAccount = [[MXKAccount alloc] initWithCredentials:credentials]; + [self addAccount:newAccount andOpenSession:YES]; + } +} + +- (MXKAccount *)accountForUserId:(NSString *)userId +{ + for (MXKAccount *account in mxAccounts) + { + if ([account.mxCredentials.userId isEqualToString:userId]) + { + return account; + } + } + return nil; +} + +- (MXKAccount *)accountKnowingRoomWithRoomIdOrAlias:(NSString *)roomIdOrAlias +{ + MXKAccount *theAccount = nil; + + NSArray *activeAccounts = self.activeAccounts; + + for (MXKAccount *account in activeAccounts) + { + if ([roomIdOrAlias hasPrefix:@"#"]) + { + if ([account.mxSession roomWithAlias:roomIdOrAlias]) + { + theAccount = account; + break; + } + } + else + { + if ([account.mxSession roomWithRoomId:roomIdOrAlias]) + { + theAccount = account; + break; + } + } + } + return theAccount; +} + +- (MXKAccount *)accountKnowingUserWithUserId:(NSString *)userId +{ + MXKAccount *theAccount = nil; + + NSArray *activeAccounts = self.activeAccounts; + + for (MXKAccount *account in activeAccounts) + { + if ([account.mxSession userWithUserId:userId]) + { + theAccount = account; + break; + } + } + return theAccount; +} + +#pragma mark - + +- (void)setStoreClass:(Class)storeClass +{ + // Sanity check + NSAssert([storeClass conformsToProtocol:@protocol(MXStore)], @"MXKAccountManager only manages store class that conforms to MXStore protocol"); + + _storeClass = storeClass; +} + +- (NSArray *)accounts +{ + return [mxAccounts copy]; +} + +- (NSArray *)activeAccounts +{ + NSMutableArray *activeAccounts = [NSMutableArray arrayWithCapacity:mxAccounts.count]; + for (MXKAccount *account in mxAccounts) + { + if (!account.disabled && !account.isSoftLogout) + { + [activeAccounts addObject:account]; + } + } + return activeAccounts; +} + +- (NSData *)apnsDeviceToken +{ + NSData *token = [[NSUserDefaults standardUserDefaults] objectForKey:@"apnsDeviceToken"]; + if (!token.length) + { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"apnsDeviceToken"]; + token = nil; + } + + MXLogDebug(@"[MXKAccountManager][Push] apnsDeviceToken: %@", [MXKTools logForPushToken:token]); + return token; +} + +- (void)setApnsDeviceToken:(NSData *)apnsDeviceToken +{ + MXLogDebug(@"[MXKAccountManager][Push] setApnsDeviceToken: %@", [MXKTools logForPushToken:apnsDeviceToken]); + + NSData *oldToken = self.apnsDeviceToken; + if (!apnsDeviceToken.length) + { + MXLogDebug(@"[MXKAccountManager][Push] setApnsDeviceToken: reset APNS device token"); + + if (oldToken) + { + // turn off the Apns flag for all accounts if any + for (MXKAccount *account in mxAccounts) + { + [account enablePushNotifications:NO success:nil failure:nil]; + } + } + + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"apnsDeviceToken"]; + } + else + { + NSArray *activeAccounts = self.activeAccounts; + + if (!oldToken) + { + MXLogDebug(@"[MXKAccountManager][Push] setApnsDeviceToken: set APNS device token"); + + [[NSUserDefaults standardUserDefaults] setObject:apnsDeviceToken forKey:@"apnsDeviceToken"]; + + // turn on the Apns flag for all accounts, when the Apns registration succeeds for the first time + for (MXKAccount *account in activeAccounts) + { + [account enablePushNotifications:YES success:nil failure:nil]; + } + } + else if (![oldToken isEqualToData:apnsDeviceToken]) + { + MXLogDebug(@"[MXKAccountManager][Push] setApnsDeviceToken: update APNS device token"); + + NSMutableArray *accountsWithAPNSPusher = [NSMutableArray new]; + + // Delete the pushers related to the old token + for (MXKAccount *account in activeAccounts) + { + if (account.hasPusherForPushNotifications) + { + [accountsWithAPNSPusher addObject:account]; + } + + [account enablePushNotifications:NO success:nil failure:nil]; + } + + // Update the token + [[NSUserDefaults standardUserDefaults] setObject:apnsDeviceToken forKey:@"apnsDeviceToken"]; + + // Refresh pushers with the new token. + for (MXKAccount *account in activeAccounts) + { + if ([accountsWithAPNSPusher containsObject:account]) + { + MXLogDebug(@"[MXKAccountManager][Push] setApnsDeviceToken: Resync APNS for %@ account", account.mxCredentials.userId); + [account enablePushNotifications:YES success:nil failure:nil]; + } + else + { + MXLogDebug(@"[MXKAccountManager][Push] setApnsDeviceToken: hasPusherForPushNotifications = NO for %@ account. Do not enable Push", account.mxCredentials.userId); + } + } + } + else + { + MXLogDebug(@"[MXKAccountManager][Push] setApnsDeviceToken: Same token. Nothing to do."); + } + } +} + +- (BOOL)isAPNSAvailable +{ + // [UIApplication isRegisteredForRemoteNotifications] tells whether your app can receive + // remote notifications or not. Receiving remote notifications does not guarantee it will + // display them to the user as they may have notifications set to deliver quietly. + + BOOL isRemoteNotificationsAllowed = NO; + + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + isRemoteNotificationsAllowed = [sharedApplication isRegisteredForRemoteNotifications]; + + MXLogDebug(@"[MXKAccountManager][Push] isAPNSAvailable: The user %@ remote notification", (isRemoteNotificationsAllowed ? @"allowed" : @"denied")); + } + + BOOL isAPNSAvailable = (isRemoteNotificationsAllowed && self.apnsDeviceToken); + + MXLogDebug(@"[MXKAccountManager][Push] isAPNSAvailable: %@", @(isAPNSAvailable)); + + return isAPNSAvailable; +} + +- (NSData *)pushDeviceToken +{ + NSData *token = [[NSUserDefaults standardUserDefaults] objectForKey:@"pushDeviceToken"]; + if (!token.length) + { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"pushDeviceToken"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"pushOptions"]; + token = nil; + } + + MXLogDebug(@"[MXKAccountManager][Push] pushDeviceToken: %@", [MXKTools logForPushToken:token]); + return token; +} + +- (NSDictionary *)pushOptions +{ + NSDictionary *pushOptions = [[NSUserDefaults standardUserDefaults] objectForKey:@"pushOptions"]; + + MXLogDebug(@"[MXKAccountManager][Push] pushOptions: %@", pushOptions); + return pushOptions; +} + +- (void)setPushDeviceToken:(NSData *)pushDeviceToken withPushOptions:(NSDictionary *)pushOptions +{ + MXLogDebug(@"[MXKAccountManager][Push] setPushDeviceToken: %@ withPushOptions: %@", [MXKTools logForPushToken:pushDeviceToken], pushOptions); + + NSData *oldToken = self.pushDeviceToken; + if (!pushDeviceToken.length) + { + MXLogDebug(@"[MXKAccountManager][Push] setPushDeviceToken: Reset Push device token"); + + if (oldToken) + { + // turn off the Push flag for all accounts if any + for (MXKAccount *account in mxAccounts) + { + [account enablePushKitNotifications:NO success:^{ + // make sure pusher really removed before losing token. + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"pushDeviceToken"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"pushOptions"]; + } failure:nil]; + } + } + } + else + { + NSArray *activeAccounts = self.activeAccounts; + + if (!oldToken) + { + MXLogDebug(@"[MXKAccountManager][Push] setPushDeviceToken: Set Push device token"); + + [[NSUserDefaults standardUserDefaults] setObject:pushDeviceToken forKey:@"pushDeviceToken"]; + if (pushOptions) + { + [[NSUserDefaults standardUserDefaults] setObject:pushOptions forKey:@"pushOptions"]; + } + else + { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"pushOptions"]; + } + + // turn on the Push flag for all accounts + for (MXKAccount *account in activeAccounts) + { + [account enablePushKitNotifications:YES success:nil failure:nil]; + } + } + else if (![oldToken isEqualToData:pushDeviceToken]) + { + MXLogDebug(@"[MXKAccountManager][Push] setPushDeviceToken: Update Push device token"); + + NSMutableArray *accountsWithPushKitPusher = [NSMutableArray new]; + + // Delete the pushers related to the old token + for (MXKAccount *account in activeAccounts) + { + if (account.hasPusherForPushKitNotifications) + { + [accountsWithPushKitPusher addObject:account]; + } + + [account enablePushKitNotifications:NO success:nil failure:nil]; + } + + // Update the token + [[NSUserDefaults standardUserDefaults] setObject:pushDeviceToken forKey:@"pushDeviceToken"]; + if (pushOptions) + { + [[NSUserDefaults standardUserDefaults] setObject:pushOptions forKey:@"pushOptions"]; + } + else + { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"pushOptions"]; + } + + // Refresh pushers with the new token. + for (MXKAccount *account in activeAccounts) + { + if ([accountsWithPushKitPusher containsObject:account]) + { + MXLogDebug(@"[MXKAccountManager][Push] setPushDeviceToken: Resync Push for %@ account", account.mxCredentials.userId); + [account enablePushKitNotifications:YES success:nil failure:nil]; + } + else + { + MXLogDebug(@"[MXKAccountManager][Push] setPushDeviceToken: hasPusherForPushKitNotifications = NO for %@ account. Do not enable Push", account.mxCredentials.userId); + } + } + } + else + { + MXLogDebug(@"[MXKAccountManager][Push] setPushDeviceToken: Same token. Nothing to do."); + } + } +} + +- (BOOL)isPushAvailable +{ + // [UIApplication isRegisteredForRemoteNotifications] tells whether your app can receive + // remote notifications or not. Receiving remote notifications does not guarantee it will + // display them to the user as they may have notifications set to deliver quietly. + + BOOL isRemoteNotificationsAllowed = NO; + + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + isRemoteNotificationsAllowed = [sharedApplication isRegisteredForRemoteNotifications]; + + MXLogDebug(@"[MXKAccountManager][Push] isPushAvailable: The user %@ remote notification", (isRemoteNotificationsAllowed ? @"allowed" : @"denied")); + } + + BOOL isPushAvailable = (isRemoteNotificationsAllowed && self.pushDeviceToken); + + MXLogDebug(@"[MXKAccountManager][Push] isPushAvailable: %@", @(isPushAvailable)); + return isPushAvailable; +} + +#pragma mark - + +// Return the path of the file containing stored MXAccounts array +- (NSString*)accountFile +{ + NSString *matrixKitCacheFolder = [MXKAppSettings cacheFolder]; + return [matrixKitCacheFolder stringByAppendingPathComponent:kMXKAccountsKey]; +} + +- (void)loadAccounts +{ + MXLogDebug(@"[MXKAccountManager] loadAccounts"); + + NSString *accountFile = [self accountFile]; + if ([[NSFileManager defaultManager] fileExistsAtPath:accountFile]) + { + NSDate *startDate = [NSDate date]; + + NSError *error = nil; + NSData* filecontent = [NSData dataWithContentsOfFile:accountFile options:(NSDataReadingMappedAlways | NSDataReadingUncached) error:&error]; + + if (!error) + { + // Decrypt data if encryption method is provided + NSData *unciphered = [self decryptData:filecontent]; + NSKeyedUnarchiver *decoder = [[NSKeyedUnarchiver alloc] initForReadingWithData:unciphered]; + mxAccounts = [decoder decodeObjectForKey:@"mxAccounts"]; + + if (!mxAccounts && [[MXKeyProvider sharedInstance] isEncryptionAvailableForDataOfType:MXKAccountManagerDataType]) + { + // This happens if the V2 file has not been encrypted -> read file content then save encrypted accounts + MXLogDebug(@"[MXKAccountManager] loadAccounts. Failed to read decrypted data: reading file data without encryption."); + decoder = [[NSKeyedUnarchiver alloc] initForReadingWithData:filecontent]; + mxAccounts = [decoder decodeObjectForKey:@"mxAccounts"]; + + if (mxAccounts) + { + MXLogDebug(@"[MXKAccountManager] loadAccounts. saving encrypted accounts"); + [self saveAccounts]; + } + } + } + + MXLogDebug(@"[MXKAccountManager] loadAccounts. %tu accounts loaded in %.0fms", mxAccounts.count, [[NSDate date] timeIntervalSinceDate:startDate] * 1000); + } + else + { + // Migration of accountData from sharedUserDefaults to a file + NSUserDefaults *sharedDefaults = [MXKAppSettings standardAppSettings].sharedUserDefaults; + + NSData *accountData = [sharedDefaults objectForKey:kMXKAccountsKey]; + if (!accountData) + { + // Migration of accountData from [NSUserDefaults standardUserDefaults], the first location storage + accountData = [[NSUserDefaults standardUserDefaults] objectForKey:kMXKAccountsKey]; + } + + if (accountData) + { + mxAccounts = [NSMutableArray arrayWithArray:[NSKeyedUnarchiver unarchiveObjectWithData:accountData]]; + [self saveAccounts]; + + MXLogDebug(@"[MXKAccountManager] loadAccounts: performed data migration"); + + // Now that data has been migrated, erase old location of accountData + [[NSUserDefaults standardUserDefaults] removeObjectForKey:kMXKAccountsKey]; + + [sharedDefaults removeObjectForKey:kMXKAccountsKey]; + } + } + + if (!mxAccounts) + { + MXLogDebug(@"[MXKAccountManager] loadAccounts. No accounts"); + mxAccounts = [NSMutableArray array]; + } +} + +- (void)forceReloadAccounts +{ + MXLogDebug(@"[MXKAccountManager] Force reload existing accounts from local storage"); + [self loadAccounts]; +} + +- (NSData*)encryptData:(NSData*)data +{ + // Exceptions are not caught as the key is always needed if the KeyProviderDelegate + // is provided. + MXKeyData *keyData = [[MXKeyProvider sharedInstance] requestKeyForDataOfType:MXKAccountManagerDataType isMandatory:YES expectedKeyType:kAes]; + if (keyData && [keyData isKindOfClass:[MXAesKeyData class]]) + { + MXAesKeyData *aesKey = (MXAesKeyData *) keyData; + NSData *cipher = [MXAes encrypt:data aesKey:aesKey.key iv:aesKey.iv error:nil]; + return cipher; + } + + MXLogDebug(@"[MXKAccountManager] encryptData: no key method provided for encryption."); + return data; +} + +- (NSData*)decryptData:(NSData*)data +{ + // Exceptions are not cached as the key is always needed if the KeyProviderDelegate + // is provided. + MXKeyData *keyData = [[MXKeyProvider sharedInstance] requestKeyForDataOfType:MXKAccountManagerDataType isMandatory:YES expectedKeyType:kAes]; + if (keyData && [keyData isKindOfClass:[MXAesKeyData class]]) + { + MXAesKeyData *aesKey = (MXAesKeyData *) keyData; + NSData *decrypt = [MXAes decrypt:data aesKey:aesKey.key iv:aesKey.iv error:nil]; + return decrypt; + } + + MXLogDebug(@"[MXKAccountManager] decryptData: no key method provided for decryption."); + return data; +} + +- (void)migrateAccounts +{ + NSString *pathOld = [[MXKAppSettings cacheFolder] stringByAppendingPathComponent:kMXKAccountsKeyOld]; + NSString *pathNew = [[MXKAppSettings cacheFolder] stringByAppendingPathComponent:kMXKAccountsKey]; + NSFileManager *fileManager = [NSFileManager defaultManager]; + if ([fileManager fileExistsAtPath:pathOld]) + { + if (![fileManager fileExistsAtPath:pathNew]) + { + MXLogDebug(@"[MXKAccountManager] migrateAccounts: reading account"); + mxAccounts = [NSKeyedUnarchiver unarchiveObjectWithFile:pathOld]; + MXLogDebug(@"[MXKAccountManager] migrateAccounts: writing to accountV2"); + [self saveAccounts]; + } + + MXLogDebug(@"[MXKAccountManager] migrateAccounts: removing account"); + [fileManager removeItemAtPath:pathOld error:nil]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKContact.h b/Riot/Modules/MatrixKit/Models/Contact/MXKContact.h new file mode 100644 index 000000000..3d8e8af24 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKContact.h @@ -0,0 +1,170 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import + +#import "MXKCellData.h" + +#import "MXKEmail.h" +#import "MXKPhoneNumber.h" + +/** + Posted when the contact thumbnail is updated. + The notification object is a contact Id. + */ +extern NSString *const kMXKContactThumbnailUpdateNotification; + +extern NSString *const kMXKContactLocalContactPrefixId; +extern NSString *const kMXKContactMatrixContactPrefixId; +extern NSString *const kMXKContactDefaultContactPrefixId; + +@interface MXKContact : MXKCellData + +/** + The unique identifier + */ +@property (nonatomic, readonly) NSString * contactID; + +/** + The display name + */ +@property (nonatomic, readwrite) NSString *displayName; + +/** + The sorting display name built by trimming the symbols [_!~`@#$%^&*-+();:={}[],.<>?\/"'] from the display name. + */ +@property (nonatomic) NSString* sortingDisplayName; + +/** + The contact thumbnail. Default size: 256 X 256 pixels + */ +@property (nonatomic, copy, readonly) UIImage *thumbnail; + +/** + YES if the contact does not exist in the contacts book + the contact has been created from a MXUser or MXRoomThirdPartyInvite + */ +@property (nonatomic) BOOL isMatrixContact; + +/** + YES if the contact is coming from MXRoomThirdPartyInvite event (NO by default). + */ +@property (nonatomic) BOOL isThirdPartyInvite; + +/** + The array of MXKPhoneNumber + */ +@property (nonatomic, readonly) NSArray *phoneNumbers; + +/** + The array of MXKEmail + */ +@property (nonatomic, readonly) NSArray *emailAddresses; + +/** + The array of matrix identifiers + */ +@property (nonatomic, readonly) NSArray* matrixIdentifiers; + +/** + The matrix avatar url used (if any) to build the current thumbnail, nil by default. + */ +@property (nonatomic, readonly) NSString* matrixAvatarURL; + +/** + Reset the current thumbnail if it is retrieved from a matrix url. May be used in case of the matrix avatar url change. + A new thumbnail will be automatically restored from the contact data. + */ +- (void)resetMatrixThumbnail; + +/** + The contact ID from native phonebook record + */ ++ (NSString*)contactID:(ABRecordRef)record; + +/** + Create a local contact from a device contact + + @param record device contact id + @return MXKContact instance + */ +- (id)initLocalContactWithABRecord:(ABRecordRef)record; + +/** + Create a matrix contact with the dedicated info + + @param displayName the contact display name + @param matrixID the contact matrix id + @return MXKContact instance + */ +- (id)initMatrixContactWithDisplayName:(NSString*)displayName andMatrixID:(NSString*)matrixID; + +/** + Create a matrix contact with the dedicated info + + @param displayName the contact display name + @param matrixID the contact matrix id + @param matrixAvatarURL the matrix avatar url + @return MXKContact instance + */ +- (id)initMatrixContactWithDisplayName:(NSString*)displayName matrixID:(NSString*)matrixID andMatrixAvatarURL:(NSString*)matrixAvatarURL; + +/** + Create a contact with the dedicated info + + @param displayName the contact display name + @param emails an array of emails + @param phones an array of phone numbers + @param thumbnail the contact thumbnail + @return MXKContact instance + */ +- (id)initContactWithDisplayName:(NSString*)displayName + emails:(NSArray *)emails + phoneNumbers:(NSArray *)phones + andThumbnail:(UIImage *)thumbnail; + +/** + The contact thumbnail with a prefered size. + + If the thumbnail is already loaded, this method returns this one by ignoring prefered size. + The prefered size is used only if a server request is required. + + @return thumbnail with a prefered size + */ +- (UIImage*)thumbnailWithPreferedSize:(CGSize)size; + +/** + Tell whether a component of the contact's displayName, or one of his matrix id/email has the provided prefix. + + @param prefix a non empty string. + @return YES when at least one matrix id, email or a component of the display name has this prefix. + */ +- (BOOL)hasPrefix:(NSString*)prefix; + +/** + Check if the patterns can match with this contact + */ +- (BOOL)matchedWithPatterns:(NSArray*)patterns; + +/** + The default ISO 3166-1 country code used to internationalize the contact phone numbers. + */ +@property (nonatomic) NSString *defaultCountryCode; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKContact.m b/Riot/Modules/MatrixKit/Models/Contact/MXKContact.m new file mode 100644 index 000000000..2b1e57727 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKContact.m @@ -0,0 +1,659 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKContact.h" + +#import "MXKEmail.h" +#import "MXKPhoneNumber.h" + +NSString *const kMXKContactThumbnailUpdateNotification = @"kMXKContactThumbnailUpdateNotification"; + +NSString *const kMXKContactLocalContactPrefixId = @"Local_"; +NSString *const kMXKContactMatrixContactPrefixId = @"Matrix_"; +NSString *const kMXKContactDefaultContactPrefixId = @"Default_"; + +@interface MXKContact() +{ + UIImage* contactThumbnail; + UIImage* matrixThumbnail; + + // The matrix id of the contact (used when the contact is not defined in the contacts book) + MXKContactField *matrixIdField; +} +@end + +@implementation MXKContact +@synthesize isMatrixContact, isThirdPartyInvite; + ++ (NSString*)contactID:(ABRecordRef)record +{ + return [NSString stringWithFormat:@"%@%d", kMXKContactLocalContactPrefixId, ABRecordGetRecordID(record)]; +} + +- (id)init +{ + self = [super init]; + if (self) + { + matrixIdField = nil; + isMatrixContact = NO; + _matrixAvatarURL = nil; + + isThirdPartyInvite = NO; + } + + return self; +} + +- (id)initLocalContactWithABRecord:(ABRecordRef)record +{ + self = [self init]; + if (self) + { + // compute a contact ID + _contactID = [MXKContact contactID:record]; + + // use the contact book display name + _displayName = (__bridge NSString*) ABRecordCopyCompositeName(record); + + // avoid nil display name + // the display name is used to sort contacts + if (!_displayName) + { + _displayName = @""; + } + + // extract the phone numbers and their related label + ABMultiValueRef multi = ABRecordCopyValue(record, kABPersonPhoneProperty); + CFIndex nCount = ABMultiValueGetCount(multi); + NSMutableArray* pns = [[NSMutableArray alloc] initWithCapacity:nCount]; + + for (int i = 0; i < nCount; i++) + { + CFTypeRef phoneRef = ABMultiValueCopyValueAtIndex(multi, i); + NSString *phoneVal = (__bridge NSString*)phoneRef; + + // sanity check + if (0 != [phoneVal length]) + { + CFStringRef lblRef = ABMultiValueCopyLabelAtIndex(multi, i); + CFStringRef localizedLblRef = nil; + NSString *lbl = @""; + + if (lblRef != nil) + { + localizedLblRef = ABAddressBookCopyLocalizedLabel(lblRef); + if (localizedLblRef) + { + lbl = (__bridge NSString*)localizedLblRef; + } + else + { + lbl = (__bridge NSString*)lblRef; + } + } + else + { + localizedLblRef = ABAddressBookCopyLocalizedLabel(kABOtherLabel); + if (localizedLblRef) + { + lbl = (__bridge NSString*)localizedLblRef; + } + } + + [pns addObject:[[MXKPhoneNumber alloc] initWithTextNumber:phoneVal type:lbl contactID:_contactID matrixID:nil]]; + + if (lblRef) + { + CFRelease(lblRef); + } + if (localizedLblRef) + { + CFRelease(localizedLblRef); + } + } + + // release meory + if (phoneRef) + { + CFRelease(phoneRef); + } + } + + CFRelease(multi); + _phoneNumbers = pns; + + // extract the emails + multi = ABRecordCopyValue(record, kABPersonEmailProperty); + nCount = ABMultiValueGetCount(multi); + + NSMutableArray *emails = [[NSMutableArray alloc] initWithCapacity:nCount]; + + for (int i = 0; i < nCount; i++) + { + CFTypeRef emailValRef = ABMultiValueCopyValueAtIndex(multi, i); + NSString *emailVal = (__bridge NSString*)emailValRef; + + // sanity check + if ((nil != emailVal) && (0 != [emailVal length])) + { + CFStringRef lblRef = ABMultiValueCopyLabelAtIndex(multi, i); + CFStringRef localizedLblRef = nil; + NSString *lbl = @""; + + if (lblRef != nil) + { + localizedLblRef = ABAddressBookCopyLocalizedLabel(lblRef); + + if (localizedLblRef) + { + lbl = (__bridge NSString*)localizedLblRef; + } + else + { + lbl = (__bridge NSString*)lblRef; + } + } + else + { + localizedLblRef = ABAddressBookCopyLocalizedLabel(kABOtherLabel); + if (localizedLblRef) + { + lbl = (__bridge NSString*)localizedLblRef; + } + } + + [emails addObject: [[MXKEmail alloc] initWithEmailAddress:emailVal type:lbl contactID:_contactID matrixID:nil]]; + + if (lblRef) + { + CFRelease(lblRef); + } + + if (localizedLblRef) + { + CFRelease(localizedLblRef); + } + } + + if (emailValRef) + { + CFRelease(emailValRef); + } + } + + CFRelease(multi); + + _emailAddresses = emails; + + // thumbnail/picture + // check whether the contact has a picture + if (ABPersonHasImageData(record)) + { + CFDataRef dataRef; + + dataRef = ABPersonCopyImageDataWithFormat(record, kABPersonImageFormatThumbnail); + if (dataRef) + { + contactThumbnail = [UIImage imageWithData:(__bridge NSData*)dataRef]; + CFRelease(dataRef); + } + } + } + return self; +} + +- (id)initMatrixContactWithDisplayName:(NSString*)displayName andMatrixID:(NSString*)matrixID +{ + self = [self init]; + if (self) + { + _contactID = [NSString stringWithFormat:@"%@%@", kMXKContactMatrixContactPrefixId, [[NSUUID UUID] UUIDString]]; + + // Sanity check + if (matrixID.length) + { + // used when the contact is not defined in the contacts book + matrixIdField = [[MXKContactField alloc] initWithContactID:_contactID matrixID:matrixID]; + isMatrixContact = YES; + } + + // _displayName must not be nil + // it is used to sort the contacts + if (displayName) + { + _displayName = displayName; + } + else + { + _displayName = @""; + } + } + + return self; +} + +- (id)initMatrixContactWithDisplayName:(NSString*)displayName matrixID:(NSString*)matrixID andMatrixAvatarURL:(NSString*)matrixAvatarURL +{ + self = [self initMatrixContactWithDisplayName:displayName andMatrixID:matrixID]; + if (self) + { + matrixIdField.matrixAvatarURL = matrixAvatarURL; + } + return self; +} + +- (id)initContactWithDisplayName:(NSString*)displayName + emails:(NSArray *)emails + phoneNumbers:(NSArray *)phones + andThumbnail:(UIImage *)thumbnail +{ + self = [self init]; + if (self) + { + _contactID = [NSString stringWithFormat:@"%@%@", kMXKContactDefaultContactPrefixId, [[NSUUID UUID] UUIDString]]; + + // _displayName must not be nil + // it is used to sort the contacts + if (displayName) + { + _displayName = displayName; + } + else + { + _displayName = @""; + } + + _emailAddresses = emails; + _phoneNumbers = phones; + + contactThumbnail = thumbnail; + } + + return self; +} + +#pragma mark - + +- (NSString*)sortingDisplayName +{ + if (!_sortingDisplayName) + { + // Sanity check - display name should not be nil here + if (self.displayName) + { + NSCharacterSet *specialCharacterSet = [NSCharacterSet characterSetWithCharactersInString:@"_!~`@#$%^&*-+();:={}[],.<>?\\/\"\'"]; + + _sortingDisplayName = [self.displayName stringByTrimmingCharactersInSet:specialCharacterSet]; + } + else + { + return @""; + } + } + + return _sortingDisplayName; +} + +- (BOOL)hasPrefix:(NSString*)prefix +{ + prefix = [prefix lowercaseString]; + + // Check first display name + if (_displayName.length) + { + NSString *lowercaseString = [_displayName lowercaseString]; + if ([lowercaseString hasPrefix:prefix]) + { + return YES; + } + + NSArray *components = [lowercaseString componentsSeparatedByString:@" "]; + for (NSString *component in components) + { + NSString *theComponent = [component stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if ([theComponent hasPrefix:prefix]) + { + return YES; + } + } + } + + // Check matrix identifiers + NSArray *identifiers = self.matrixIdentifiers; + NSString *idPrefix = prefix; + if (![prefix hasPrefix:@"@"]) + { + idPrefix = [NSString stringWithFormat:@"@%@", prefix]; + } + + for (NSString* mxId in identifiers) + { + if ([[mxId lowercaseString] hasPrefix:idPrefix]) + { + return YES; + } + } + + // Check email + for (MXKEmail* email in _emailAddresses) + { + if ([email.emailAddress hasPrefix:prefix]) + { + return YES; + } + } + + // Check phones + for (MXKPhoneNumber* phone in _phoneNumbers) + { + if ([phone hasPrefix:prefix]) + { + return YES; + } + } + + return NO; +} + +- (BOOL)matchedWithPatterns:(NSArray*)patterns +{ + BOOL matched = NO; + + if (patterns.count > 0) + { + matched = YES; + + // test first display name + for (NSString* pattern in patterns) + { + if ([_displayName rangeOfString:pattern options:NSCaseInsensitiveSearch].location == NSNotFound) + { + matched = NO; + break; + } + } + + NSArray *identifiers = self.matrixIdentifiers; + if (!matched && identifiers.count > 0) + { + for (NSString* mxId in identifiers) + { + // Consider only the first part of the matrix id (ignore homeserver name) + NSRange range = [mxId rangeOfString:@":"]; + if (range.location != NSNotFound) + { + NSString *mxIdName = [mxId substringToIndex:range.location]; + for (NSString* pattern in patterns) + { + if ([mxIdName rangeOfString:pattern options:NSCaseInsensitiveSearch].location != NSNotFound) + { + matched = YES; + break; + } + } + + if (matched) + { + break; + } + } + } + } + + if (!matched && _phoneNumbers.count > 0) + { + for (MXKPhoneNumber* phonenumber in _phoneNumbers) + { + if ([phonenumber matchedWithPatterns:patterns]) + { + matched = YES; + break; + } + } + } + + if (!matched && _emailAddresses.count > 0) + { + for (MXKEmail* email in _emailAddresses) + { + if ([email matchedWithPatterns:patterns]) + { + matched = YES; + break; + } + } + } + } + else + { + // if there is no pattern to search, it should always matched + matched = YES; + } + + return matched; +} + +- (void)setDefaultCountryCode:(NSString *)defaultCountryCode +{ + for (MXKPhoneNumber* phonenumber in _phoneNumbers) + { + phonenumber.defaultCountryCode = defaultCountryCode; + } + + _defaultCountryCode = defaultCountryCode; +} + +#pragma mark - getter/setter + +- (NSArray*)matrixIdentifiers +{ + NSMutableArray* identifiers = [[NSMutableArray alloc] init]; + + if (matrixIdField) + { + [identifiers addObject:matrixIdField.matrixID]; + } + + for (MXKEmail* email in _emailAddresses) + { + if (email.matrixID && ([identifiers indexOfObject:email.matrixID] == NSNotFound)) + { + [identifiers addObject:email.matrixID]; + } + } + + for (MXKPhoneNumber* pn in _phoneNumbers) + { + if (pn.matrixID && ([identifiers indexOfObject:pn.matrixID] == NSNotFound)) + { + [identifiers addObject:pn.matrixID]; + } + } + + return identifiers; +} + +- (void)setDisplayName:(NSString *)displayName +{ + // a display name must not be emptied + // it is used to sort the contacts + if (displayName.length == 0) + { + _displayName = _contactID; + } + else + { + _displayName = displayName; + } +} + +- (void)resetMatrixThumbnail +{ + matrixThumbnail = nil; + _matrixAvatarURL = nil; + + // Reset the avatar in the contact fields too. + [matrixIdField resetMatrixAvatar]; + + for (MXKEmail* email in _emailAddresses) + { + [email resetMatrixAvatar]; + } +} + +- (UIImage*)thumbnailWithPreferedSize:(CGSize)size +{ + // Consider first the local thumbnail if any. + if (contactThumbnail) + { + return contactThumbnail; + } + + // Check whether a matrix thumbnail is already found. + if (matrixThumbnail) + { + return matrixThumbnail; + } + + // Look for a thumbnail from the matrix identifiers + MXKContactField* firstField = matrixIdField; + if (firstField) + { + if (firstField.avatarImage) + { + matrixThumbnail = firstField.avatarImage; + _matrixAvatarURL = firstField.matrixAvatarURL; + return matrixThumbnail; + } + } + + // try to replace the thumbnail by the matrix one + if (_emailAddresses.count > 0) + { + // list the linked email + // search if one email field has a dedicated thumbnail + for (MXKEmail* email in _emailAddresses) + { + if (email.avatarImage) + { + matrixThumbnail = email.avatarImage; + _matrixAvatarURL = email.matrixAvatarURL; + return matrixThumbnail; + } + else if (!firstField && email.matrixID) + { + firstField = email; + } + } + } + + if (_phoneNumbers.count > 0) + { + // list the linked phones + // search if one phone field has a dedicated thumbnail + for (MXKPhoneNumber* phoneNb in _phoneNumbers) + { + if (phoneNb.avatarImage) + { + matrixThumbnail = phoneNb.avatarImage; + _matrixAvatarURL = phoneNb.matrixAvatarURL; + return matrixThumbnail; + } + else if (!firstField && phoneNb.matrixID) + { + firstField = phoneNb; + } + } + } + + // if no thumbnail has been found + // try to load the first field one + if (firstField) + { + // should be retrieved by the cell info + [firstField loadAvatarWithSize:size]; + } + + return nil; +} + +- (UIImage*)thumbnail +{ + return [self thumbnailWithPreferedSize:CGSizeMake(256, 256)]; +} + +#pragma mark NSCoding + +- (id)initWithCoder:(NSCoder *)coder +{ + _contactID = [coder decodeObjectForKey:@"contactID"]; + _displayName = [coder decodeObjectForKey:@"displayName"]; + + matrixIdField = [coder decodeObjectForKey:@"matrixIdField"]; + + _phoneNumbers = [coder decodeObjectForKey:@"phoneNumbers"]; + _emailAddresses = [coder decodeObjectForKey:@"emailAddresses"]; + + NSData *data = [coder decodeObjectForKey:@"contactThumbnail"]; + if (!data) + { + // Check the legacy storage. + data = [coder decodeObjectForKey:@"contactBookThumbnail"]; + } + + if (data) + { + contactThumbnail = [UIImage imageWithData:data]; + } + + return self; +} + +- (void)encodeWithCoder:(NSCoder *)coder +{ + + [coder encodeObject:_contactID forKey:@"contactID"]; + [coder encodeObject:_displayName forKey:@"displayName"]; + + if (matrixIdField) + { + [coder encodeObject:matrixIdField forKey:@"matrixIdField"]; + } + + if (_phoneNumbers.count) + { + [coder encodeObject:_phoneNumbers forKey:@"phoneNumbers"]; + } + + if (_emailAddresses.count) + { + [coder encodeObject:_emailAddresses forKey:@"emailAddresses"]; + } + + if (contactThumbnail) + { + @autoreleasepool + { + NSData *data = UIImageJPEGRepresentation(contactThumbnail, 0.8); + [coder encodeObject:data forKey:@"contactThumbnail"]; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKContactField.h b/Riot/Modules/MatrixKit/Models/Contact/MXKContactField.h new file mode 100644 index 000000000..0fa4107d2 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKContactField.h @@ -0,0 +1,50 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +@interface MXKContactField : NSObject + +/** + The identifier of the contact to whom the data belongs to. + */ +@property (nonatomic, readonly) NSString *contactID; +/** + The linked matrix identifier if any + */ +@property (nonatomic, readwrite) NSString *matrixID; +/** + The matrix avatar url (Matrix Content URI), nil by default. + */ +@property (nonatomic) NSString* matrixAvatarURL; +/** + The current avatar downloaded by using the avatar url if any + */ +@property (nonatomic, readonly) UIImage *avatarImage; + +- (id)initWithContactID:(NSString*)contactID matrixID:(NSString*)matrixID; + +- (void)loadAvatarWithSize:(CGSize)avatarSize; + +/** + Reset the current avatar. May be used in case of the matrix avatar url change. + A new avatar will be automatically restored from the matrix data. + */ +- (void)resetMatrixAvatar; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKContactField.m b/Riot/Modules/MatrixKit/Models/Contact/MXKContactField.m new file mode 100644 index 000000000..b1850878e --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKContactField.m @@ -0,0 +1,235 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKContactField.h" + +@import MatrixSDK.MXMediaManager; + +#import "MXKContactManager.h" + +@interface MXKContactField() +{ + // Tell whether we already check the contact avatar definition. + BOOL shouldCheckAvatarURL; + // The media manager of the session used to retrieve the contect avatar url + // This manager is used to download this avatar if need + MXMediaManager *mediaManager; + // The current download id + NSString *downloadId; +} +@end + +@implementation MXKContactField + +- (void)initFields +{ + // init members + _contactID = nil; + _matrixID = nil; + + [self resetMatrixAvatar]; +} + +- (id)initWithContactID:(NSString*)contactID matrixID:(NSString*)matrixID +{ + self = [super init]; + + if (self) + { + [self initFields]; + _contactID = contactID; + _matrixID = matrixID; + } + + return self; +} + +- (void)resetMatrixAvatar +{ + _avatarImage = nil; + _matrixAvatarURL = nil; + shouldCheckAvatarURL = YES; + mediaManager = nil; + downloadId = nil; +} + +- (void)loadAvatarWithSize:(CGSize)avatarSize +{ + // Check whether the avatar image is already set + if (_avatarImage) + { + return; + } + + // Sanity check + if (_matrixID) + { + if (shouldCheckAvatarURL) + { + // Consider here all sessions reported into contact manager + NSArray* mxSessions = [MXKContactManager sharedManager].mxSessions; + + if (mxSessions.count) + { + // Check whether a matrix user is already known + MXUser* user; + MXSession *mxSession; + + for (mxSession in mxSessions) + { + user = [mxSession userWithUserId:_matrixID]; + if (user) + { + _matrixAvatarURL = user.avatarUrl; + if (_matrixAvatarURL) + { + shouldCheckAvatarURL = NO; + mediaManager = mxSession.mediaManager; + [self downloadAvatarImage:avatarSize]; + } + break; + } + } + + // Trigger a server request if this url has not been found. + if (shouldCheckAvatarURL) + { + MXWeakify(self); + [mxSession.matrixRestClient avatarUrlForUser:_matrixID + success:^(NSString *mxAvatarUrl) { + + MXStrongifyAndReturnIfNil(self); + self.matrixAvatarURL = mxAvatarUrl; + self->shouldCheckAvatarURL = NO; + self->mediaManager = mxSession.mediaManager; + [self downloadAvatarImage:avatarSize]; + + } failure:nil]; + } + } + } + else if (_matrixAvatarURL) + { + [self downloadAvatarImage:avatarSize]; + } + // Do nothing if the avatar url has been checked, and it is null. + } +} + +- (void)downloadAvatarImage:(CGSize)avatarSize +{ + // the avatar image is already done + if (_avatarImage) + { + return; + } + + if (_matrixAvatarURL) + { + NSString *cacheFilePath = [MXMediaManager thumbnailCachePathForMatrixContentURI:_matrixAvatarURL + andType:nil + inFolder:kMXMediaManagerAvatarThumbnailFolder + toFitViewSize:avatarSize + withMethod:MXThumbnailingMethodCrop]; + _avatarImage = [MXMediaManager loadPictureFromFilePath:cacheFilePath]; + + // the image is already in the cache + if (_avatarImage) + { + MXWeakify(self); + dispatch_async(dispatch_get_main_queue(), ^{ + MXStrongifyAndReturnIfNil(self); + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactThumbnailUpdateNotification object:self.contactID userInfo:nil]; + }); + } + else + { + NSString *downloadId = [MXMediaManager thumbnailDownloadIdForMatrixContentURI:_matrixAvatarURL inFolder:kMXMediaManagerAvatarThumbnailFolder toFitViewSize:avatarSize withMethod:MXThumbnailingMethodCrop]; + MXMediaLoader* loader = [MXMediaManager existingDownloaderWithIdentifier:downloadId]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMediaDownloadEnd:) name:kMXMediaLoaderStateDidChangeNotification object:loader]; + if (!loader && mediaManager) + { + [mediaManager downloadThumbnailFromMatrixContentURI:_matrixAvatarURL + withType:nil + inFolder:kMXMediaManagerAvatarThumbnailFolder + toFitViewSize:avatarSize + withMethod:MXThumbnailingMethodCrop + success:nil + failure:nil]; + } + } + } +} + +- (void)onMediaDownloadEnd:(NSNotification *)notif +{ + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + if ([loader.downloadId isEqualToString:downloadId]) + { + // update the image + switch (loader.state) { + case MXMediaLoaderStateDownloadCompleted: + { + UIImage *image = [MXMediaManager loadPictureFromFilePath:loader.downloadOutputFilePath]; + if (image) + { + _avatarImage = image; + + MXWeakify(self); + dispatch_async(dispatch_get_main_queue(), ^{ + MXStrongifyAndReturnIfNil(self); + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactThumbnailUpdateNotification object:self.contactID userInfo:nil]; + }); + } + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + downloadId = nil; + break; + } + case MXMediaLoaderStateDownloadFailed: + case MXMediaLoaderStateCancelled: + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + downloadId = nil; + break; + default: + break; + } + } +} + +#pragma mark NSCoding + +- (id)initWithCoder:(NSCoder *)coder +{ + if (self) + { + [self initFields]; + _contactID = [coder decodeObjectForKey:@"contactID"]; + _matrixID = [coder decodeObjectForKey:@"matrixID"]; + } + + return self; +} + +- (void)encodeWithCoder:(NSCoder *)coder +{ + [coder encodeObject:_contactID forKey:@"contactID"]; + [coder encodeObject:_matrixID forKey:@"matrixID"]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKContactManager.h b/Riot/Modules/MatrixKit/Models/Contact/MXKContactManager.h new file mode 100644 index 000000000..c8b0082b0 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKContactManager.h @@ -0,0 +1,243 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import + +#import "MXKSectionedContacts.h" +#import "MXKContact.h" + +/** + Posted when the matrix contact list is loaded or updated. + The notification object is: + - a contact Id when a matrix contact has been added/updated/removed. + or + - nil when all matrix contacts are concerned. + */ +extern NSString * _Nonnull const kMXKContactManagerDidUpdateMatrixContactsNotification; + +/** + Posted when the local contact list is loaded and updated. + The notification object is: + - a contact Id when a local contact has been added/updated/removed. + or + - nil when all local contacts are concerned. + */ +extern NSString * _Nonnull const kMXKContactManagerDidUpdateLocalContactsNotification; + +/** + Posted when local contact matrix ids is updated. + The notification object is: + - a contact Id when a local contact has been added/updated/removed. + or + - nil when all local contacts are concerned. + */ +extern NSString * _Nonnull const kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification; + +/** + Posted when the presence of a matrix user linked at least to one contact has changed. + The notification object is the matrix Id. The `userInfo` dictionary contains an `MXPresenceString` object under the `kMXKContactManagerMatrixPresenceKey` key, representing the matrix user presence. + */ +extern NSString * _Nonnull const kMXKContactManagerMatrixUserPresenceChangeNotification; +extern NSString * _Nonnull const kMXKContactManagerMatrixPresenceKey; + +/** + Posted when all phonenumbers of local contacts have been internationalized. + The notification object is nil. + */ +extern NSString * _Nonnull const kMXKContactManagerDidInternationalizeNotification; + +/** + Used to identify the type of data when requesting MXKeyProvider + */ +extern NSString * _Nonnull const MXKContactManagerDataType; + +/** + Define the contact creation for the room members + */ +typedef NS_ENUM(NSInteger, MXKContactManagerMXRoomSource) { + MXKContactManagerMXRoomSourceNone = 0, // the MXMember does not create any new contact. + MXKContactManagerMXRoomSourceDirectChats = 1, // the direct chat users have their own contact even if they are not defined in the device contacts book + MXKContactManagerMXRoomSourceAll = 2, // all the room members have their own contact even if they are not defined in the device contacts book +}; + +/** + This manager handles 2 kinds of contact list: + - The local contacts retrieved from the device phonebook. + - The matrix contacts retrieved from the matrix one-to-one rooms. + + Note: The local contacts handling depends on the 'syncLocalContacts' and 'phonebookCountryCode' properties + of the shared application settings object '[MXKAppSettings standardAppSettings]'. + */ +@interface MXKContactManager : NSObject + +/** + The shared instance of contact manager. + */ ++ (MXKContactManager* _Nonnull)sharedManager; + +/** + Block called (if any) to discover the Matrix users bound to a set of third-party identifiers (email addresses, phone numbers). + If this property is unset, the contact manager will consider the potential identity server URL (see the `identityServer` property) + to build its own Restclient and trigger `lookup3PIDs` requests. + + @param threepids the list of 3rd party ids: [[<(MX3PIDMedium)media1>, <(NSString*)address1>], [<(MX3PIDMedium)media2>, <(NSString*)address2>], ...]. + @param success a block object called when the operation succeeds. It provides the array of the discovered users: + [[<(MX3PIDMedium)media>, <(NSString*)address>, <(NSString*)userId>], ...]. + @param failure a block object called when the operation fails. + */ +typedef void(^MXKContactManagerDiscoverUsersBoundTo3PIDs)(NSArray *> * _Nonnull threepids, + void (^ _Nonnull success)(NSArray *> *_Nonnull), + void (^ _Nonnull failure)(NSError *_Nonnull)); +@property (nonatomic, nullable) MXKContactManagerDiscoverUsersBoundTo3PIDs discoverUsersBoundTo3PIDsBlock; + +/** + Define if the room member must have their dedicated contact even if they are not define in the device contacts book. + The default value is MXKContactManagerMXRoomSourceDirectChats; + */ +@property (nonatomic) MXKContactManagerMXRoomSource contactManagerMXRoomSource; + +/** + Associated matrix sessions (empty by default). + */ +@property (nonatomic, readonly, nonnull) NSArray *mxSessions; + +/** + The current list of the contacts extracted from matrix data. Depends on 'contactManagerMXRoomSource'. + */ +@property (nonatomic, readonly, nullable) NSArray *matrixContacts; + +/** + The current list of the local contacts (nil by default until the contacts are loaded). + */ +@property (nonatomic, readonly, nullable) NSArray *localContacts; + +/** + The current list of the local contacts who have contact methods which can be used to invite them or to discover matrix users. + */ +@property (nonatomic, readonly, nullable) NSArray *localContactsWithMethods; + +/** + The contacts list obtained by splitting each local contact by contact method. + This list is alphabetically sorted. + Each contact has one and only one contact method. + */ +//- (void)localContactsSplitByContactMethod:(void (^)(NSArray *localContactsSplitByContactMethod))onComplete; + +@property (nonatomic, readonly, nullable) NSArray *localContactsSplitByContactMethod; + +/** + The current list of the contacts for whom a direct chat exists. + */ +@property (nonatomic, readonly, nonnull) NSArray *directMatrixContacts; + +/// Flag to allow local contacts access or not. Default value is YES. +@property (nonatomic, assign) BOOL allowLocalContactsAccess; + +/** + Add/remove matrix session. The matrix contact list is automatically updated (see kMXKContactManagerDidUpdateMatrixContactsNotification event). + */ +- (void)addMatrixSession:(MXSession* _Nonnull)mxSession; +- (void)removeMatrixSession:(MXSession* _Nonnull)mxSession; + +/** + Takes into account the state of the identity service's terms, local contacts access authorization along with + whether the user has left the app for the Settings app to update the contacts access, and enables/disables + the `syncLocalContacts` property of `MXKAppSettings` when necessary. + @param mxSession The session who's identity service shall be used. + */ +- (void)validateSyncLocalContactsStateForSession:(MXSession *)mxSession; + +/** + Load and/or refresh the local contacts. Observe kMXKContactManagerDidUpdateLocalContactsNotification to know when local contacts are available. + */ +- (void)refreshLocalContacts; + +/** + Delete contacts info + */ +- (void)reset; + +/** + Get contact by its identifier. + + @param contactID the contact identifier. + @return the contact defined with the provided id. + */ +- (MXKContact* _Nullable)contactWithContactID:(NSString* _Nonnull)contactID; + +/** + Refresh matrix IDs for a specific local contact. See kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification + posted when update is done. + + @param contact the local contact to refresh. + */ +- (void)updateMatrixIDsForLocalContact:(MXKContact* _Nonnull)contact; + +/** + Refresh matrix IDs for all local contacts. See kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification + posted when update for all local contacts is done. + */ +- (void)updateMatrixIDsForAllLocalContacts; + +/** + The contacts list obtained by splitting each local contact by contact method. + This list is alphabetically sorted. + Each contact has one and only one contact method. + */ +//- (void)localContactsSplitByContactMethod:(void (^)(NSArray *localContactsSplitByContactMethod))onComplete; + +/** + Sort a contacts array in sectioned arrays to be displayable in a UITableview + */ +- (MXKSectionedContacts* _Nullable)getSectionedContacts:(NSArray* _Nonnull)contactList; + +/** + Sort alphabetically an array of contacts. + + @param contactsArray the array of contacts to sort. + */ +- (void)sortAlphabeticallyContacts:(NSMutableArray * _Nonnull)contactsArray; + +/** + Sort an array of contacts by last active, with "active now" first. + ...and then alphabetically. + + @param contactsArray the array of contacts to sort. + */ +- (void)sortContactsByLastActiveInformation:(NSMutableArray * _Nonnull)contactsArray; + +/** + Refresh the international phonenumber of the local contacts (See kMXKContactManagerDidInternationalizeNotification). + + @param countryCode the country code. + */ +- (void)internationalizePhoneNumbers:(NSString* _Nonnull)countryCode; + +/** + Request user permission for syncing local contacts. + + @param viewController the view controller to attach the dialog to the user. + @param handler the block called with the result of requesting access + */ ++ (void)requestUserConfirmationForLocalContactsSyncInViewController:(UIViewController* _Nonnull)viewController + completionHandler:(void (^_Nonnull)(BOOL granted))handler; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKContactManager.m b/Riot/Modules/MatrixKit/Models/Contact/MXKContactManager.m new file mode 100644 index 000000000..52ac60a68 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKContactManager.m @@ -0,0 +1,1939 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKContactManager.h" + +#import "MXKContact.h" + +#import "MXKAppSettings.h" +#import "MXKTools.h" +#import "NSBundle+MatrixKit.h" +#import +#import +#import + +#import "MXKSwiftHeader.h" + +NSString *const kMXKContactManagerDidUpdateMatrixContactsNotification = @"kMXKContactManagerDidUpdateMatrixContactsNotification"; + +NSString *const kMXKContactManagerDidUpdateLocalContactsNotification = @"kMXKContactManagerDidUpdateLocalContactsNotification"; +NSString *const kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification = @"kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification"; + +NSString *const kMXKContactManagerMatrixUserPresenceChangeNotification = @"kMXKContactManagerMatrixUserPresenceChangeNotification"; +NSString *const kMXKContactManagerMatrixPresenceKey = @"kMXKContactManagerMatrixPresenceKey"; + +NSString *const kMXKContactManagerDidInternationalizeNotification = @"kMXKContactManagerDidInternationalizeNotification"; + +NSString *const MXKContactManagerDataType = @"org.matrix.kit.MXKContactManagerDataType"; + +@interface MXKContactManager() +{ + /** + Array of `MXSession` instances. + */ + NSMutableArray *mxSessionArray; + id mxSessionStateObserver; + id mxSessionNewSyncedRoomObserver; + + /** + Listeners registered on matrix presence and membership events (one by matrix session) + */ + NSMutableArray *mxEventListeners; + + /** + Local contacts handling + */ + BOOL isLocalContactListRefreshing; + dispatch_queue_t processingQueue; + NSDate *lastSyncDate; + // Local contacts by contact Id + NSMutableDictionary* localContactByContactID; + NSMutableArray* localContactsWithMethods; + NSMutableArray* splitLocalContacts; + + // Matrix id linked to 3PID. + NSMutableDictionary *matrixIDBy3PID; + + /** + Matrix contacts handling + */ + // Matrix contacts by contact Id + NSMutableDictionary* matrixContactByContactID; + // Matrix contacts by matrix id + NSMutableDictionary* matrixContactByMatrixID; +} + +@end + +@implementation MXKContactManager +@synthesize contactManagerMXRoomSource; + +#pragma mark Singleton Methods + ++ (instancetype)sharedManager +{ + static MXKContactManager *sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedInstance = [[MXKContactManager alloc] init]; + }); + return sharedInstance; +} + +#pragma mark - + +-(MXKContactManager *)init +{ + if (self = [super init]) + { + NSString *label = [NSString stringWithFormat:@"MatrixKit.%@.Contacts", [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleName"]]; + + [self deleteOldFiles]; + + processingQueue = dispatch_queue_create([label UTF8String], DISPATCH_QUEUE_SERIAL); + + // save the last sync date + // to avoid resync the whole phonebook + lastSyncDate = nil; + + self.contactManagerMXRoomSource = MXKContactManagerMXRoomSourceDirectChats; + + // Observe related settings change + [[MXKAppSettings standardAppSettings] addObserver:self forKeyPath:@"syncLocalContacts" options:0 context:nil]; + [[MXKAppSettings standardAppSettings] addObserver:self forKeyPath:@"phonebookCountryCode" options:0 context:nil]; + + [self registerAccountDataDidChangeIdentityServerNotification]; + self.allowLocalContactsAccess = YES; + } + + return self; +} + +-(void)dealloc +{ + matrixIDBy3PID = nil; + + localContactByContactID = nil; + localContactsWithMethods = nil; + splitLocalContacts = nil; + + matrixContactByContactID = nil; + matrixContactByMatrixID = nil; + + lastSyncDate = nil; + + while (mxSessionArray.count) { + [self removeMatrixSession:mxSessionArray.lastObject]; + } + mxSessionArray = nil; + mxEventListeners = nil; + + [[MXKAppSettings standardAppSettings] removeObserver:self forKeyPath:@"syncLocalContacts"]; + [[MXKAppSettings standardAppSettings] removeObserver:self forKeyPath:@"phonebookCountryCode"]; + + processingQueue = nil; +} + +#pragma mark - + +- (void)addMatrixSession:(MXSession*)mxSession +{ + if (!mxSessionArray) + { + mxSessionArray = [NSMutableArray array]; + } + if (!mxEventListeners) + { + mxEventListeners = [NSMutableArray array]; + } + + if ([mxSessionArray indexOfObject:mxSession] == NSNotFound) + { + [mxSessionArray addObject:mxSession]; + + MXWeakify(self); + + // Register a listener on matrix presence and membership events + id eventListener = [mxSession listenToEventsOfTypes:@[kMXEventTypeStringRoomMember, kMXEventTypeStringPresence] + onEvent:^(MXEvent *event, MXTimelineDirection direction, id customObject) { + + MXStrongifyAndReturnIfNil(self); + + // Consider only live event + if (direction == MXTimelineDirectionForwards) + { + // Consider first presence events + if (event.eventType == MXEventTypePresence) + { + // Check whether the concerned matrix user belongs to at least one contact. + BOOL isMatched = ([self->matrixContactByMatrixID objectForKey:event.sender] != nil); + if (!isMatched) + { + NSArray *matrixIDs = [self->matrixIDBy3PID allValues]; + isMatched = ([matrixIDs indexOfObject:event.sender] != NSNotFound); + } + + if (isMatched) { + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerMatrixUserPresenceChangeNotification object:event.sender userInfo:@{kMXKContactManagerMatrixPresenceKey:event.content[@"presence"]}]; + } + } + // Else the event type is MXEventTypeRoomMember. + // Ignore here membership events if the session is not running yet, + // Indeed all the contacts are refreshed when session state becomes running. + else if (mxSession.state == MXSessionStateRunning) + { + // Update matrix contact list on membership change + [self updateMatrixContactWithID:event.sender]; + } + } + }]; + + [mxEventListeners addObject:eventListener]; + + // Update matrix contact list in case of new synced one-to-one room + if (!mxSessionNewSyncedRoomObserver) + { + mxSessionNewSyncedRoomObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXRoomInitialSyncNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXStrongifyAndReturnIfNil(self); + + // create contact for known room members + if (self.contactManagerMXRoomSource != MXKContactManagerMXRoomSourceNone) + { + MXRoom *room = notif.object; + [room state:^(MXRoomState *roomState) { + + MXRoomMembers *roomMembers = roomState.members; + + NSArray *members = roomMembers.members; + + // Consider only 1:1 chat for MXKMemberContactCreationOneToOneRoom + // or adding all + if (((members.count == 2) && (self.contactManagerMXRoomSource == MXKContactManagerMXRoomSourceDirectChats)) || (self.contactManagerMXRoomSource == MXKContactManagerMXRoomSourceAll)) + { + NSString* myUserId = room.mxSession.myUser.userId; + + for (MXRoomMember* member in members) + { + if ([member.userId isEqualToString:myUserId]) + { + [self updateMatrixContactWithID:member.userId]; + } + } + } + }]; + } + }]; + } + + // Update all matrix contacts as soon as matrix session is ready + if (!mxSessionStateObserver) { + mxSessionStateObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionStateDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXStrongifyAndReturnIfNil(self); + + MXSession *mxSession = notif.object; + + if ([self->mxSessionArray indexOfObject:mxSession] != NSNotFound) + { + if ((mxSession.state == MXSessionStateStoreDataReady) || (mxSession.state == MXSessionStateRunning)) { + [self refreshMatrixContacts]; + } + } + }]; + } + + // refreshMatrixContacts can take time. Delay its execution to not overload + // launch of apps that call [MXKContactManager addMatrixSession] at startup + dispatch_async(dispatch_get_main_queue(), ^{ + [self refreshMatrixContacts]; + }); + } +} + +- (void)removeMatrixSession:(MXSession*)mxSession +{ + NSUInteger index = [mxSessionArray indexOfObject:mxSession]; + if (index != NSNotFound) + { + id eventListener = [mxEventListeners objectAtIndex:index]; + [mxSession removeListener:eventListener]; + + [mxEventListeners removeObjectAtIndex:index]; + [mxSessionArray removeObjectAtIndex:index]; + + if (!mxSessionArray.count) { + if (mxSessionStateObserver) { + [[NSNotificationCenter defaultCenter] removeObserver:mxSessionStateObserver]; + mxSessionStateObserver = nil; + } + + if (mxSessionNewSyncedRoomObserver) { + [[NSNotificationCenter defaultCenter] removeObserver:mxSessionNewSyncedRoomObserver]; + mxSessionNewSyncedRoomObserver = nil; + } + } + + // Update matrix contacts list + [self refreshMatrixContacts]; + } +} + +- (NSArray*)mxSessions +{ + return [NSArray arrayWithArray:mxSessionArray]; +} + + +- (NSArray*)matrixContacts +{ + NSParameterAssert([NSThread isMainThread]); + + return [matrixContactByContactID allValues]; +} + +- (NSArray*)localContacts +{ + NSParameterAssert([NSThread isMainThread]); + + // Return nil if the loading step is in progress. + if (isLocalContactListRefreshing) + { + return nil; + } + + return [localContactByContactID allValues]; +} + +- (NSArray*)localContactsWithMethods +{ + NSParameterAssert([NSThread isMainThread]); + + // Return nil if the loading step is in progress. + if (isLocalContactListRefreshing) + { + return nil; + } + + // Check whether the array must be prepared + if (!localContactsWithMethods) + { + // List all the local contacts with emails and/or phones + NSArray *localContacts = self.localContacts; + localContactsWithMethods = [NSMutableArray arrayWithCapacity:localContacts.count]; + + for (MXKContact* contact in localContacts) + { + if (contact.emailAddresses) + { + [localContactsWithMethods addObject:contact]; + } + else if (contact.phoneNumbers) + { + [localContactsWithMethods addObject:contact]; + } + } + } + + return localContactsWithMethods; +} + +- (NSArray*)localContactsSplitByContactMethod +{ + NSParameterAssert([NSThread isMainThread]); + + // Return nil if the loading step is in progress. + if (isLocalContactListRefreshing) + { + return nil; + } + + // Check whether the array must be prepared + if (!splitLocalContacts) + { + // List all the local contacts with contact methods + NSArray *contactsArray = self.localContactsWithMethods; + + splitLocalContacts = [NSMutableArray arrayWithCapacity:contactsArray.count]; + + for (MXKContact* contact in contactsArray) + { + NSArray *emails = contact.emailAddresses; + NSArray *phones = contact.phoneNumbers; + + if (emails.count + phones.count > 1) + { + for (MXKEmail *email in emails) + { + MXKContact *splitContact = [[MXKContact alloc] initContactWithDisplayName:contact.displayName emails:@[email] phoneNumbers:nil andThumbnail:contact.thumbnail]; + [splitLocalContacts addObject:splitContact]; + } + + for (MXKPhoneNumber *phone in phones) + { + MXKContact *splitContact = [[MXKContact alloc] initContactWithDisplayName:contact.displayName emails:nil phoneNumbers:@[phone] andThumbnail:contact.thumbnail]; + [splitLocalContacts addObject:splitContact]; + } + } + else if (emails.count + phones.count) + { + [splitLocalContacts addObject:contact]; + } + } + + // Sort alphabetically the resulting list + [self sortAlphabeticallyContacts:splitLocalContacts]; + } + + return splitLocalContacts; +} + + +//- (void)localContactsSplitByContactMethod:(void (^)(NSArray *localContactsSplitByContactMethod))onComplete +//{ +// NSParameterAssert([NSThread isMainThread]); +// +// // Return nil if the loading step is in progress. +// if (isLocalContactListRefreshing) +// { +// onComplete(nil); +// return; +// } +// +// // Check whether the array must be prepared +// if (!splitLocalContacts) +// { +// // List all the local contacts with contact methods +// NSArray *contactsArray = self.localContactsWithMethods; +// +// splitLocalContacts = [NSMutableArray arrayWithCapacity:contactsArray.count]; +// +// for (MXKContact* contact in contactsArray) +// { +// NSArray *emails = contact.emailAddresses; +// NSArray *phones = contact.phoneNumbers; +// +// if (emails.count + phones.count > 1) +// { +// for (MXKEmail *email in emails) +// { +// MXKContact *splitContact = [[MXKContact alloc] initContactWithDisplayName:contact.displayName emails:@[email] phoneNumbers:nil andThumbnail:contact.thumbnail]; +// [splitLocalContacts addObject:splitContact]; +// } +// +// for (MXKPhoneNumber *phone in phones) +// { +// MXKContact *splitContact = [[MXKContact alloc] initContactWithDisplayName:contact.displayName emails:nil phoneNumbers:@[phone] andThumbnail:contact.thumbnail]; +// [splitLocalContacts addObject:splitContact]; +// } +// } +// else if (emails.count + phones.count) +// { +// [splitLocalContacts addObject:contact]; +// } +// } +// +// // Sort alphabetically the resulting list +// [self sortAlphabeticallyContacts:splitLocalContacts]; +// } +// +// onComplete(splitLocalContacts); +//} + +- (NSArray*)directMatrixContacts +{ + NSParameterAssert([NSThread isMainThread]); + + NSMutableDictionary *directContacts = [NSMutableDictionary dictionary]; + + NSArray *mxSessions = self.mxSessions; + + for (MXSession *mxSession in mxSessions) + { + // Check all existing users for whom a direct chat exists + NSArray *mxUserIds = mxSession.directRooms.allKeys; + + for (NSString *mxUserId in mxUserIds) + { + MXKContact* contact = [matrixContactByMatrixID objectForKey:mxUserId]; + + // Sanity check - the contact must be already defined here + if (contact) + { + [directContacts setValue:contact forKey:mxUserId]; + } + } + } + + return directContacts.allValues; +} + +// The current identity service used with the contact manager +- (MXIdentityService*)identityService +{ + // For the moment, only use the one of the first session + MXSession *mxSession = [mxSessionArray firstObject]; + return mxSession.identityService; +} + +- (BOOL)isUsersDiscoveringEnabled +{ + // Check whether the 3pid lookup is available + return (self.discoverUsersBoundTo3PIDsBlock || self.identityService); +} + +#pragma mark - + +- (void)validateSyncLocalContactsStateForSession:(MXSession *)mxSession +{ + if (!self.allowLocalContactsAccess) + { + return; + } + + // Get the status of the identity service terms. + BOOL areAllTermsAgreed = mxSession.identityService.areAllTermsAgreed; + + if (MXKAppSettings.standardAppSettings.syncLocalContacts) + { + // Disable local contact sync when all terms are no longer accepted or if contacts access has been revoked. + if (!areAllTermsAgreed || [CNContactStore authorizationStatusForEntityType:CNEntityTypeContacts] != CNAuthorizationStatusAuthorized) + { + MXLogDebug(@"[MXKContactManager] validateSyncLocalContactsState : Disabling contacts sync."); + MXKAppSettings.standardAppSettings.syncLocalContacts = false; + return; + } + } + else + { + // Check whether the user has been directed to the Settings app to enable contact access. + if (MXKAppSettings.standardAppSettings.syncLocalContactsPermissionOpenedSystemSettings) + { + // Reset the system settings app flag as they are back in the app. + MXKAppSettings.standardAppSettings.syncLocalContactsPermissionOpenedSystemSettings = false; + + // And if all other conditions are met for contacts sync enable it. + if (areAllTermsAgreed && [CNContactStore authorizationStatusForEntityType:CNEntityTypeContacts] == CNAuthorizationStatusAuthorized) + { + MXLogDebug(@"[MXKContactManager] validateSyncLocalContactsState : Enabling contacts sync after user visited Settings app."); + MXKAppSettings.standardAppSettings.syncLocalContacts = true; + } + } + } +} + +- (void)refreshLocalContacts +{ + MXLogDebug(@"[MXKContactManager] refreshLocalContacts : Started"); + + if (!self.allowLocalContactsAccess) + { + MXLogDebug(@"[MXKContactManager] refreshLocalContacts : Finished because local contacts access not allowed."); + return; + } + + NSDate *startDate = [NSDate date]; + + if ([CNContactStore authorizationStatusForEntityType:CNEntityTypeContacts] != CNAuthorizationStatusAuthorized) + { + if ([MXKAppSettings standardAppSettings].syncLocalContacts) + { + // The user authorised syncLocalContacts and allowed access to his contacts + // but he then removed contacts access from app permissions. + // So, reset syncLocalContacts value + [MXKAppSettings standardAppSettings].syncLocalContacts = NO; + } + + // Local contacts list is empty if the access is denied. + self->localContactByContactID = nil; + self->localContactsWithMethods = nil; + self->splitLocalContacts = nil; + [self cacheLocalContacts]; + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateLocalContactsNotification object:nil userInfo:nil]; + + MXLogDebug(@"[MXKContactManager] refreshLocalContacts : Complete"); + MXLogDebug(@"[MXKContactManager] refreshLocalContacts : Local contacts access denied"); + } + else + { + self->isLocalContactListRefreshing = YES; + + // Reset the internal contact lists (These arrays will be prepared only if need). + self->localContactsWithMethods = self->splitLocalContacts = nil; + + BOOL isColdStart = NO; + + // Check whether the local contacts sync has been disabled. + if (self->matrixIDBy3PID && ![MXKAppSettings standardAppSettings].syncLocalContacts) + { + // The user changed his mind and disabled the local contact sync, remove the cached data. + self->matrixIDBy3PID = nil; + [self cacheMatrixIDsDict]; + + // Reload the local contacts from the system + self->localContactByContactID = nil; + [self cacheLocalContacts]; + } + + // Check whether this is a cold start. + if (!self->matrixIDBy3PID) + { + isColdStart = YES; + + // Load the dictionary from the file system. It is cached to improve UX. + [self loadCachedMatrixIDsDict]; + } + + MXWeakify(self); + + dispatch_async(self->processingQueue, ^{ + + MXStrongifyAndReturnIfNil(self); + + // In case of cold start, retrieve the data from the file system + if (isColdStart) + { + [self loadCachedLocalContacts]; + [self loadCachedContactBookInfo]; + + // no local contact -> assume that the last sync date is useless + if (self->localContactByContactID.count == 0) + { + self->lastSyncDate = nil; + } + } + + BOOL didContactBookChange = NO; + + NSMutableArray* deletedContactIDs = [NSMutableArray arrayWithArray:[self->localContactByContactID allKeys]]; + + // can list local contacts? + if (ABAddressBookGetAuthorizationStatus() == kABAuthorizationStatusAuthorized) + { + NSString* countryCode = [[MXKAppSettings standardAppSettings] phonebookCountryCode]; + + ABAddressBookRef ab = ABAddressBookCreateWithOptions(nil, nil); + ABRecordRef contactRecord; + CFIndex index; + CFMutableArrayRef people = (CFMutableArrayRef)ABAddressBookCopyArrayOfAllPeople(ab); + + if (nil != people) + { + CFIndex peopleCount = CFArrayGetCount(people); + + for (index = 0; index < peopleCount; index++) + { + contactRecord = (ABRecordRef)CFArrayGetValueAtIndex(people, index); + + NSString* contactID = [MXKContact contactID:contactRecord]; + + // the contact still exists + [deletedContactIDs removeObject:contactID]; + + if (self->lastSyncDate) + { + // ignore unchanged contacts since the previous sync + CFDateRef lastModifDate = ABRecordCopyValue(contactRecord, kABPersonModificationDateProperty); + if (lastModifDate) + { + if (kCFCompareGreaterThan != CFDateCompare(lastModifDate, (__bridge CFDateRef)self->lastSyncDate, nil)) + + { + CFRelease(lastModifDate); + continue; + } + CFRelease(lastModifDate); + } + } + + didContactBookChange = YES; + + MXKContact* contact = [[MXKContact alloc] initLocalContactWithABRecord:contactRecord]; + + if (countryCode) + { + contact.defaultCountryCode = countryCode; + } + + // update the local contacts list + [self->localContactByContactID setValue:contact forKey:contactID]; + } + + CFRelease(people); + } + + if (ab) + { + CFRelease(ab); + } + } + + // some contacts have been deleted + for (NSString* contactID in deletedContactIDs) + { + didContactBookChange = YES; + [self->localContactByContactID removeObjectForKey:contactID]; + } + + // something has been modified in the local contact book + if (didContactBookChange) + { + [self cacheLocalContacts]; + } + + self->lastSyncDate = [NSDate date]; + [self cacheContactBookInfo]; + + // Update loaded contacts with the known dict 3PID -> matrix ID + [self updateAllLocalContactsMatrixIDs]; + + dispatch_async(dispatch_get_main_queue(), ^{ + + // Contacts are loaded, post a notification + self->isLocalContactListRefreshing = NO; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateLocalContactsNotification object:nil userInfo:nil]; + + // Check the conditions required before triggering a matrix users lookup. + if (isColdStart || didContactBookChange) + { + [self updateMatrixIDsForAllLocalContacts]; + } + + MXLogDebug(@"[MXKContactManager] refreshLocalContacts : Complete"); + MXLogDebug(@"[MXKContactManager] refreshLocalContacts : Refresh %tu local contacts in %.0fms", self->localContactByContactID.count, [[NSDate date] timeIntervalSinceDate:startDate] * 1000); + }); + }); + } +} + +- (void)updateMatrixIDsForLocalContact:(MXKContact *)contact +{ + // Check if the user allowed to sync local contacts. + // + Check whether users discovering is available. + if ([MXKAppSettings standardAppSettings].syncLocalContacts && !contact.isMatrixContact && [self isUsersDiscoveringEnabled]) + { + // Retrieve all 3PIDs of the contact + NSMutableArray* threepids = [[NSMutableArray alloc] init]; + NSMutableArray* lookup3pidsArray = [[NSMutableArray alloc] init]; + + for (MXKEmail* email in contact.emailAddresses) + { + // Not yet added + if (email.emailAddress.length && [threepids indexOfObject:email.emailAddress] == NSNotFound) + { + [lookup3pidsArray addObject:@[kMX3PIDMediumEmail, email.emailAddress]]; + [threepids addObject:email.emailAddress]; + } + } + + for (MXKPhoneNumber* phone in contact.phoneNumbers) + { + if (phone.msisdn) + { + [lookup3pidsArray addObject:@[kMX3PIDMediumMSISDN, phone.msisdn]]; + [threepids addObject:phone.msisdn]; + } + } + + if (lookup3pidsArray.count > 0) + { + MXWeakify(self); + + void (^success)(NSArray *> *) = ^(NSArray *> *discoveredUsers) { + MXStrongifyAndReturnIfNil(self); + + // Look for updates + BOOL isUpdated = NO; + + // Consider each discored user + for (NSArray *discoveredUser in discoveredUsers) + { + // Sanity check + if (discoveredUser.count == 3) + { + NSString *pid = discoveredUser[1]; + NSString *matrixId = discoveredUser[2]; + + // Remove the 3pid from the requested list + [threepids removeObject:pid]; + + NSString *currentMatrixID = [self->matrixIDBy3PID objectForKey:pid]; + + if (![currentMatrixID isEqualToString:matrixId]) + { + [self->matrixIDBy3PID setObject:matrixId forKey:pid]; + isUpdated = YES; + } + } + } + + // Remove existing information which is not valid anymore + for (NSString *pid in threepids) + { + if ([self->matrixIDBy3PID objectForKey:pid]) + { + [self->matrixIDBy3PID removeObjectForKey:pid]; + isUpdated = YES; + } + } + + if (isUpdated) + { + [self cacheMatrixIDsDict]; + + // Update only this contact + [self updateLocalContactMatrixIDs:contact]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification object:contact.contactID userInfo:nil]; + }); + } + }; + + void (^failure)(NSError *) = ^(NSError *error) { + MXLogDebug(@"[MXKContactManager] updateMatrixIDsForLocalContact failed"); + }; + + if (self.discoverUsersBoundTo3PIDsBlock) + { + self.discoverUsersBoundTo3PIDsBlock(lookup3pidsArray, success, failure); + } + else + { + // Consider the potential identity server url by default + [self.identityService lookup3pids:lookup3pidsArray + success:success + failure:failure]; + } + } + } +} + + +- (void)updateMatrixIDsForAllLocalContacts +{ + // If localContactByContactID is not loaded, the manager will consider there is no local contacts + // and will reset its cache + NSAssert(localContactByContactID, @"[MXKContactManager] updateMatrixIDsForAllLocalContacts: refreshLocalContacts must be called before"); + + // Check if the user allowed to sync local contacts. + // + Check if at least an identity server is available, and if the loading step is not in progress. + if (![MXKAppSettings standardAppSettings].syncLocalContacts || ![self isUsersDiscoveringEnabled] || isLocalContactListRefreshing) + { + return; + } + + MXWeakify(self); + + // Refresh the 3PIDs -> Matrix ID mapping + dispatch_async(processingQueue, ^{ + + MXStrongifyAndReturnIfNil(self); + + NSArray* contactsSnapshot = [self->localContactByContactID allValues]; + + // Retrieve all 3PIDs + NSMutableArray* threepids = [[NSMutableArray alloc] init]; + NSMutableArray* lookup3pidsArray = [[NSMutableArray alloc] init]; + + for (MXKContact* contact in contactsSnapshot) + { + for (MXKEmail* email in contact.emailAddresses) + { + // Not yet added + if (email.emailAddress.length && [threepids indexOfObject:email.emailAddress] == NSNotFound) + { + [lookup3pidsArray addObject:@[kMX3PIDMediumEmail, email.emailAddress]]; + [threepids addObject:email.emailAddress]; + } + } + + for (MXKPhoneNumber* phone in contact.phoneNumbers) + { + if (phone.msisdn) + { + // Not yet added + if ([threepids indexOfObject:phone.msisdn] == NSNotFound) + { + [lookup3pidsArray addObject:@[kMX3PIDMediumMSISDN, phone.msisdn]]; + [threepids addObject:phone.msisdn]; + } + } + } + } + + // Update 3PIDs mapping + if (lookup3pidsArray.count > 0) + { + MXWeakify(self); + + void (^success)(NSArray *> *) = ^(NSArray *> *discoveredUsers) { + MXStrongifyAndReturnIfNil(self); + + [threepids removeAllObjects]; + NSMutableArray* userIds = [[NSMutableArray alloc] init]; + + // Consider each discored user + for (NSArray *discoveredUser in discoveredUsers) + { + // Sanity check + if (discoveredUser.count == 3) + { + id threepid = discoveredUser[1]; + id userId = discoveredUser[2]; + + if ([threepid isKindOfClass:[NSString class]] && [userId isKindOfClass:[NSString class]]) + { + [threepids addObject:threepid]; + [userIds addObject:userId]; + } + } + } + + if (userIds.count) + { + self->matrixIDBy3PID = [[NSMutableDictionary alloc] initWithObjects:userIds forKeys:threepids]; + } + else + { + self->matrixIDBy3PID = nil; + } + + [self cacheMatrixIDsDict]; + + [self updateAllLocalContactsMatrixIDs]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification object:nil userInfo:nil]; + }); + }; + + void (^failure)(NSError *) = ^(NSError *error) { + MXLogDebug(@"[MXKContactManager] updateMatrixIDsForAllLocalContacts failed"); + }; + + if (self.discoverUsersBoundTo3PIDsBlock) + { + self.discoverUsersBoundTo3PIDsBlock(lookup3pidsArray, success, failure); + } + else if (self.identityService) + { + [self.identityService lookup3pids:lookup3pidsArray + success:success + failure:failure]; + } + else + { + // No IS, no detection of Matrix users in local contacts + self->matrixIDBy3PID = nil; + [self cacheMatrixIDsDict]; + } + } + else + { + self->matrixIDBy3PID = nil; + [self cacheMatrixIDsDict]; + } + }); +} + +- (void)resetMatrixIDs +{ + dispatch_async(processingQueue, ^{ + + self->matrixIDBy3PID = nil; + [self cacheMatrixIDsDict]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification object:nil userInfo:nil]; + }); + }); +} + +- (void)reset +{ + matrixIDBy3PID = nil; + [self cacheMatrixIDsDict]; + + isLocalContactListRefreshing = NO; + localContactByContactID = nil; + localContactsWithMethods = nil; + splitLocalContacts = nil; + [self cacheLocalContacts]; + + matrixContactByContactID = nil; + matrixContactByMatrixID = nil; + [self cacheMatrixContacts]; + + lastSyncDate = nil; + [self cacheContactBookInfo]; + + while (mxSessionArray.count) { + [self removeMatrixSession:mxSessionArray.lastObject]; + } + mxSessionArray = nil; + mxEventListeners = nil; + + // warn of the contacts list update + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateMatrixContactsNotification object:nil userInfo:nil]; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateLocalContactsNotification object:nil userInfo:nil]; +} + +- (MXKContact*)contactWithContactID:(NSString*)contactID +{ + if ([contactID hasPrefix:kMXKContactLocalContactPrefixId]) + { + return [localContactByContactID objectForKey:contactID]; + } + else + { + return [matrixContactByContactID objectForKey:contactID]; + } +} + +// refresh the international phonenumber of the contacts +- (void)internationalizePhoneNumbers:(NSString*)countryCode +{ + MXWeakify(self); + + dispatch_async(processingQueue, ^{ + + MXStrongifyAndReturnIfNil(self); + + NSArray* contactsSnapshot = [self->localContactByContactID allValues]; + + for (MXKContact* contact in contactsSnapshot) + { + contact.defaultCountryCode = countryCode; + } + + [self cacheLocalContacts]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidInternationalizeNotification object:nil userInfo:nil]; + }); + }); +} + +- (MXKSectionedContacts *)getSectionedContacts:(NSArray*)contactsList +{ + if (!contactsList.count) + { + return nil; + } + + UILocalizedIndexedCollation *collation = [UILocalizedIndexedCollation currentCollation]; + + int indexOffset = 0; + + NSInteger index, sectionTitlesCount = [[collation sectionTitles] count]; + NSMutableArray *tmpSectionsArray = [[NSMutableArray alloc] initWithCapacity:(sectionTitlesCount)]; + + sectionTitlesCount += indexOffset; + + for (index = 0; index < sectionTitlesCount; index++) + { + NSMutableArray *array = [[NSMutableArray alloc] init]; + [tmpSectionsArray addObject:array]; + } + + int contactsCount = 0; + + for (MXKContact *aContact in contactsList) + { + NSInteger section = [collation sectionForObject:aContact collationStringSelector:@selector(displayName)] + indexOffset; + + [[tmpSectionsArray objectAtIndex:section] addObject:aContact]; + ++contactsCount; + } + + NSMutableArray *tmpSectionedContactsTitle = [[NSMutableArray alloc] initWithCapacity:sectionTitlesCount]; + NSMutableArray *shortSectionsArray = [[NSMutableArray alloc] initWithCapacity:sectionTitlesCount]; + + for (index = indexOffset; index < sectionTitlesCount; index++) + { + NSMutableArray *usersArrayForSection = [tmpSectionsArray objectAtIndex:index]; + + if ([usersArrayForSection count] != 0) + { + NSArray* sortedUsersArrayForSection = [collation sortedArrayFromArray:usersArrayForSection collationStringSelector:@selector(displayName)]; + [shortSectionsArray addObject:sortedUsersArrayForSection]; + [tmpSectionedContactsTitle addObject:[[[UILocalizedIndexedCollation currentCollation] sectionTitles] objectAtIndex:(index - indexOffset)]]; + } + } + + return [[MXKSectionedContacts alloc] initWithContacts:shortSectionsArray andTitles:tmpSectionedContactsTitle andCount:contactsCount]; +} + +- (void)sortAlphabeticallyContacts:(NSMutableArray *)contactsArray +{ + NSComparator comparator = ^NSComparisonResult(MXKContact *contactA, MXKContact *contactB) { + + if (contactA.sortingDisplayName.length && contactB.sortingDisplayName.length) + { + return [contactA.sortingDisplayName compare:contactB.sortingDisplayName options:NSCaseInsensitiveSearch]; + } + else if (contactA.sortingDisplayName.length) + { + return NSOrderedAscending; + } + else if (contactB.sortingDisplayName.length) + { + return NSOrderedDescending; + } + return [contactA.displayName compare:contactB.displayName options:NSCaseInsensitiveSearch]; + }; + + // Sort the contacts list + [contactsArray sortUsingComparator:comparator]; +} + +- (void)sortContactsByLastActiveInformation:(NSMutableArray *)contactsArray +{ + // Sort invitable contacts by last active, with "active now" first. + // ...and then alphabetically. + NSComparator comparator = ^NSComparisonResult(MXKContact *contactA, MXKContact *contactB) { + + MXUser *userA = [self firstMatrixUserOfContact:contactA]; + MXUser *userB = [self firstMatrixUserOfContact:contactB]; + + // Non-Matrix-enabled contacts are moved to the end. + if (userA && !userB) + { + return NSOrderedAscending; + } + if (!userA && userB) + { + return NSOrderedDescending; + } + + // Display active contacts first. + if (userA.currentlyActive && userB.currentlyActive) + { + // Then order by name + if (contactA.sortingDisplayName.length && contactB.sortingDisplayName.length) + { + return [contactA.sortingDisplayName compare:contactB.sortingDisplayName options:NSCaseInsensitiveSearch]; + } + else if (contactA.sortingDisplayName.length) + { + return NSOrderedAscending; + } + else if (contactB.sortingDisplayName.length) + { + return NSOrderedDescending; + } + return [contactA.displayName compare:contactB.displayName options:NSCaseInsensitiveSearch]; + } + + if (userA.currentlyActive && !userB.currentlyActive) + { + return NSOrderedAscending; + } + if (!userA.currentlyActive && userB.currentlyActive) + { + return NSOrderedDescending; + } + + // Finally, compare the lastActiveAgo + NSUInteger lastActiveAgoA = userA.lastActiveAgo; + NSUInteger lastActiveAgoB = userB.lastActiveAgo; + + if (lastActiveAgoA == lastActiveAgoB) + { + return NSOrderedSame; + } + else + { + return ((lastActiveAgoA > lastActiveAgoB) ? NSOrderedDescending : NSOrderedAscending); + } + }; + + // Sort the contacts list + [contactsArray sortUsingComparator:comparator]; +} + ++ (void)requestUserConfirmationForLocalContactsSyncInViewController:(UIViewController *)viewController completionHandler:(void (^)(BOOL))handler +{ + NSString *appDisplayName = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleDisplayName"]; + + [MXKContactManager requestUserConfirmationForLocalContactsSyncWithTitle:[MatrixKitL10n localContactsAccessDiscoveryWarningTitle] + message:[MatrixKitL10n localContactsAccessDiscoveryWarning:appDisplayName] + manualPermissionChangeMessage:[MatrixKitL10n localContactsAccessNotGranted:appDisplayName] + showPopUpInViewController:viewController + completionHandler:handler]; +} + ++ (void)requestUserConfirmationForLocalContactsSyncWithTitle:(NSString*)title + message:(NSString*)message + manualPermissionChangeMessage:(NSString*)manualPermissionChangeMessage + showPopUpInViewController:(UIViewController*)viewController + completionHandler:(void (^)(BOOL granted))handler +{ + if ([[MXKAppSettings standardAppSettings] syncLocalContacts]) + { + handler(YES); + } + else + { + UIAlertController *alert = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + [MXKTools checkAccessForContacts:manualPermissionChangeMessage showPopUpInViewController:viewController completionHandler:^(BOOL granted) { + + handler(granted); + }]; + + }]]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + handler(NO); + + }]]; + + + [viewController presentViewController:alert animated:YES completion:nil]; + } +} + +#pragma mark - Internals + +- (NSDictionary*)matrixContactsByMatrixIDFromMXSessions:(NSArray*)mxSessions +{ + // The existing dictionary of contacts will be replaced by this one + NSMutableDictionary *matrixContactByMatrixID = [[NSMutableDictionary alloc] init]; + for (MXSession *mxSession in mxSessions) + { + // Check all existing users + NSArray *mxUsers = [mxSession.users copy]; + + for (MXUser *user in mxUsers) + { + // Check whether this user has already been added + if (!matrixContactByMatrixID[user.userId]) + { + if ((self.contactManagerMXRoomSource == MXKContactManagerMXRoomSourceAll) || ((self.contactManagerMXRoomSource == MXKContactManagerMXRoomSourceDirectChats) && mxSession.directRooms[user.userId])) + { + // Check whether a contact is already defined for this id in previous dictionary + // (avoid delete and create the same ones, it could save thumbnail downloads). + MXKContact* contact = matrixContactByMatrixID[user.userId]; + if (contact) + { + contact.displayName = (user.displayname.length > 0) ? user.displayname : user.userId; + + // Check the avatar change + if ((user.avatarUrl || contact.matrixAvatarURL) && ([user.avatarUrl isEqualToString:contact.matrixAvatarURL] == NO)) + { + [contact resetMatrixThumbnail]; + } + } + else + { + contact = [[MXKContact alloc] initMatrixContactWithDisplayName:((user.displayname.length > 0) ? user.displayname : user.userId) andMatrixID:user.userId]; + } + + matrixContactByMatrixID[user.userId] = contact; + } + } + } + } + + // Do not make an immutable copy to avoid performance penalty + return matrixContactByMatrixID; +} + +- (void)refreshMatrixContacts +{ + NSArray *mxSessions = self.mxSessions; + + // Check whether at least one session is available + if (!mxSessions.count) + { + matrixContactByMatrixID = nil; + matrixContactByContactID = nil; + [self cacheMatrixContacts]; + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateMatrixContactsNotification object:nil userInfo:nil]; + } + else if (self.contactManagerMXRoomSource != MXKContactManagerMXRoomSourceNone) + { + MXWeakify(self); + + BOOL shouldFetchLocalContacts = self->matrixContactByContactID == nil; + + dispatch_async(processingQueue, ^{ + + MXStrongifyAndReturnIfNil(self); + + NSArray *sessions = self.mxSessions; + + NSMutableDictionary *matrixContactsByMatrixID = nil; + NSMutableDictionary *matrixContactsByContactID = nil; + + if (shouldFetchLocalContacts) + { + NSDictionary *cachedMatrixContacts = [self fetchCachedMatrixContacts]; + + if (!matrixContactsByContactID) + { + matrixContactsByContactID = [NSMutableDictionary dictionary]; + } + else + { + matrixContactsByContactID = [cachedMatrixContacts mutableCopy]; + } + } + else + { + matrixContactsByContactID = [NSMutableDictionary dictionary]; + } + + NSDictionary *matrixContacts = [self matrixContactsByMatrixIDFromMXSessions:sessions]; + + if (!matrixContacts) + { + matrixContactsByMatrixID = [NSMutableDictionary dictionary]; + + for (MXKContact *contact in matrixContactsByContactID.allValues) + { + matrixContactsByMatrixID[contact.matrixIdentifiers.firstObject] = contact; + } + } + else + { + matrixContactsByMatrixID = [matrixContacts mutableCopy]; + } + + for (MXKContact *contact in matrixContactsByMatrixID.allValues) + { + matrixContactsByContactID[contact.contactID] = contact; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + MXStrongifyAndReturnIfNil(self); + + // Update the matrix contacts list + self->matrixContactByMatrixID = matrixContactsByMatrixID; + self->matrixContactByContactID = matrixContactsByContactID; + + [self cacheMatrixContacts]; + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateMatrixContactsNotification object:nil userInfo:nil]; + }); + }); + } +} + +- (void)updateMatrixContactWithID:(NSString*)matrixId +{ + // Check if a one-to-one room exist for this matrix user in at least one matrix session. + NSArray *mxSessions = self.mxSessions; + for (MXSession *mxSession in mxSessions) + { + if ((self.contactManagerMXRoomSource == MXKContactManagerMXRoomSourceAll) || ((self.contactManagerMXRoomSource == MXKContactManagerMXRoomSourceDirectChats) && mxSession.directRooms[matrixId])) + { + // Retrieve the user object related to this contact + MXUser* user = [mxSession userWithUserId:matrixId]; + + // This user may not exist (if the oneToOne room is a pending invitation to him). + if (user) + { + // Update or create a contact for this user + MXKContact* contact = [matrixContactByMatrixID objectForKey:matrixId]; + BOOL isUpdated = NO; + + // already defined + if (contact) + { + // Check the display name change + NSString *userDisplayName = (user.displayname.length > 0) ? user.displayname : user.userId; + if (![contact.displayName isEqualToString:userDisplayName]) + { + contact.displayName = userDisplayName; + + [self cacheMatrixContacts]; + isUpdated = YES; + } + + // Check the avatar change + if ((user.avatarUrl || contact.matrixAvatarURL) && ([user.avatarUrl isEqualToString:contact.matrixAvatarURL] == NO)) + { + [contact resetMatrixThumbnail]; + isUpdated = YES; + } + } + else + { + contact = [[MXKContact alloc] initMatrixContactWithDisplayName:((user.displayname.length > 0) ? user.displayname : user.userId) andMatrixID:user.userId]; + [matrixContactByMatrixID setValue:contact forKey:matrixId]; + + // update the matrix contacts list + [matrixContactByContactID setValue:contact forKey:contact.contactID]; + + [self cacheMatrixContacts]; + isUpdated = YES; + } + + if (isUpdated) + { + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateMatrixContactsNotification object:contact.contactID userInfo:nil]; + } + + // Done + return; + } + } + } + + // Here no one-to-one room exist, remove the contact if any + MXKContact* contact = [matrixContactByMatrixID objectForKey:matrixId]; + if (contact) + { + [matrixContactByContactID removeObjectForKey:contact.contactID]; + [matrixContactByMatrixID removeObjectForKey:matrixId]; + + [self cacheMatrixContacts]; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKContactManagerDidUpdateMatrixContactsNotification object:contact.contactID userInfo:nil]; + } +} + +- (void)updateLocalContactMatrixIDs:(MXKContact*) contact +{ + for (MXKPhoneNumber* phoneNumber in contact.phoneNumbers) + { + if (phoneNumber.msisdn) + { + NSString* matrixID = [matrixIDBy3PID objectForKey:phoneNumber.msisdn]; + + dispatch_async(dispatch_get_main_queue(), ^{ + + [phoneNumber setMatrixID:matrixID]; + + }); + } + } + + for (MXKEmail* email in contact.emailAddresses) + { + if (email.emailAddress.length > 0) + { + NSString *matrixID = [matrixIDBy3PID objectForKey:email.emailAddress]; + + dispatch_async(dispatch_get_main_queue(), ^{ + + [email setMatrixID:matrixID]; + + }); + } + } +} + +- (void)updateAllLocalContactsMatrixIDs +{ + // Check if the user allowed to sync local contacts + if (![MXKAppSettings standardAppSettings].syncLocalContacts) + { + return; + } + + NSArray* localContacts = [localContactByContactID allValues]; + + // update the contacts info + for (MXKContact* contact in localContacts) + { + [self updateLocalContactMatrixIDs:contact]; + } +} + +- (MXUser*)firstMatrixUserOfContact:(MXKContact*)contact; +{ + MXUser *user = nil; + + NSArray *identifiers = contact.matrixIdentifiers; + if (identifiers.count) + { + for (MXSession *session in mxSessionArray) + { + user = [session userWithUserId:identifiers.firstObject]; + if (user) + { + break; + } + } + } + + return user; +} + + +#pragma mark - Identity server updates + +- (void)registerAccountDataDidChangeIdentityServerNotification +{ + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleAccountDataDidChangeIdentityServerNotification:) name:kMXSessionAccountDataDidChangeIdentityServerNotification object:nil]; +} + +- (void)handleAccountDataDidChangeIdentityServerNotification:(NSNotification*)notification +{ + MXLogDebug(@"[MXKContactManager] handleAccountDataDidChangeIdentityServerNotification"); + + if (!self.allowLocalContactsAccess) + { + MXLogDebug(@"[MXKContactManager] handleAccountDataDidChangeIdentityServerNotification. Does nothing because local contacts access not allowed."); + return; + } + + // Use the identity server of the up + MXSession *mxSession = notification.object; + if (mxSession != mxSessionArray.firstObject) + { + return; + } + + if (self.identityService) + { + // Do a full lookup + // But check first if the data is loaded + if (!self->localContactByContactID ) + { + // Load data. That will trigger updateMatrixIDsForAllLocalContacts if needed + [self refreshLocalContacts]; + } + else + { + [self updateMatrixIDsForAllLocalContacts]; + } + } + else + { + [self resetMatrixIDs]; + } +} + + +#pragma mark - KVO + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +{ + if (!self.allowLocalContactsAccess) + { + MXLogDebug(@"[MXKContactManager] Ignoring KVO changes, because local contacts access not allowed."); + return; + } + + if ([@"syncLocalContacts" isEqualToString:keyPath]) + { + dispatch_async(dispatch_get_main_queue(), ^{ + + [self refreshLocalContacts]; + + }); + } + else if ([@"phonebookCountryCode" isEqualToString:keyPath]) + { + dispatch_async(dispatch_get_main_queue(), ^{ + + [self internationalizePhoneNumbers:[[MXKAppSettings standardAppSettings] phonebookCountryCode]]; + + // Refresh local contacts if we have some + if (MXKAppSettings.standardAppSettings.syncLocalContacts && self->localContactByContactID.count) + { + [self refreshLocalContacts]; + } + + }); + } +} + +#pragma mark - file caches + +static NSString *MXKContactManagerDomain = @"org.matrix.MatrixKit.MXKContactManager"; +static NSInteger MXContactManagerEncryptionDelegateNotReady = -1; + +static NSString *matrixContactsFileOld = @"matrixContacts"; +static NSString *matrixIDsDictFileOld = @"matrixIDsDict"; +static NSString *localContactsFileOld = @"localContacts"; +static NSString *contactsBookInfoFileOld = @"contacts"; + +static NSString *matrixContactsFile = @"matrixContactsV2"; +static NSString *matrixIDsDictFile = @"matrixIDsDictV2"; +static NSString *localContactsFile = @"localContactsV2"; +static NSString *contactsBookInfoFile = @"contactsV2"; + +- (NSString*)dataFilePathForComponent:(NSString*)component +{ + NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); + NSString *documentsDirectory = [paths objectAtIndex:0]; + return [documentsDirectory stringByAppendingPathComponent:component]; +} + +- (void)cacheMatrixContacts +{ + NSString *dataFilePath = [self dataFilePathForComponent:matrixContactsFile]; + + if (matrixContactByContactID && (matrixContactByContactID.count > 0)) + { + // Switch on processing queue because matrixContactByContactID dictionary may be huge. + NSDictionary *matrixContactByContactIDCpy = [matrixContactByContactID copy]; + + dispatch_async(processingQueue, ^{ + + NSMutableData *theData = [NSMutableData data]; + NSKeyedArchiver *encoder = [[NSKeyedArchiver alloc] initForWritingWithMutableData:theData]; + + [encoder encodeObject:matrixContactByContactIDCpy forKey:@"matrixContactByContactID"]; + + [encoder finishEncoding]; + + [self encryptAndSaveData:theData toFile:matrixContactsFile]; + }); + } + else + { + NSFileManager *fileManager = [[NSFileManager alloc] init]; + [fileManager removeItemAtPath:dataFilePath error:nil]; + } +} + +- (NSDictionary*)fetchCachedMatrixContacts +{ + NSDate *startDate = [NSDate date]; + + NSString *dataFilePath = [self dataFilePathForComponent:matrixContactsFile]; + + NSFileManager *fileManager = [[NSFileManager alloc] init]; + + __block NSDictionary *matrixContactByContactID = nil; + + if ([fileManager fileExistsAtPath:dataFilePath]) + { + @try + { + NSData* filecontent = [NSData dataWithContentsOfFile:dataFilePath options:(NSDataReadingMappedAlways | NSDataReadingUncached) error:nil]; + + NSError *error = nil; + filecontent = [self decryptData:filecontent error:&error fileName:matrixContactsFile]; + + if (!error) + { + NSKeyedUnarchiver *decoder = [[NSKeyedUnarchiver alloc] initForReadingWithData:filecontent]; + + id object = [decoder decodeObjectForKey:@"matrixContactByContactID"]; + + if ([object isKindOfClass:[NSDictionary class]]) + { + matrixContactByContactID = object; + } + + [decoder finishDecoding]; + } + else + { + MXLogDebug(@"[MXKContactManager] fetchCachedMatrixContacts: failed to decrypt %@: %@", matrixContactsFile, error); + } + } + @catch (NSException *exception) + { + } + } + + MXLogDebug(@"[MXKContactManager] fetchCachedMatrixContacts : Loaded %tu contacts in %.0fms", matrixContactByContactID.count, [[NSDate date] timeIntervalSinceDate:startDate] * 1000); + + return matrixContactByContactID; +} + +- (void)cacheMatrixIDsDict +{ + NSString *dataFilePath = [self dataFilePathForComponent:matrixIDsDictFile]; + + if (matrixIDBy3PID.count) + { + NSMutableData *theData = [NSMutableData data]; + NSKeyedArchiver *encoder = [[NSKeyedArchiver alloc] initForWritingWithMutableData:theData]; + + [encoder encodeObject:matrixIDBy3PID forKey:@"matrixIDsDict"]; + [encoder finishEncoding]; + + [self encryptAndSaveData:theData toFile:matrixIDsDictFile]; + } + else + { + NSFileManager *fileManager = [[NSFileManager alloc] init]; + [fileManager removeItemAtPath:dataFilePath error:nil]; + } +} + +- (void)loadCachedMatrixIDsDict +{ + NSString *dataFilePath = [self dataFilePathForComponent:matrixIDsDictFile]; + + NSFileManager *fileManager = [[NSFileManager alloc] init]; + + if ([fileManager fileExistsAtPath:dataFilePath]) + { + // the file content could be corrupted + @try + { + NSData* filecontent = [NSData dataWithContentsOfFile:dataFilePath options:(NSDataReadingMappedAlways | NSDataReadingUncached) error:nil]; + + NSError *error = nil; + filecontent = [self decryptData:filecontent error:&error fileName:matrixIDsDictFile]; + + if (!error) + { + NSKeyedUnarchiver *decoder = [[NSKeyedUnarchiver alloc] initForReadingWithData:filecontent]; + + id object = [decoder decodeObjectForKey:@"matrixIDsDict"]; + + if ([object isKindOfClass:[NSDictionary class]]) + { + matrixIDBy3PID = [object mutableCopy]; + } + + [decoder finishDecoding]; + } + else + { + MXLogDebug(@"[MXKContactManager] loadCachedMatrixIDsDict: failed to decrypt %@: %@", matrixIDsDictFile, error); + } + } + @catch (NSException *exception) + { + } + } + + if (!matrixIDBy3PID) + { + matrixIDBy3PID = [[NSMutableDictionary alloc] init]; + } +} + +- (void)cacheLocalContacts +{ + NSString *dataFilePath = [self dataFilePathForComponent:localContactsFile]; + + if (localContactByContactID && (localContactByContactID.count > 0)) + { + NSMutableData *theData = [NSMutableData data]; + NSKeyedArchiver *encoder = [[NSKeyedArchiver alloc] initForWritingWithMutableData:theData]; + + [encoder encodeObject:localContactByContactID forKey:@"localContactByContactID"]; + + [encoder finishEncoding]; + + [self encryptAndSaveData:theData toFile:localContactsFile]; + } + else + { + NSFileManager *fileManager = [[NSFileManager alloc] init]; + [fileManager removeItemAtPath:dataFilePath error:nil]; + } +} + +- (void)loadCachedLocalContacts +{ + NSString *dataFilePath = [self dataFilePathForComponent:localContactsFile]; + + NSFileManager *fileManager = [[NSFileManager alloc] init]; + + if ([fileManager fileExistsAtPath:dataFilePath]) + { + // the file content could be corrupted + @try + { + NSData* filecontent = [NSData dataWithContentsOfFile:dataFilePath options:(NSDataReadingMappedAlways | NSDataReadingUncached) error:nil]; + + NSError *error = nil; + filecontent = [self decryptData:filecontent error:&error fileName:localContactsFile]; + + if (!error) + { + NSKeyedUnarchiver *decoder = [[NSKeyedUnarchiver alloc] initForReadingWithData:filecontent]; + + id object = [decoder decodeObjectForKey:@"localContactByContactID"]; + + if ([object isKindOfClass:[NSDictionary class]]) + { + localContactByContactID = [object mutableCopy]; + } + + [decoder finishDecoding]; + } + else + { + MXLogDebug(@"[MXKContactManager] loadCachedLocalContacts: failed to decrypt %@: %@", localContactsFile, error); + } + } + @catch (NSException *exception) + { + lastSyncDate = nil; + } + } + + if (!localContactByContactID) + { + localContactByContactID = [[NSMutableDictionary alloc] init]; + } +} + +- (void)cacheContactBookInfo +{ + NSString *dataFilePath = [self dataFilePathForComponent:contactsBookInfoFile]; + + if (lastSyncDate) + { + NSMutableData *theData = [NSMutableData data]; + NSKeyedArchiver *encoder = [[NSKeyedArchiver alloc] initForWritingWithMutableData:theData]; + + [encoder encodeObject:lastSyncDate forKey:@"lastSyncDate"]; + + [encoder finishEncoding]; + + [self encryptAndSaveData:theData toFile:contactsBookInfoFile]; + } + else + { + NSFileManager *fileManager = [[NSFileManager alloc] init]; + [fileManager removeItemAtPath:dataFilePath error:nil]; + } +} + +- (void)loadCachedContactBookInfo +{ + NSString *dataFilePath = [self dataFilePathForComponent:contactsBookInfoFile]; + + NSFileManager *fileManager = [[NSFileManager alloc] init]; + + if ([fileManager fileExistsAtPath:dataFilePath]) + { + // the file content could be corrupted + @try + { + NSData* filecontent = [NSData dataWithContentsOfFile:dataFilePath options:(NSDataReadingMappedAlways | NSDataReadingUncached) error:nil]; + + NSError *error = nil; + filecontent = [self decryptData:filecontent error:&error fileName:contactsBookInfoFile]; + + if (!error) + { + NSKeyedUnarchiver *decoder = [[NSKeyedUnarchiver alloc] initForReadingWithData:filecontent]; + + lastSyncDate = [decoder decodeObjectForKey:@"lastSyncDate"]; + + [decoder finishDecoding]; + } + else + { + lastSyncDate = nil; + MXLogDebug(@"[MXKContactManager] loadCachedContactBookInfo: failed to decrypt %@: %@", contactsBookInfoFile, error); + } + } + @catch (NSException *exception) + { + lastSyncDate = nil; + } + } +} + +- (BOOL)encryptAndSaveData:(NSData*)data toFile:(NSString*)fileName +{ + NSError *error = nil; + NSData *cipher = [self encryptData:data error:&error fileName:fileName]; + + if (error == nil) + { + [cipher writeToFile:[self dataFilePathForComponent:fileName] atomically:YES]; + } + else + { + MXLogDebug(@"[MXKContactManager] encryptAndSaveData: failed to encrypt %@", fileName); + } + + return error == nil; +} + +- (NSData*)encryptData:(NSData*)data error:(NSError**)error fileName:(NSString*)fileName +{ + @try + { + MXKeyData *keyData = (MXKeyData *) [[MXKeyProvider sharedInstance] requestKeyForDataOfType:MXKContactManagerDataType isMandatory:NO expectedKeyType:kAes]; + if (keyData && [keyData isKindOfClass:[MXAesKeyData class]]) + { + MXAesKeyData *aesKey = (MXAesKeyData *) keyData; + NSData *cipher = [MXAes encrypt:data aesKey:aesKey.key iv:aesKey.iv error:error]; + MXLogDebug(@"[MXKContactManager] encryptData: encrypted %lu Bytes for %@", cipher.length, fileName); + return cipher; + } + } + @catch (NSException *exception) + { + *error = [NSError errorWithDomain:MXKContactManagerDomain code:MXContactManagerEncryptionDelegateNotReady userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"encryptData failed: %@", exception.reason]}]; + } + + MXLogDebug(@"[MXKContactManager] encryptData: no key method provided for encryption of %@", fileName); + return data; +} + +- (NSData*)decryptData:(NSData*)data error:(NSError**)error fileName:(NSString*)fileName +{ + @try + { + MXKeyData *keyData = [[MXKeyProvider sharedInstance] requestKeyForDataOfType:MXKContactManagerDataType isMandatory:NO expectedKeyType:kAes]; + if (keyData && [keyData isKindOfClass:[MXAesKeyData class]]) + { + MXAesKeyData *aesKey = (MXAesKeyData *) keyData; + NSData *decrypt = [MXAes decrypt:data aesKey:aesKey.key iv:aesKey.iv error:error]; + MXLogDebug(@"[MXKContactManager] decryptData: decrypted %lu Bytes for %@", decrypt.length, fileName); + return decrypt; + } + } + @catch (NSException *exception) + { + *error = [NSError errorWithDomain:MXKContactManagerDomain code:MXContactManagerEncryptionDelegateNotReady userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"decryptData failed: %@", exception.reason]}]; + } + + MXLogDebug(@"[MXKContactManager] decryptData: no key method provided for decryption of %@", fileName); + return data; +} + +- (void)deleteOldFiles { + NSFileManager *fileManager = [[NSFileManager alloc] init]; + NSArray *oldFileNames = @[matrixContactsFileOld, matrixIDsDictFileOld, localContactsFileOld, contactsBookInfoFileOld]; + NSError *error = nil; + + for (NSString *fileName in oldFileNames) { + NSString *filePath = [self dataFilePathForComponent:fileName]; + if ([fileManager fileExistsAtPath:filePath]) + { + error = nil; + if (![fileManager removeItemAtPath:filePath error:&error]) + { + MXLogDebug(@"[MXKContactManager] deleteOldFiles: failed to remove %@", fileName); + } + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKEmail.h b/Riot/Modules/MatrixKit/Models/Contact/MXKEmail.h new file mode 100644 index 000000000..7073ae5c6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKEmail.h @@ -0,0 +1,30 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import "MXKContactField.h" + +@interface MXKEmail : MXKContactField + +// email info (the address is stored in lowercase) +@property (nonatomic, readonly) NSString *type; +@property (nonatomic, readonly) NSString *emailAddress; + +- (id)initWithEmailAddress:(NSString*)anEmailAddress type:(NSString*)aType contactID:(NSString*)aContactID matrixID:(NSString*)matrixID; + +- (BOOL)matchedWithPatterns:(NSArray*)patterns; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKEmail.m b/Riot/Modules/MatrixKit/Models/Contact/MXKEmail.m new file mode 100644 index 000000000..5ccda81c3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKEmail.m @@ -0,0 +1,91 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEmail.h" + +@implementation MXKEmail + +- (id)init +{ + self = [super init]; + + if (self) + { + _emailAddress = nil; + _type = nil; + } + + return self; +} + +- (id)initWithEmailAddress:(NSString*)anEmailAddress type:(NSString*)aType contactID:(NSString*)aContactID matrixID:(NSString*)matrixID +{ + self = [super initWithContactID:aContactID matrixID:matrixID]; + + if (self) + { + _emailAddress = [anEmailAddress lowercaseString]; + _type = aType; + } + + return self; +} + +- (BOOL)matchedWithPatterns:(NSArray*)patterns +{ + // no number -> cannot match + if (_emailAddress.length == 0) + { + return NO; + } + if (patterns.count > 0) + { + for(NSString *pattern in patterns) + { + if ([_emailAddress rangeOfString:pattern options:NSCaseInsensitiveSearch].location == NSNotFound) + { + return NO; + } + } + } + + return YES; +} +#pragma mark NSCoding + +- (id)initWithCoder:(NSCoder *)coder +{ + self = [super initWithCoder:coder]; + + if (self) + { + _type = [coder decodeObjectForKey:@"type"]; + _emailAddress = [[coder decodeObjectForKey:@"emailAddress"] lowercaseString]; + } + + return self; +} + +- (void)encodeWithCoder:(NSCoder *)coder +{ + [super encodeWithCoder:coder]; + + [coder encodeObject:_type forKey:@"type"]; + [coder encodeObject:_emailAddress forKey:@"emailAddress"]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKPhoneNumber.h b/Riot/Modules/MatrixKit/Models/Contact/MXKPhoneNumber.h new file mode 100644 index 000000000..b992dd358 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKPhoneNumber.h @@ -0,0 +1,78 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKContactField.h" + +@class NBPhoneNumber; + +@interface MXKPhoneNumber : MXKContactField + +/** + The phone number information + */ +@property (nonatomic, readonly) NSString *type; +@property (nonatomic, readonly) NSString *textNumber; +@property (nonatomic, readonly) NSString *cleanedPhonenumber; + +/** + When the number is considered to be a possible number. We expose here + the corresponding NBPhoneNumber instance. Use the NBPhoneNumberUtil interface + to format this phone number, or check whether the number is actually a + valid number. + */ +@property (nonatomic, readonly) NBPhoneNumber* nbPhoneNumber; + +/** + The default ISO 3166-1 country code used to parse the text number, + and create the nbPhoneNumber instance. + */ +@property (nonatomic) NSString *defaultCountryCode; + +/** + The Mobile Station International Subscriber Directory Number. + Available when the nbPhoneNumber is not nil. + */ +@property (nonatomic, readonly) NSString *msisdn; + +/** + Create a new MXKPhoneNumber instance + + @param textNumber the phone number + @param type the phone number type + @param contactID The identifier of the contact to whom the data belongs to. + @param matrixID The linked matrix identifier if any. + */ +- (id)initWithTextNumber:(NSString*)textNumber type:(NSString*)type contactID:(NSString*)contactID matrixID:(NSString*)matrixID; + +/** + Return YES when all the provided patterns are found in the phone number or its msisdn. + + @param patterns an array of patterns (The potential "+" (or "00") prefix is ignored during the msisdn handling). + */ +- (BOOL)matchedWithPatterns:(NSArray*)patterns; + +/** + Tell whether the phone number or its msisdn has the provided prefix. + + @param prefix a non empty string (The potential "+" (or "00") prefix is ignored during the msisdn handling). + @return YES when the phone number or its msisdn has the provided prefix. + */ +- (BOOL)hasPrefix:(NSString*)prefix; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKPhoneNumber.m b/Riot/Modules/MatrixKit/Models/Contact/MXKPhoneNumber.m new file mode 100644 index 000000000..5e4779926 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKPhoneNumber.m @@ -0,0 +1,213 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKPhoneNumber.h" + +@import libPhoneNumber_iOS; + +@implementation MXKPhoneNumber + +@synthesize msisdn; + +- (id)initWithTextNumber:(NSString*)textNumber type:(NSString*)type contactID:(NSString*)contactID matrixID:(NSString*)matrixID +{ + self = [super initWithContactID:contactID matrixID:matrixID]; + + if (self) + { + _type = type ? type : @""; + _textNumber = textNumber ? textNumber : @"" ; + _cleanedPhonenumber = [MXKPhoneNumber cleanPhonenumber:_textNumber]; + _defaultCountryCode = nil; + msisdn = nil; + + _nbPhoneNumber = [[NBPhoneNumberUtil sharedInstance] parse:_cleanedPhonenumber defaultRegion:nil error:nil]; + } + + return self; +} + +// remove the unuseful characters in a phonenumber ++ (NSString*)cleanPhonenumber:(NSString*)phoneNumber +{ + // sanity check + if (nil == phoneNumber) + { + return nil; + } + + // empty string + if (0 == [phoneNumber length]) + { + return @""; + } + + static NSCharacterSet *invertedPhoneCharSet = nil; + + if (!invertedPhoneCharSet) + { + invertedPhoneCharSet = [[NSCharacterSet characterSetWithCharactersInString:@"0123456789+*#,()"] invertedSet]; + } + + return [[phoneNumber componentsSeparatedByCharactersInSet:invertedPhoneCharSet] componentsJoinedByString:@""]; +} + + +- (BOOL)matchedWithPatterns:(NSArray*)patterns +{ + // no number -> cannot match + if (_textNumber.length == 0) + { + return NO; + } + + if (patterns.count > 0) + { + for (NSString *pattern in patterns) + { + if ([_textNumber rangeOfString:pattern].location == NSNotFound) + { + NSString *cleanPattern = [[pattern componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceCharacterSet]] componentsJoinedByString:@""]; + + if ([_cleanedPhonenumber rangeOfString:cleanPattern].location == NSNotFound) + { + NSString *msisdnPattern; + + if ([cleanPattern hasPrefix:@"+"]) + { + msisdnPattern = [cleanPattern substringFromIndex:1]; + } + else if ([cleanPattern hasPrefix:@"00"]) + { + msisdnPattern = [cleanPattern substringFromIndex:2]; + } + else + { + msisdnPattern = cleanPattern; + } + + // Check the msisdn + if (!self.msisdn || !msisdnPattern.length || [self.msisdn rangeOfString:msisdnPattern].location == NSNotFound) + { + return NO; + } + } + + } + } + } + + return YES; +} + +- (BOOL)hasPrefix:(NSString*)prefix +{ + // no number -> cannot match + if (_textNumber.length == 0) + { + return NO; + } + + if ([_textNumber hasPrefix:prefix]) + { + return YES; + } + + // Remove whitespace before checking the cleaned phone number + prefix = [[prefix componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceCharacterSet]] componentsJoinedByString:@""]; + + if ([_cleanedPhonenumber hasPrefix:prefix]) + { + return YES; + } + + if (self.msisdn) + { + if ([prefix hasPrefix:@"+"]) + { + prefix = [prefix substringFromIndex:1]; + } + else if ([prefix hasPrefix:@"00"]) + { + prefix = [prefix substringFromIndex:2]; + } + + return [self.msisdn hasPrefix:prefix]; + } + + return NO; +} + +- (void)setDefaultCountryCode:(NSString *)defaultCountryCode +{ + if (![defaultCountryCode isEqualToString:_defaultCountryCode]) + { + _nbPhoneNumber = [[NBPhoneNumberUtil sharedInstance] parse:_cleanedPhonenumber defaultRegion:defaultCountryCode error:nil]; + + _defaultCountryCode = defaultCountryCode; + msisdn = nil; + } +} + +- (NSString*)msisdn +{ + if (!msisdn && _nbPhoneNumber) + { + NSString *e164 = [[NBPhoneNumberUtil sharedInstance] format:_nbPhoneNumber numberFormat:NBEPhoneNumberFormatE164 error:nil]; + if ([e164 hasPrefix:@"+"]) + { + msisdn = [e164 substringFromIndex:1]; + } + else if ([e164 hasPrefix:@"00"]) + { + msisdn = [e164 substringFromIndex:2]; + } + } + return msisdn; +} + +#pragma mark NSCoding + +- (id)initWithCoder:(NSCoder *)coder +{ + self = [super initWithCoder:coder]; + + if (self) + { + _type = [coder decodeObjectForKey:@"type"]; + _textNumber = [coder decodeObjectForKey:@"textNumber"]; + _cleanedPhonenumber = [coder decodeObjectForKey:@"cleanedPhonenumber"]; + _defaultCountryCode = [coder decodeObjectForKey:@"countryCode"]; + + _nbPhoneNumber = [[NBPhoneNumberUtil sharedInstance] parse:_cleanedPhonenumber defaultRegion:_defaultCountryCode error:nil]; + msisdn = nil; + } + + return self; +} + +- (void)encodeWithCoder:(NSCoder *)coder +{ + [super encodeWithCoder:coder]; + + [coder encodeObject:_type forKey:@"type"]; + [coder encodeObject:_textNumber forKey:@"textNumber"]; + [coder encodeObject:_cleanedPhonenumber forKey:@"cleanedPhonenumber"]; + [coder encodeObject:_defaultCountryCode forKey:@"countryCode"]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKSectionedContacts.h b/Riot/Modules/MatrixKit/Models/Contact/MXKSectionedContacts.h new file mode 100644 index 000000000..aaee29742 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKSectionedContacts.h @@ -0,0 +1,33 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKContact.h" + +@interface MXKSectionedContacts : NSObject { + int contactsCount; + NSArray *sectionTitles; + NSArray*> *sectionedContacts; +} + +@property (nonatomic, readonly) int contactsCount; +@property (nonatomic, readonly) NSArray *sectionTitles; +@property (nonatomic, readonly) NSArray*> *sectionedContacts; + +- (instancetype)initWithContacts:(NSArray*> *)inSectionedContacts andTitles:(NSArray *)titles andCount:(int)count; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Contact/MXKSectionedContacts.m b/Riot/Modules/MatrixKit/Models/Contact/MXKSectionedContacts.m new file mode 100644 index 000000000..a8237dfcc --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Contact/MXKSectionedContacts.m @@ -0,0 +1,32 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSectionedContacts.h" + +@implementation MXKSectionedContacts + +@synthesize contactsCount, sectionTitles, sectionedContacts; + +-(id)initWithContacts:(NSArray *> *)inSectionedContacts andTitles:(NSArray *)titles andCount:(int)count { + if (self = [super init]) { + contactsCount = count; + sectionedContacts = inSectionedContacts; + sectionTitles = titles; + } + return self; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellData.h b/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellData.h new file mode 100644 index 000000000..9ecd35b9c --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellData.h @@ -0,0 +1,24 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKGroupCellDataStoring.h" + +/** + `MXKGroupCellData` modelised the data for a `MXKGroupTableViewCell` cell. + */ +@interface MXKGroupCellData : MXKCellData + +@end diff --git a/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellData.m b/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellData.m new file mode 100644 index 000000000..9bb53db22 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellData.m @@ -0,0 +1,49 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKGroupCellData.h" + +#import "MXKSessionGroupsDataSource.h" + +@implementation MXKGroupCellData +@synthesize group, groupsDataSource, groupDisplayname, sortingDisplayname; + +- (instancetype)initWithGroup:(MXGroup*)theGroup andGroupsDataSource:(MXKSessionGroupsDataSource*)theGroupsDataSource +{ + self = [self init]; + if (self) + { + groupsDataSource = theGroupsDataSource; + [self updateWithGroup:theGroup]; + } + return self; +} + +- (void)updateWithGroup:(MXGroup*)theGroup +{ + group = theGroup; + + groupDisplayname = sortingDisplayname = group.profile.name; + + if (!groupDisplayname.length) + { + groupDisplayname = group.groupId; + // Ignore the prefix '+' of the group id during sorting. + sortingDisplayname = [groupDisplayname substringFromIndex:1]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellDataStoring.h b/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellDataStoring.h new file mode 100644 index 000000000..9ca2bd57b --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Group/MXKGroupCellDataStoring.h @@ -0,0 +1,53 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKCellData.h" + +@class MXKSessionGroupsDataSource; + +/** + `MXKGroupCellDataStoring` defines a protocol a class must conform in order to store group cell data + managed by `MXKSessionGroupsDataSource`. + */ +@protocol MXKGroupCellDataStoring + +@property (nonatomic, weak, readonly) MXKSessionGroupsDataSource *groupsDataSource; + +@property (nonatomic, readonly) MXGroup *group; + +@property (nonatomic, readonly) NSString *groupDisplayname; +@property (nonatomic, readonly) NSString *sortingDisplayname; + +#pragma mark - Public methods +/** + Create a new `MXKCellData` object for a new group cell. + + @param group the `MXGroup` object that has data about the group. + @param groupsDataSource the `MXKSessionGroupsDataSource` object that will use this instance. + @return the newly created instance. + */ +- (instancetype)initWithGroup:(MXGroup*)group andGroupsDataSource:(MXKSessionGroupsDataSource*)groupsDataSource; + +/** + The `MXKSessionGroupsDataSource` object calls this method when the group data has been updated. + + @param group the updated group. + */ +- (void)updateWithGroup:(MXGroup*)group; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Group/MXKSessionGroupsDataSource.h b/Riot/Modules/MatrixKit/Models/Group/MXKSessionGroupsDataSource.h new file mode 100644 index 000000000..7ee1ac3c0 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Group/MXKSessionGroupsDataSource.h @@ -0,0 +1,94 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKDataSource.h" +#import "MXKGroupCellData.h" + +/** + Identifier to use for cells that display a group. + */ +extern NSString *const kMXKGroupCellIdentifier; + +/** + 'MXKSessionGroupsDataSource' is a base class to handle the groups of a matrix session. + A 'MXKSessionGroupsDataSource' instance provides the data source for `MXKGroupListViewController`. + + A section is created to handle the invitations to a group, the first one if any. + */ +@interface MXKSessionGroupsDataSource : MXKDataSource +{ +@protected + + /** + The current list of the group invitations (sorted in the alphabetic order). + This list takes into account potential filter defined by`patternsList`. + */ + NSMutableArray *groupsInviteCellDataArray; + + /** + The current displayed list of the joined groups (sorted in the alphabetic order). + This list takes into account potential filter defined by`patternsList`. + */ + NSMutableArray *groupsCellDataArray; +} + +@property (nonatomic) NSInteger groupInvitesSection; +@property (nonatomic) NSInteger joinedGroupsSection; + +#pragma mark - Life cycle + +/** + Refresh all the groups summary. + The group data are not synced with the server, use this method to refresh them according to your needs. + + @param completion the block to execute when a request has been done for each group (whatever the result of the requests). + You may specify nil for this parameter. + */ +- (void)refreshGroupsSummary:(void (^)(void))completion; + +/** + Filter the current groups list according to the provided patterns. + When patterns are not empty, the search result is stored in `filteredGroupsCellDataArray`, + this array provides then data for the cells served by `MXKSessionGroupsDataSource`. + + @param patternsList the list of patterns (`NSString` instances) to match with. Set nil to cancel search. + */ +- (void)searchWithPatterns:(NSArray*)patternsList; + +/** + Get the data for the cell at the given index path. + + @param indexPath the index of the cell in the table + @return the cell data + */ +- (id)cellDataAtIndex:(NSIndexPath*)indexPath; + +/** + Get the index path of the cell related to the provided groupId. + + @param groupId the group identifier. + @return indexPath the index of the cell (nil if not found). + */ +- (NSIndexPath*)cellIndexPathWithGroupId:(NSString*)groupId; + +/** + Leave the group displayed at the provided path. + + @param indexPath the index of the group cell in the table + */ +- (void)leaveGroupAtIndexPath:(NSIndexPath *)indexPath; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Group/MXKSessionGroupsDataSource.m b/Riot/Modules/MatrixKit/Models/Group/MXKSessionGroupsDataSource.m new file mode 100644 index 000000000..cb9f7a948 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Group/MXKSessionGroupsDataSource.m @@ -0,0 +1,611 @@ +/* + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSessionGroupsDataSource.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKConstants.h" + +#import "MXKSwiftHeader.h" + +#pragma mark - Constant definitions +NSString *const kMXKGroupCellIdentifier = @"kMXKGroupCellIdentifier"; + + +@interface MXKSessionGroupsDataSource () +{ + /** + Internal array used to regulate change notifications. + Cell data changes are stored instantly in this array. + We wait at least for 500 ms between two notifications of the delegate. + */ + NSMutableArray *internalCellDataArray; + + /* + Timer to not notify the delegate on every changes. + */ + NSTimer *timer; + + /* + Tells whether some changes must be notified. + */ + BOOL isDataChangePending; + + /** + Store the current search patterns list. + */ + NSArray* searchPatternsList; +} + +@end + +@implementation MXKSessionGroupsDataSource + +- (instancetype)initWithMatrixSession:(MXSession *)matrixSession +{ + self = [super initWithMatrixSession:matrixSession]; + if (self) + { + internalCellDataArray = [NSMutableArray array]; + groupsCellDataArray = [NSMutableArray array]; + groupsInviteCellDataArray = [NSMutableArray array]; + + isDataChangePending = NO; + + // Set default data and view classes + [self registerCellDataClass:MXKGroupCellData.class forCellIdentifier:kMXKGroupCellIdentifier]; + } + return self; +} + +- (void)destroy +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + groupsCellDataArray = nil; + groupsInviteCellDataArray = nil; + internalCellDataArray = nil; + + searchPatternsList = nil; + + [timer invalidate]; + timer = nil; + + [super destroy]; +} + +- (void)didMXSessionStateChange +{ + if (MXSessionStateRunning <= self.mxSession.state) + { + // Check whether some data have been already load + if (0 == internalCellDataArray.count) + { + [self loadData]; + } + else if (self.mxSession.state == MXSessionStateRunning) + { + // Refresh the group data + [self refreshGroupsSummary:nil]; + } + } +} + +#pragma mark - + +- (void)refreshGroupsSummary:(void (^)(void))completion +{ + MXLogDebug(@"[MXKSessionGroupsDataSource] refreshGroupsSummary"); + + __block NSUInteger count = internalCellDataArray.count; + + if (count) + { + for (id groupData in internalCellDataArray) + { + // Force the matrix session to refresh the group summary. + [self.mxSession updateGroupSummary:groupData.group success:^{ + + if (completion && !(--count)) + { + // All the requests have been done. + completion (); + } + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKSessionGroupsDataSource] refreshGroupsSummary: group summary update failed %@", groupData.group.groupId); + + if (completion && !(--count)) + { + // All the requests have been done. + completion (); + } + + }]; + } + } + else if (completion) + { + completion(); + } +} + +- (void)searchWithPatterns:(NSArray*)patternsList +{ + if (patternsList.count) + { + searchPatternsList = patternsList; + } + else + { + searchPatternsList = nil; + } + + [self onCellDataChange]; +} + +- (id)cellDataAtIndex:(NSIndexPath*)indexPath +{ + id groupData; + + if (indexPath.section == _groupInvitesSection) + { + if (indexPath.row < groupsInviteCellDataArray.count) + { + groupData = groupsInviteCellDataArray[indexPath.row]; + } + } + else if (indexPath.section == _joinedGroupsSection) + { + if (indexPath.row < groupsCellDataArray.count) + { + groupData = groupsCellDataArray[indexPath.row]; + } + } + + return groupData; +} + +- (NSIndexPath*)cellIndexPathWithGroupId:(NSString*)groupId +{ + // Look for the cell + if (_groupInvitesSection != -1) + { + for (NSInteger index = 0; index < groupsInviteCellDataArray.count; index ++) + { + id groupData = groupsInviteCellDataArray[index]; + if ([groupId isEqualToString:groupData.group.groupId]) + { + // Got it + return [NSIndexPath indexPathForRow:index inSection:_groupInvitesSection]; + } + } + } + + if (_joinedGroupsSection != -1) + { + for (NSInteger index = 0; index < groupsCellDataArray.count; index ++) + { + id groupData = groupsCellDataArray[index]; + if ([groupId isEqualToString:groupData.group.groupId]) + { + // Got it + return [NSIndexPath indexPathForRow:index inSection:_joinedGroupsSection]; + } + } + } + + return nil; +} + +#pragma mark - Groups processing + +- (void)loadData +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionNewGroupInviteNotification object:self.mxSession]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidJoinGroupNotification object:self.mxSession]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidLeaveGroupNotification object:self.mxSession]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidUpdateGroupSummaryNotification object:self.mxSession]; + + // Reset the table + [internalCellDataArray removeAllObjects]; + + // Retrieve the MXKCellData class to manage the data + Class class = [self cellDataClassForCellIdentifier:kMXKGroupCellIdentifier]; + NSAssert([class conformsToProtocol:@protocol(MXKGroupCellDataStoring)], @"MXKSessionGroupsDataSource only manages MXKCellData that conforms to MXKGroupCellDataStoring protocol"); + + // Listen to MXSession groups changes + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onNewGroupInvite:) name:kMXSessionNewGroupInviteNotification object:self.mxSession]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didJoinGroup:) name:kMXSessionDidJoinGroupNotification object:self.mxSession]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didLeaveGroup:) name:kMXSessionDidLeaveGroupNotification object:self.mxSession]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didUpdateGroup:) name:kMXSessionDidUpdateGroupSummaryNotification object:self.mxSession]; + + NSDate *startDate = [NSDate date]; + + NSArray *groups = self.mxSession.groups; + for (MXGroup *group in groups) + { + id cellData = [[class alloc] initWithGroup:group andGroupsDataSource:self]; + if (cellData) + { + [internalCellDataArray addObject:cellData]; + + // Force the matrix session to refresh the group summary. + [self.mxSession updateGroupSummary:group success:nil failure:^(NSError *error) { + MXLogDebug(@"[MXKSessionGroupsDataSource] loadData: group summary update failed %@", group.groupId); + }]; + } + } + + MXLogDebug(@"[MXKSessionGroupsDataSource] Loaded %tu groups in %.3fms", groups.count, [[NSDate date] timeIntervalSinceDate:startDate] * 1000); + + [self sortCellData]; + [self onCellDataChange]; +} + +- (void)didUpdateGroup:(NSNotification *)notif +{ + MXGroup *group = notif.userInfo[kMXSessionNotificationGroupKey]; + if (group) + { + id groupData = [self cellDataWithGroupId:group.groupId]; + if (groupData) + { + [groupData updateWithGroup:group]; + } + else + { + MXLogDebug(@"[MXKSessionGroupsDataSource] didUpdateGroup: Cannot find the changed group for %@ (%@). It is probably not managed by this group data source", group.groupId, group); + return; + } + } + + [self sortCellData]; + [self onCellDataChange]; +} + +- (void)onNewGroupInvite:(NSNotification *)notif +{ + MXGroup *group = notif.userInfo[kMXSessionNotificationGroupKey]; + if (group) + { + // Add the group if there is not yet a cell for it + id groupData = [self cellDataWithGroupId:group.groupId]; + if (nil == groupData) + { + MXLogDebug(@"MXKSessionGroupsDataSource] Add new group invite: %@", group.groupId); + + // Retrieve the MXKCellData class to manage the data + Class class = [self cellDataClassForCellIdentifier:kMXKGroupCellIdentifier]; + + id cellData = [[class alloc] initWithGroup:group andGroupsDataSource:self]; + if (cellData) + { + [internalCellDataArray addObject:cellData]; + + [self sortCellData]; + [self onCellDataChange]; + } + } + } +} + +- (void)didJoinGroup:(NSNotification *)notif +{ + MXGroup *group = notif.userInfo[kMXSessionNotificationGroupKey]; + if (group) + { + id groupData = [self cellDataWithGroupId:group.groupId]; + if (groupData) + { + MXLogDebug(@"MXKSessionGroupsDataSource] Update joined room: %@", group.groupId); + [groupData updateWithGroup:group]; + } + else + { + MXLogDebug(@"MXKSessionGroupsDataSource] Add new joined invite: %@", group.groupId); + + // Retrieve the MXKCellData class to manage the data + Class class = [self cellDataClassForCellIdentifier:kMXKGroupCellIdentifier]; + + id cellData = [[class alloc] initWithGroup:group andGroupsDataSource:self]; + if (cellData) + { + [internalCellDataArray addObject:cellData]; + } + } + + [self sortCellData]; + [self onCellDataChange]; + } +} + +- (void)didLeaveGroup:(NSNotification *)notif +{ + NSString *groupId = notif.userInfo[kMXSessionNotificationGroupIdKey]; + if (groupId) + { + [self removeGroup:groupId]; + } +} + +- (void)removeGroup:(NSString*)groupId +{ + id groupData = [self cellDataWithGroupId:groupId]; + if (groupData) + { + MXLogDebug(@"MXKSessionGroupsDataSource] Remove left group: %@", groupId); + + [internalCellDataArray removeObject:groupData]; + + [self sortCellData]; + [self onCellDataChange]; + } +} + +- (void)onCellDataChange +{ + isDataChangePending = NO; + + // Check no notification was done recently. + // Note: do not wait in case of search + if (timer == nil || searchPatternsList) + { + [timer invalidate]; + timer = [NSTimer scheduledTimerWithTimeInterval:0.5 target:self selector:@selector(checkPendingUpdate:) userInfo:nil repeats:NO]; + + // Prepare cell data array, and notify the delegate. + [self prepareCellDataAndNotifyChanges]; + } + else + { + isDataChangePending = YES; + } +} + +- (IBAction)checkPendingUpdate:(id)sender +{ + [timer invalidate]; + timer = nil; + + if (isDataChangePending) + { + [self onCellDataChange]; + } +} + +- (void)sortCellData +{ + // Order alphabetically the groups + [internalCellDataArray sortUsingComparator:^NSComparisonResult(id cellData1, id cellData2) + { + if (cellData1.sortingDisplayname.length && cellData2.sortingDisplayname.length) + { + return [cellData1.sortingDisplayname compare:cellData2.sortingDisplayname options:NSCaseInsensitiveSearch]; + } + else if (cellData1.sortingDisplayname.length) + { + return NSOrderedAscending; + } + else if (cellData2.sortingDisplayname.length) + { + return NSOrderedDescending; + } + return NSOrderedSame; + }]; +} + +- (void)prepareCellDataAndNotifyChanges +{ + // Prepare the cell data arrays by considering the potential filter. + [groupsInviteCellDataArray removeAllObjects]; + [groupsCellDataArray removeAllObjects]; + for (id groupData in internalCellDataArray) + { + BOOL isKept = !searchPatternsList; + + for (NSString* pattern in searchPatternsList) + { + if (groupData.groupDisplayname && [groupData.groupDisplayname rangeOfString:pattern options:NSCaseInsensitiveSearch].location != NSNotFound) + { + isKept = YES; + break; + } + } + + if (isKept) + { + if (groupData.group.membership == MXMembershipInvite) + { + [groupsInviteCellDataArray addObject:groupData]; + } + else + { + [groupsCellDataArray addObject:groupData]; + } + } + } + + // Update here data source state + if (state != MXKDataSourceStateReady) + { + state = MXKDataSourceStateReady; + if (self.delegate && [self.delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [self.delegate dataSource:self didStateChange:state]; + } + } + + // And inform the delegate about the update + [self.delegate dataSource:self didCellChange:nil]; +} + +// Find the cell data that stores information about the given group id +- (id)cellDataWithGroupId:(NSString*)groupId +{ + id theGroupData; + for (id groupData in internalCellDataArray) + { + if ([groupData.group.groupId isEqualToString:groupId]) + { + theGroupData = groupData; + break; + } + } + return theGroupData; +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + NSInteger count = 0; + _groupInvitesSection = _joinedGroupsSection = -1; + + // Check whether all data sources are ready before rendering groups. + if (self.state == MXKDataSourceStateReady) + { + if (groupsInviteCellDataArray.count) + { + _groupInvitesSection = count++; + } + if (groupsCellDataArray.count) + { + _joinedGroupsSection = count++; + } + } + return count; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + if (section == _groupInvitesSection) + { + return groupsInviteCellDataArray.count; + } + else if (section == _joinedGroupsSection) + { + return groupsCellDataArray.count; + } + + return 0; +} + +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section +{ + NSString* sectionTitle = nil; + + if (section == _groupInvitesSection) + { + sectionTitle = [MatrixKitL10n groupInviteSection]; + } + else if (section == _joinedGroupsSection) + { + sectionTitle = [MatrixKitL10n groupSection]; + } + + return sectionTitle; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + id groupData; + + if (indexPath.section == _groupInvitesSection) + { + if (indexPath.row < groupsInviteCellDataArray.count) + { + groupData = groupsInviteCellDataArray[indexPath.row]; + } + } + else if (indexPath.section == _joinedGroupsSection) + { + if (indexPath.row < groupsCellDataArray.count) + { + groupData = groupsCellDataArray[indexPath.row]; + } + } + + if (groupData) + { + NSString *cellIdentifier = [self.delegate cellReuseIdentifierForCellData:groupData]; + if (cellIdentifier) + { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIdentifier forIndexPath:indexPath]; + + // Make sure we listen to user actions on the cell + cell.delegate = self; + + // Make the bubble display the data + [cell render:groupData]; + + return cell; + } + } + + // Return a fake cell to prevent app from crashing. + return [[UITableViewCell alloc] init]; +} + +- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath +{ + // Return NO if you do not want the specified item to be editable. + return YES; +} + +- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (editingStyle == UITableViewCellEditingStyleDelete) + { + [self leaveGroupAtIndexPath:indexPath]; + } +} + +- (void)leaveGroupAtIndexPath:(NSIndexPath *)indexPath +{ + id cellData = [self cellDataAtIndex:indexPath]; + + if (cellData.group) + { + __weak typeof(self) weakSelf = self; + + [self.mxSession leaveGroup:cellData.group.groupId success:^{ + + if (weakSelf) + { + // Refresh the table content + typeof(self) self = weakSelf; + [self removeGroup:cellData.group.groupId]; + } + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKSessionGroupsDataSource] Failed to leave group (%@)", cellData.group.groupId); + + // Notify MatrixKit user + NSString *myUserId = self.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } +} + + +@end diff --git a/Riot/Modules/MatrixKit/Models/MXK3PID.h b/Riot/Modules/MatrixKit/Models/MXK3PID.h new file mode 100644 index 000000000..1bf84cb3c --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXK3PID.h @@ -0,0 +1,119 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +typedef enum : NSUInteger { + MXK3PIDAuthStateUnknown, + MXK3PIDAuthStateTokenRequested, + MXK3PIDAuthStateTokenReceived, + MXK3PIDAuthStateTokenSubmitted, + MXK3PIDAuthStateAuthenticated +} MXK3PIDAuthState; + + +@interface MXK3PID : NSObject + +/** + The type of the third party media. + */ +@property (nonatomic, readonly) MX3PIDMedium medium; + +/** + The third party media (email address, msisdn,...). + */ +@property (nonatomic, readonly) NSString *address; + +/** + The current client secret key used during third party validation. + */ +@property (nonatomic, readonly) NSString *clientSecret; + +/** + The current session identifier during third party validation. + */ +@property (nonatomic, readonly) NSString *sid; + +/** + The id of the user on Matrix. + nil if unknown or not yet resolved. + */ +@property (nonatomic) NSString *userId; + +@property (nonatomic, readonly) MXK3PIDAuthState validationState; + +/** + Initialise the instance with a 3PID. + + @param medium the medium. + @param address the id of the contact on this medium. + @return the new instance. + */ +- (instancetype)initWithMedium:(NSString*)medium andAddress:(NSString*)address; + +/** + Cancel the current request, and reset parameters + */ +- (void)cancelCurrentRequest; + +/** + Start the validation process + The identity server will send a validation token by email or sms. + + In case of email, the end user must click on the link in the received email + to validate their email address in order to be able to call add3PIDToUser successfully. + + In case of phone number, the end user must send back the sms token + in order to be able to call add3PIDToUser successfully. + + @param restClient used to make matrix API requests during validation process. + @param isDuringRegistration tell whether this request occurs during a registration flow. + @param nextLink the link the validation page will automatically open. Can be nil. + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)requestValidationTokenWithMatrixRestClient:(MXRestClient*)restClient + isDuringRegistration:(BOOL)isDuringRegistration + nextLink:(NSString*)nextLink + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + Submit the received validation token. + + @param token the validation token. + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)submitValidationToken:(NSString *)token + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + Link a 3rd party id to the user. + + @param bind whether the homeserver should also bind this third party identifier + to the account's Matrix ID with the identity server. + @param success A block object called when the operation succeeds. It provides the raw + server response. + @param failure A block object called when the operation fails. + */ +- (void)add3PIDToUser:(BOOL)bind + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +@end diff --git a/Riot/Modules/MatrixKit/Models/MXK3PID.m b/Riot/Modules/MatrixKit/Models/MXK3PID.m new file mode 100644 index 000000000..5c4657cfa --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXK3PID.m @@ -0,0 +1,316 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXK3PID.h" + +@import libPhoneNumber_iOS; + +@interface MXK3PID () +{ + MXRestClient *mxRestClient; + MXHTTPOperation *currentRequest; +} +@property (nonatomic) NSString *clientSecret; +@property (nonatomic) NSUInteger sendAttempt; +@property (nonatomic) NSString *sid; +@property (nonatomic) MXIdentityService *identityService; +@property (nonatomic) NSString *submitUrl; + +@end + +@implementation MXK3PID + +- (instancetype)initWithMedium:(NSString *)medium andAddress:(NSString *)address +{ + self = [super init]; + if (self) + { + _medium = [medium copy]; + _address = [address copy]; + self.clientSecret = [MXTools generateSecret]; + } + return self; +} + +- (void)cancelCurrentRequest +{ + _validationState = MXK3PIDAuthStateUnknown; + + [currentRequest cancel]; + currentRequest = nil; + mxRestClient = nil; + self.identityService = nil; + + self.sendAttempt = 1; + self.sid = nil; + // Removed potential linked userId + self.userId = nil; +} + +- (void)requestValidationTokenWithMatrixRestClient:(MXRestClient*)restClient + isDuringRegistration:(BOOL)isDuringRegistration + nextLink:(NSString*)nextLink + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + // Sanity Check + if (_validationState != MXK3PIDAuthStateTokenRequested && restClient) + { + // Reset if the current state is different than "Unknown" + if (_validationState != MXK3PIDAuthStateUnknown) + { + [self cancelCurrentRequest]; + } + + NSString *identityServer = restClient.identityServer; + if (identityServer) + { + // Use same identity server as REST client for validation token submission + self.identityService = [[MXIdentityService alloc] initWithIdentityServer:identityServer accessToken:nil andHomeserverRestClient:restClient]; + } + + if ([self.medium isEqualToString:kMX3PIDMediumEmail]) + { + _validationState = MXK3PIDAuthStateTokenRequested; + mxRestClient = restClient; + + currentRequest = [mxRestClient requestTokenForEmail:self.address isDuringRegistration:isDuringRegistration clientSecret:self.clientSecret sendAttempt:self.sendAttempt nextLink:nextLink success:^(NSString *sid) { + + self->_validationState = MXK3PIDAuthStateTokenReceived; + self->currentRequest = nil; + self.sid = sid; + + if (success) + { + success(); + } + + } failure:^(NSError *error) { + + // Return in unknown state + self->_validationState = MXK3PIDAuthStateUnknown; + self->currentRequest = nil; + // Increment attempt counter + self.sendAttempt++; + + if (failure) + { + failure (error); + } + + }]; + } + else if ([self.medium isEqualToString:kMX3PIDMediumMSISDN]) + { + _validationState = MXK3PIDAuthStateTokenRequested; + mxRestClient = restClient; + + NSString *phoneNumber = [NSString stringWithFormat:@"+%@", self.address]; + + currentRequest = [mxRestClient requestTokenForPhoneNumber:phoneNumber isDuringRegistration:isDuringRegistration countryCode:nil clientSecret:self.clientSecret sendAttempt:self.sendAttempt nextLink:nextLink success:^(NSString *sid, NSString *msisdn, NSString *submitUrl) { + + self->_validationState = MXK3PIDAuthStateTokenReceived; + self->currentRequest = nil; + self.sid = sid; + self.submitUrl = submitUrl; + + if (success) + { + success(); + } + + } failure:^(NSError *error) { + + // Return in unknown state + self->_validationState = MXK3PIDAuthStateUnknown; + self->currentRequest = nil; + // Increment attempt counter + self.sendAttempt++; + + if (failure) + { + failure (error); + } + + }]; + } + else + { + MXLogDebug(@"[MXK3PID] requestValidationToken: is not supported for this 3PID: %@ (%@)", self.address, self.medium); + } + } + else + { + MXLogDebug(@"[MXK3PID] Failed to request validation token for 3PID: %@ (%@), state: %lu", self.address, self.medium, (unsigned long)_validationState); + } +} + +- (void)submitValidationToken:(NSString *)token + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + // Sanity Check + if (_validationState == MXK3PIDAuthStateTokenReceived) + { + if (self.submitUrl) + { + _validationState = MXK3PIDAuthStateTokenSubmitted; + + currentRequest = [self submitMsisdnTokenOtherUrl:self.submitUrl token:token medium:self.medium clientSecret:self.clientSecret sid:self.sid success:^{ + + self->_validationState = MXK3PIDAuthStateAuthenticated; + self->currentRequest = nil; + + if (success) + { + success(); + } + + } failure:^(NSError *error) { + + // Return in previous state + self->_validationState = MXK3PIDAuthStateTokenReceived; + self->currentRequest = nil; + + if (failure) + { + failure (error); + } + + }]; + } + else if (self.identityService) + { + _validationState = MXK3PIDAuthStateTokenSubmitted; + + currentRequest = [self.identityService submit3PIDValidationToken:token medium:self.medium clientSecret:self.clientSecret sid:self.sid success:^{ + + self->_validationState = MXK3PIDAuthStateAuthenticated; + self->currentRequest = nil; + + if (success) + { + success(); + } + + } failure:^(NSError *error) { + + // Return in previous state + self->_validationState = MXK3PIDAuthStateTokenReceived; + self->currentRequest = nil; + + if (failure) + { + failure (error); + } + + }]; + } + else + { + MXLogDebug(@"[MXK3PID] Failed to submit validation token for 3PID: %@ (%@), identity service is not set", self.address, self.medium); + + if (failure) + { + failure(nil); + } + } + } + else + { + MXLogDebug(@"[MXK3PID] Failed to submit validation token for 3PID: %@ (%@), state: %lu", self.address, self.medium, (unsigned long)_validationState); + + if (failure) + { + failure(nil); + } + } +} + +- (MXHTTPOperation *)submitMsisdnTokenOtherUrl:(NSString *)url + token:(NSString*)token + medium:(NSString *)medium + clientSecret:(NSString *)clientSecret + sid:(NSString *)sid + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + NSDictionary *parameters = @{ + @"sid": sid, + @"client_secret": clientSecret, + @"token": token + }; + + MXHTTPClient *httpClient = [[MXHTTPClient alloc] initWithBaseURL:nil andOnUnrecognizedCertificateBlock:nil]; + return [httpClient requestWithMethod:@"POST" + path:url + parameters:parameters + success:^(NSDictionary *JSONResponse) { + success(); + } + failure:failure]; +} + +- (void)add3PIDToUser:(BOOL)bind + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + if ([self.medium isEqualToString:kMX3PIDMediumEmail] || [self.medium isEqualToString:kMX3PIDMediumMSISDN]) + { + MXWeakify(self); + + currentRequest = [mxRestClient add3PID:self.sid clientSecret:self.clientSecret bind:bind success:^{ + + MXStrongifyAndReturnIfNil(self); + + // Update linked userId in 3PID + self.userId = self->mxRestClient.credentials.userId; + self->currentRequest = nil; + + if (success) + { + success(); + } + + } failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + + self->currentRequest = nil; + + if (failure) + { + failure (error); + } + + }]; + + return; + } + else + { + MXLogDebug(@"[MXK3PID] bindWithUserId: is not supported for this 3PID: %@ (%@)", self.address, self.medium); + } + + // Here the validation process failed + if (failure) + { + failure (nil); + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/MXKAppSettings.h b/Riot/Modules/MatrixKit/Models/MXKAppSettings.h new file mode 100644 index 000000000..710e1d5db --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXKAppSettings.h @@ -0,0 +1,290 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +#import + +typedef NS_ENUM(NSUInteger, MXKKeyPreSharingStrategy) +{ + MXKKeyPreSharingNone = 0, + MXKKeyPreSharingWhenEnteringRoom = 1, + MXKKeyPreSharingWhenTyping = 2 +}; + +/** + `MXKAppSettings` represents the application settings. Most of them are used to handle matrix session data. + + The shared object `standardAppSettings` provides the default application settings defined in `standardUserDefaults`. + Any property change of this shared settings is reported into `standardUserDefaults`. + + Developper may define their own `MXKAppSettings` instances to handle specific setting values without impacting the shared object. + */ +@interface MXKAppSettings : NSObject + +#pragma mark - /sync filter + +/** + Lazy load room members when /syncing with the homeserver. + */ +@property (nonatomic) BOOL syncWithLazyLoadOfRoomMembers; + +#pragma mark - Room display + +/** + Display all received events in room history (Only recognized events are displayed, presently `custom` events are ignored). + + This boolean value is defined in shared settings object with the key: `showAllEventsInRoomHistory`. + Return NO if no value is defined. + */ +@property (nonatomic) BOOL showAllEventsInRoomHistory; + +/** + The types of events allowed to be displayed in room history. + Its value depends on `showAllEventsInRoomHistory`. + */ +@property (nonatomic, readonly) NSArray *eventsFilterForMessages; + +/** + All the event types which may be displayed in the room history. + */ +@property (nonatomic, readonly) NSArray *allEventTypesForMessages; + +/** + An allow list for the types of events allowed to be displayed as the last message. + + When `nil`, there is no list and all events are allowed. + */ +@property (nonatomic, readonly) NSArray *lastMessageEventTypesAllowList; + +/** + Add event types to `eventsFilterForMessages` and `eventsFilterForMessages`. + + @param eventTypes the event types to add. + */ +- (void)addSupportedEventTypes:(NSArray *)eventTypes; + +/** + Remove event types from `eventsFilterForMessages` and `eventsFilterForMessages`. + + @param eventTypes the event types to remove. + */ +- (void)removeSupportedEventTypes:(NSArray *)eventTypes; + +/** + Display redacted events in room history. + + This boolean value is defined in shared settings object with the key: `showRedactionsInRoomHistory`. + Return NO if no value is defined. + */ +@property (nonatomic) BOOL showRedactionsInRoomHistory; + +/** + Display unsupported/unexpected events in room history. + + This boolean value is defined in shared settings object with the key: `showUnsupportedEventsInRoomHistory`. + Return NO if no value is defined. + */ +@property (nonatomic) BOOL showUnsupportedEventsInRoomHistory; + +/** + Scheme with which to open HTTP links. e.g. if this is set to "googlechrome", any http:// links displayed in a room will be rewritten to use the googlechrome:// scheme. + Defaults to "http". + */ +@property (nonatomic) NSString *httpLinkScheme; + +/** + Scheme with which to open HTTPS links. e.g. if this is set to "googlechromes", any https:// links displayed in a room will be rewritten to use the googlechromes:// scheme. + Defaults to "https". + */ +@property (nonatomic) NSString *httpsLinkScheme; + +/** + Whether a bubble component should detect the first link in its event's body, storing it in the `link` property. + + This boolean value is defined in shared settings object with the key: `enableBubbleComponentLinkDetection`. + Return NO if no value is defined. + */ +@property (nonatomic) BOOL enableBubbleComponentLinkDetection; + +/** + Any hosts that should be ignored when calling `mxk_firstURLDetected` on an `NSString` without passing in any parameters. + Customising this value modifies the behaviour of link detection in `MXKRoomBubbleComponent`. + + This boolean value is defined in shared settings object with the key: `firstURLDetectionIgnoredHosts`. + The default value of this property only contains the matrix.to host. + */ +@property (nonatomic) NSArray *firstURLDetectionIgnoredHosts; + +/** + Indicate to hide un-decryptable events before joining the room. Default is `NO`. + */ +@property (nonatomic) BOOL hidePreJoinedUndecryptableEvents; + +/** + Indicate to hide un-decryptable events in the room. Default is `NO`. + */ +@property (nonatomic) BOOL hideUndecryptableEvents; + +/** + Indicates the strategy for sharing the outbound session key to other devices of the room + */ +@property (nonatomic) MXKKeyPreSharingStrategy outboundGroupSessionKeyPreSharingStrategy; + +#pragma mark - Room members + +/** + Sort room members by considering their presence. + Set NO to sort members in alphabetic order. + + This boolean value is defined in shared settings object with the key: `sortRoomMembersUsingLastSeenTime`. + Return YES if no value is defined. + */ +@property (nonatomic) BOOL sortRoomMembersUsingLastSeenTime; + +/** + Show left members in room member list. + + This boolean value is defined in shared settings object with the key: `showLeftMembersInRoomMemberList`. + Return NO if no value is defined. + */ +@property (nonatomic) BOOL showLeftMembersInRoomMemberList; + +/// Flag to allow sharing a message or not. Default value is YES. +@property (nonatomic) BOOL messageDetailsAllowSharing; + +/// Flag to allow saving a message or not. Default value is YES. +@property (nonatomic) BOOL messageDetailsAllowSaving; + +/// Flag to allow copying a media/file or not. Default value is YES. +@property (nonatomic) BOOL messageDetailsAllowCopyingMedia; + +/// Flag to allow pasting a media/file or not. Default value is YES. +@property (nonatomic) BOOL messageDetailsAllowPastingMedia; + +#pragma mark - Contacts + +/** + Return YES if the user allows the local contacts sync. + + This boolean value is defined in shared settings object with the key: `syncLocalContacts`. + Return NO if no value is defined. + */ +@property (nonatomic) BOOL syncLocalContacts; + +/** + Return YES if the user has been already asked for local contacts sync permission. + + This boolean value is defined in shared settings object with the key: `syncLocalContactsPermissionRequested`. + Return NO if no value is defined. + */ +@property (nonatomic) BOOL syncLocalContactsPermissionRequested; + +/** + Return YES if after the user has been asked for local contacts sync permission and choose to open + the system's Settings app to enable contacts access. + + This boolean value is defined in shared settings object with the key: `syncLocalContactsPermissionOpenedSystemSettings`. + Return NO if no value is defined. + */ +@property (nonatomic) BOOL syncLocalContactsPermissionOpenedSystemSettings; + +/** + The current selected country code for the phonebook. + + This value is defined in shared settings object with the key: `phonebookCountryCode`. + Return the SIM card information (if any) if no default value is defined. + */ +@property (nonatomic) NSString* phonebookCountryCode; + + +#pragma mark - Matrix users + +/** + Color associated to online matrix users. + + This color value is defined in shared settings object with the key: `presenceColorForOnlineUser`. + The default color is `[UIColor greenColor]`. + */ +@property (nonatomic) UIColor *presenceColorForOnlineUser; + +/** + Color associated to unavailable matrix users. + + This color value is defined in shared settings object with the key: `presenceColorForUnavailableUser`. + The default color is `[UIColor yellowColor]`. + */ +@property (nonatomic) UIColor *presenceColorForUnavailableUser; + +/** + Color associated to offline matrix users. + + This color value is defined in shared settings object with the key: `presenceColorForOfflineUser`. + The default color is `[UIColor redColor]`. + */ +@property (nonatomic) UIColor *presenceColorForOfflineUser; + +#pragma mark - Notifications + +/// Flag to allow PushKit pushers or not. Default value is `NO`. +@property (nonatomic, assign) BOOL allowPushKitPushers; + +/** + A localization key used when registering the default notification payload. + This key will be translated and displayed for APNS notifications as the body + content, unless it is modified locally by a Notification Service Extension. + + The default value for this setting is "MESSAGE". Changes are *not* persisted. + Updating the value after MXKAccount has called `enableAPNSPusher:success:failure:` + will have no effect. + */ +@property (nonatomic) NSString *notificationBodyLocalizationKey; + +#pragma mark - Calls + +/** + Return YES if the user enable CallKit support. + + This boolean value is defined in shared settings object with the key: `enableCallKit`. + Return YES if no value is defined. + */ +@property (nonatomic, getter=isCallKitEnabled) BOOL enableCallKit; + +#pragma mark - Shared userDefaults + +/** + A userDefaults object that is shared within the application group. The application group identifier + is retrieved from MXSDKOptions sharedInstance (see `applicationGroupIdentifier` property). + The default group is "group.org.matrix". + */ +@property (nonatomic, readonly) NSUserDefaults *sharedUserDefaults; + +#pragma mark - Class methods + +/** + Return the shared application settings object. These settings are retrieved/stored in the shared defaults object (`[NSUserDefaults standardUserDefaults]`). + */ ++ (MXKAppSettings *)standardAppSettings; + +/** + Return the folder to use for caching MatrixKit data. + */ ++ (NSString*)cacheFolder; + +/** + Restore the default values. + */ +- (void)reset; + +@end diff --git a/Riot/Modules/MatrixKit/Models/MXKAppSettings.m b/Riot/Modules/MatrixKit/Models/MXKAppSettings.m new file mode 100644 index 000000000..22b48f831 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXKAppSettings.m @@ -0,0 +1,865 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAppSettings.h" + +#import "MXKTools.h" + + +// get ISO country name +#import +#import + +static MXKAppSettings *standardAppSettings = nil; + +static NSString *const kMXAppGroupID = @"group.org.matrix"; + +@interface MXKAppSettings () +{ + NSMutableArray *eventsFilterForMessages; + NSMutableArray *allEventTypesForMessages; + NSMutableArray *lastMessageEventTypesAllowList; +} + +@property (nonatomic, readwrite) NSUserDefaults *sharedUserDefaults; +@property (nonatomic) NSString *currentApplicationGroup; + +@end + +@implementation MXKAppSettings +@synthesize syncWithLazyLoadOfRoomMembers; +@synthesize showAllEventsInRoomHistory, showRedactionsInRoomHistory, showUnsupportedEventsInRoomHistory, httpLinkScheme, httpsLinkScheme; +@synthesize enableBubbleComponentLinkDetection, firstURLDetectionIgnoredHosts, showLeftMembersInRoomMemberList, sortRoomMembersUsingLastSeenTime; +@synthesize syncLocalContacts, syncLocalContactsPermissionRequested, syncLocalContactsPermissionOpenedSystemSettings, phonebookCountryCode; +@synthesize presenceColorForOnlineUser, presenceColorForUnavailableUser, presenceColorForOfflineUser; +@synthesize enableCallKit; +@synthesize sharedUserDefaults; + ++ (MXKAppSettings *)standardAppSettings +{ + @synchronized(self) + { + if(standardAppSettings == nil) + { + standardAppSettings = [[super allocWithZone:NULL] init]; + } + } + return standardAppSettings; +} + ++ (NSString *)cacheFolder +{ + NSString *cacheFolder; + + // Check for a potential application group id + NSString *applicationGroupIdentifier = [MXSDKOptions sharedInstance].applicationGroupIdentifier; + if (applicationGroupIdentifier) + { + NSURL *sharedContainerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:applicationGroupIdentifier]; + cacheFolder = [sharedContainerURL path]; + } + else + { + NSArray *cacheDirList = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); + cacheFolder = [cacheDirList objectAtIndex:0]; + } + + // Use a dedicated cache folder for MatrixKit + cacheFolder = [cacheFolder stringByAppendingPathComponent:@"MatrixKit"]; + + // Make sure the folder exists so that it can be used + if (cacheFolder && ![[NSFileManager defaultManager] fileExistsAtPath:cacheFolder]) + { + NSError *error; + [[NSFileManager defaultManager] createDirectoryAtPath:cacheFolder withIntermediateDirectories:YES attributes:nil error:&error]; + if (error) + { + MXLogDebug(@"[MXKAppSettings] cacheFolder: Error: Cannot create MatrixKit folder at %@. Error: %@", cacheFolder, error); + } + } + + return cacheFolder; +} + +#pragma mark - + +-(instancetype)init +{ + if (self = [super init]) + { + syncWithLazyLoadOfRoomMembers = YES; + + // Use presence to sort room members by default + if (![[NSUserDefaults standardUserDefaults] objectForKey:@"sortRoomMembersUsingLastSeenTime"]) + { + [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"sortRoomMembersUsingLastSeenTime"]; + } + _hidePreJoinedUndecryptableEvents = NO; + _hideUndecryptableEvents = NO; + sortRoomMembersUsingLastSeenTime = YES; + + presenceColorForOnlineUser = [UIColor greenColor]; + presenceColorForUnavailableUser = [UIColor yellowColor]; + presenceColorForOfflineUser = [UIColor redColor]; + + httpLinkScheme = @"http"; + httpsLinkScheme = @"https"; + enableBubbleComponentLinkDetection = NO; + firstURLDetectionIgnoredHosts = @[[NSURL URLWithString:kMXMatrixDotToUrl].host]; + + _allowPushKitPushers = NO; + _notificationBodyLocalizationKey = @"MESSAGE"; + enableCallKit = YES; + + eventsFilterForMessages = @[ + kMXEventTypeStringRoomCreate, + kMXEventTypeStringRoomName, + kMXEventTypeStringRoomTopic, + kMXEventTypeStringRoomMember, + kMXEventTypeStringRoomEncrypted, + kMXEventTypeStringRoomEncryption, + kMXEventTypeStringRoomHistoryVisibility, + kMXEventTypeStringRoomMessage, + kMXEventTypeStringRoomThirdPartyInvite, + kMXEventTypeStringRoomGuestAccess, + kMXEventTypeStringRoomJoinRules, + kMXEventTypeStringCallInvite, + kMXEventTypeStringCallAnswer, + kMXEventTypeStringCallHangup, + kMXEventTypeStringCallReject, + kMXEventTypeStringCallNegotiate, + kMXEventTypeStringSticker, + kMXEventTypeStringKeyVerificationCancel, + kMXEventTypeStringKeyVerificationDone + ].mutableCopy; + + + // List all the event types, except kMXEventTypeStringPresence which are not related to a specific room. + allEventTypesForMessages = @[ + kMXEventTypeStringRoomName, + kMXEventTypeStringRoomTopic, + kMXEventTypeStringRoomMember, + kMXEventTypeStringRoomCreate, + kMXEventTypeStringRoomEncrypted, + kMXEventTypeStringRoomEncryption, + kMXEventTypeStringRoomJoinRules, + kMXEventTypeStringRoomPowerLevels, + kMXEventTypeStringRoomAliases, + kMXEventTypeStringRoomHistoryVisibility, + kMXEventTypeStringRoomMessage, + kMXEventTypeStringRoomMessageFeedback, + kMXEventTypeStringRoomRedaction, + kMXEventTypeStringRoomThirdPartyInvite, + kMXEventTypeStringRoomRelatedGroups, + kMXEventTypeStringReaction, + kMXEventTypeStringCallInvite, + kMXEventTypeStringCallAnswer, + kMXEventTypeStringCallSelectAnswer, + kMXEventTypeStringCallHangup, + kMXEventTypeStringCallReject, + kMXEventTypeStringCallNegotiate, + kMXEventTypeStringSticker, + kMXEventTypeStringKeyVerificationCancel, + kMXEventTypeStringKeyVerificationDone + ].mutableCopy; + + lastMessageEventTypesAllowList = @[ + kMXEventTypeStringRoomCreate, // Without any messages, calls or stickers an event is needed to provide a date. + kMXEventTypeStringRoomEncrypted, // Show a UTD string rather than the previous message. + kMXEventTypeStringRoomMessage, + kMXEventTypeStringRoomMember, + kMXEventTypeStringCallInvite, + kMXEventTypeStringCallAnswer, + kMXEventTypeStringCallHangup, + kMXEventTypeStringSticker + ].mutableCopy; + + _messageDetailsAllowSharing = YES; + _messageDetailsAllowSaving = YES; + _messageDetailsAllowCopyingMedia = YES; + _messageDetailsAllowPastingMedia = YES; + _outboundGroupSessionKeyPreSharingStrategy = MXKKeyPreSharingWhenTyping; + } + return self; +} + +- (void)reset +{ + if (self == [MXKAppSettings standardAppSettings]) + { + // Flush shared user defaults + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"syncWithLazyLoadOfRoomMembers2"]; + + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"showAllEventsInRoomHistory"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"showRedactionsInRoomHistory"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"showUnsupportedEventsInRoomHistory"]; + + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"sortRoomMembersUsingLastSeenTime"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"showLeftMembersInRoomMemberList"]; + + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"syncLocalContactsPermissionRequested"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"syncLocalContacts"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"phonebookCountryCode"]; + + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"presenceColorForOnlineUser"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"presenceColorForUnavailableUser"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"presenceColorForOfflineUser"]; + + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"httpLinkScheme"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"httpsLinkScheme"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"enableBubbleComponentLinkDetection"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"firstURLDetectionIgnoredHosts"]; + + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"enableCallKit"]; + } + else + { + syncWithLazyLoadOfRoomMembers = YES; + + showAllEventsInRoomHistory = NO; + showRedactionsInRoomHistory = NO; + showUnsupportedEventsInRoomHistory = NO; + + sortRoomMembersUsingLastSeenTime = YES; + showLeftMembersInRoomMemberList = NO; + + syncLocalContactsPermissionRequested = NO; + syncLocalContacts = NO; + phonebookCountryCode = nil; + + presenceColorForOnlineUser = [UIColor greenColor]; + presenceColorForUnavailableUser = [UIColor yellowColor]; + presenceColorForOfflineUser = [UIColor redColor]; + + httpLinkScheme = @"http"; + httpsLinkScheme = @"https"; + + enableCallKit = YES; + } +} + +- (NSUserDefaults *)sharedUserDefaults +{ + if (sharedUserDefaults) + { + // Check whether the current group id did not change. + NSString *applicationGroup = [MXSDKOptions sharedInstance].applicationGroupIdentifier; + if (!applicationGroup.length) + { + applicationGroup = kMXAppGroupID; + } + + if (![_currentApplicationGroup isEqualToString:applicationGroup]) + { + // Reset the existing shared object + sharedUserDefaults = nil; + } + } + + if (!sharedUserDefaults) + { + _currentApplicationGroup = [MXSDKOptions sharedInstance].applicationGroupIdentifier; + if (!_currentApplicationGroup.length) + { + _currentApplicationGroup = kMXAppGroupID; + } + + sharedUserDefaults = [[NSUserDefaults alloc] initWithSuiteName:_currentApplicationGroup]; + } + + return sharedUserDefaults; +} + +#pragma mark - Calls + +- (BOOL)syncWithLazyLoadOfRoomMembers +{ + if (self == [MXKAppSettings standardAppSettings]) + { + id storedValue = [[NSUserDefaults standardUserDefaults] objectForKey:@"syncWithLazyLoadOfRoomMembers2"]; + if (storedValue) + { + return [(NSNumber *)storedValue boolValue]; + } + else + { + // Enabled by default + return YES; + } + } + else + { + return syncWithLazyLoadOfRoomMembers; + } +} + +- (void)setSyncWithLazyLoadOfRoomMembers:(BOOL)syncWithLazyLoadOfRoomMembers +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:syncWithLazyLoadOfRoomMembers forKey:@"syncWithLazyLoadOfRoomMembers2"]; + } + else + { + syncWithLazyLoadOfRoomMembers = syncWithLazyLoadOfRoomMembers; + } +} + +#pragma mark - Room display + +- (BOOL)showAllEventsInRoomHistory +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"showAllEventsInRoomHistory"]; + } + else + { + return showAllEventsInRoomHistory; + } +} + +- (void)setShowAllEventsInRoomHistory:(BOOL)boolValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:boolValue forKey:@"showAllEventsInRoomHistory"]; + } + else + { + showAllEventsInRoomHistory = boolValue; + } +} + +- (NSArray *)eventsFilterForMessages +{ + if (showAllEventsInRoomHistory) + { + // Consider all the event types + return self.allEventTypesForMessages; + } + else + { + // Display only a subset of events + return eventsFilterForMessages; + } +} + +- (NSArray *)allEventTypesForMessages +{ + return allEventTypesForMessages; +} + +- (NSArray *)lastMessageEventTypesAllowList +{ + return lastMessageEventTypesAllowList; +} + +- (void)addSupportedEventTypes:(NSArray *)eventTypes +{ + [eventsFilterForMessages addObjectsFromArray:eventTypes]; + [allEventTypesForMessages addObjectsFromArray:eventTypes]; +} + +- (void)removeSupportedEventTypes:(NSArray *)eventTypes +{ + [eventsFilterForMessages removeObjectsInArray:eventTypes]; + [allEventTypesForMessages removeObjectsInArray:eventTypes]; + [lastMessageEventTypesAllowList removeObjectsInArray:eventTypes]; +} + +- (BOOL)showRedactionsInRoomHistory +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"showRedactionsInRoomHistory"]; + } + else + { + return showRedactionsInRoomHistory; + } +} + +- (void)setShowRedactionsInRoomHistory:(BOOL)boolValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:boolValue forKey:@"showRedactionsInRoomHistory"]; + } + else + { + showRedactionsInRoomHistory = boolValue; + } +} + +- (BOOL)showUnsupportedEventsInRoomHistory +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"showUnsupportedEventsInRoomHistory"]; + } + else + { + return showUnsupportedEventsInRoomHistory; + } +} + +- (void)setShowUnsupportedEventsInRoomHistory:(BOOL)boolValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:boolValue forKey:@"showUnsupportedEventsInRoomHistory"]; + } + else + { + showUnsupportedEventsInRoomHistory = boolValue; + } +} + +- (NSString *)httpLinkScheme +{ + if (self == [MXKAppSettings standardAppSettings]) + { + NSString *ret = [[NSUserDefaults standardUserDefaults] stringForKey:@"httpLinkScheme"]; + if (ret == nil) { + ret = @"http"; + } + return ret; + } + else + { + return httpLinkScheme; + } +} + +- (void)setHttpLinkScheme:(NSString *)stringValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setObject:stringValue forKey:@"httpLinkScheme"]; + } + else + { + httpLinkScheme = stringValue; + } +} + +- (NSString *)httpsLinkScheme +{ + if (self == [MXKAppSettings standardAppSettings]) + { + NSString *ret = [[NSUserDefaults standardUserDefaults] stringForKey:@"httpsLinkScheme"]; + if (ret == nil) { + ret = @"https"; + } + return ret; + } + else + { + return httpsLinkScheme; + } +} + +- (void)setHttpsLinkScheme:(NSString *)stringValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setObject:stringValue forKey:@"httpsLinkScheme"]; + } + else + { + httpsLinkScheme = stringValue; + } +} + +- (BOOL)enableBubbleComponentLinkDetection +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [NSUserDefaults.standardUserDefaults boolForKey:@"enableBubbleComponentLinkDetection"]; + } + else + { + return enableBubbleComponentLinkDetection; + } +} + +- (void)setEnableBubbleComponentLinkDetection:(BOOL)storeLinksInBubbleComponents +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [NSUserDefaults.standardUserDefaults setBool:storeLinksInBubbleComponents forKey:@"enableBubbleComponentLinkDetection"]; + } + else + { + enableBubbleComponentLinkDetection = storeLinksInBubbleComponents; + } +} + +- (NSArray *)firstURLDetectionIgnoredHosts +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [NSUserDefaults.standardUserDefaults objectForKey:@"firstURLDetectionIgnoredHosts"] ?: @[[NSURL URLWithString:kMXMatrixDotToUrl].host]; + } + else + { + return firstURLDetectionIgnoredHosts; + } +} + +- (void)setFirstURLDetectionIgnoredHosts:(NSArray *)ignoredHosts +{ + if (self == [MXKAppSettings standardAppSettings]) + { + if (ignoredHosts == nil) + { + ignoredHosts = @[]; + } + + [NSUserDefaults.standardUserDefaults setObject:ignoredHosts forKey:@"firstURLDetectionIgnoredHosts"]; + } + else + { + firstURLDetectionIgnoredHosts = ignoredHosts; + } +} + +#pragma mark - Room members + +- (BOOL)sortRoomMembersUsingLastSeenTime +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"sortRoomMembersUsingLastSeenTime"]; + } + else + { + return sortRoomMembersUsingLastSeenTime; + } +} + +- (void)setSortRoomMembersUsingLastSeenTime:(BOOL)boolValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:boolValue forKey:@"sortRoomMembersUsingLastSeenTime"]; + } + else + { + sortRoomMembersUsingLastSeenTime = boolValue; + } +} + +- (BOOL)showLeftMembersInRoomMemberList +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"showLeftMembersInRoomMemberList"]; + } + else + { + return showLeftMembersInRoomMemberList; + } +} + +- (void)setShowLeftMembersInRoomMemberList:(BOOL)boolValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:boolValue forKey:@"showLeftMembersInRoomMemberList"]; + } + else + { + showLeftMembersInRoomMemberList = boolValue; + } +} + +#pragma mark - Contacts + +- (BOOL)syncLocalContacts +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"syncLocalContacts"]; + } + else + { + return syncLocalContacts; + } +} + +- (void)setSyncLocalContacts:(BOOL)boolValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:boolValue forKey:@"syncLocalContacts"]; + } + else + { + syncLocalContacts = boolValue; + } +} + +- (BOOL)syncLocalContactsPermissionRequested +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"syncLocalContactsPermissionRequested"]; + } + else + { + return syncLocalContactsPermissionRequested; + } +} + +- (void)setSyncLocalContactsPermissionRequested:(BOOL)theSyncLocalContactsPermissionRequested +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:theSyncLocalContactsPermissionRequested forKey:@"syncLocalContactsPermissionRequested"]; + } + else + { + syncLocalContactsPermissionRequested = theSyncLocalContactsPermissionRequested; + } +} + +- (BOOL)syncLocalContactsPermissionOpenedSystemSettings +{ + if (self == [MXKAppSettings standardAppSettings]) + { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"syncLocalContactsPermissionOpenedSystemSettings"]; + } + else + { + return syncLocalContactsPermissionOpenedSystemSettings; + } +} + +- (void)setSyncLocalContactsPermissionOpenedSystemSettings:(BOOL)theSyncLocalContactsPermissionOpenedSystemSettings +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:theSyncLocalContactsPermissionOpenedSystemSettings forKey:@"syncLocalContactsPermissionOpenedSystemSettings"]; + } + else + { + syncLocalContactsPermissionOpenedSystemSettings = theSyncLocalContactsPermissionOpenedSystemSettings; + } +} + +- (NSString*)phonebookCountryCode +{ + NSString* res = phonebookCountryCode; + + if (self == [MXKAppSettings standardAppSettings]) + { + res = [[NSUserDefaults standardUserDefaults] stringForKey:@"phonebookCountryCode"]; + } + + // does not exist : try to get the SIM card information + if (!res) + { + // get the current MCC + CTTelephonyNetworkInfo *netInfo = [[CTTelephonyNetworkInfo alloc] init]; + CTCarrier *carrier = [netInfo subscriberCellularProvider]; + + if (carrier) + { + res = [[carrier isoCountryCode] uppercaseString]; + + if (res) + { + [self setPhonebookCountryCode:res]; + } + } + } + + return res; +} + +- (void)setPhonebookCountryCode:(NSString *)stringValue +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setObject:stringValue forKey:@"phonebookCountryCode"]; + } + else + { + phonebookCountryCode = stringValue; + } +} + +#pragma mark - Matrix users + +- (UIColor*)presenceColorForOnlineUser +{ + UIColor *color = presenceColorForOnlineUser; + + if (self == [MXKAppSettings standardAppSettings]) + { + NSNumber *rgbValue = [[NSUserDefaults standardUserDefaults] objectForKey:@"presenceColorForOnlineUser"]; + if (rgbValue) + { + color = [MXKTools colorWithRGBValue:[rgbValue unsignedIntegerValue]]; + } + else + { + color = [UIColor greenColor]; + } + } + + return color; +} + +- (void)setPresenceColorForOnlineUser:(UIColor*)color +{ + if (self == [MXKAppSettings standardAppSettings]) + { + if (color) + { + NSUInteger rgbValue = [MXKTools rgbValueWithColor:color]; + [[NSUserDefaults standardUserDefaults] setInteger:rgbValue forKey:@"presenceColorForOnlineUser"]; + } + else + { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"presenceColorForOnlineUser"]; + } + } + else + { + presenceColorForOnlineUser = color ? color : [UIColor greenColor]; + } +} + +- (UIColor*)presenceColorForUnavailableUser +{ + UIColor *color = presenceColorForUnavailableUser; + + if (self == [MXKAppSettings standardAppSettings]) + { + NSNumber *rgbValue = [[NSUserDefaults standardUserDefaults] objectForKey:@"presenceColorForUnavailableUser"]; + if (rgbValue) + { + color = [MXKTools colorWithRGBValue:[rgbValue unsignedIntegerValue]]; + } + else + { + color = [UIColor yellowColor]; + } + } + + return color; +} + +- (void)setPresenceColorForUnavailableUser:(UIColor*)color +{ + if (self == [MXKAppSettings standardAppSettings]) + { + if (color) + { + NSUInteger rgbValue = [MXKTools rgbValueWithColor:color]; + [[NSUserDefaults standardUserDefaults] setInteger:rgbValue forKey:@"presenceColorForUnavailableUser"]; + } + else + { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"presenceColorForUnavailableUser"]; + } + } + else + { + presenceColorForUnavailableUser = color ? color : [UIColor yellowColor]; + } +} + +- (UIColor*)presenceColorForOfflineUser +{ + UIColor *color = presenceColorForOfflineUser; + + if (self == [MXKAppSettings standardAppSettings]) + { + NSNumber *rgbValue = [[NSUserDefaults standardUserDefaults] objectForKey:@"presenceColorForOfflineUser"]; + if (rgbValue) + { + color = [MXKTools colorWithRGBValue:[rgbValue unsignedIntegerValue]]; + } + else + { + color = [UIColor redColor]; + } + } + + return color; +} + +- (void)setPresenceColorForOfflineUser:(UIColor *)color +{ + if (self == [MXKAppSettings standardAppSettings]) + { + if (color) + { + NSUInteger rgbValue = [MXKTools rgbValueWithColor:color]; + [[NSUserDefaults standardUserDefaults] setInteger:rgbValue forKey:@"presenceColorForOfflineUser"]; + } + else + { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"presenceColorForOfflineUser"]; + } + } + else + { + presenceColorForOfflineUser = color ? color : [UIColor redColor]; + } +} + +#pragma mark - Calls + +- (BOOL)isCallKitEnabled +{ + if (self == [MXKAppSettings standardAppSettings]) + { + id storedValue = [[NSUserDefaults standardUserDefaults] objectForKey:@"enableCallKit"]; + if (storedValue) + { + return [(NSNumber *)storedValue boolValue]; + } + else + { + return YES; + } + } + else + { + return enableCallKit; + } +} + +- (void)setEnableCallKit:(BOOL)enable +{ + if (self == [MXKAppSettings standardAppSettings]) + { + [[NSUserDefaults standardUserDefaults] setBool:enable forKey:@"enableCallKit"]; + } + else + { + enableCallKit = enable; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/MXKCellData.h b/Riot/Modules/MatrixKit/Models/MXKCellData.h new file mode 100644 index 000000000..68fba4f95 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXKCellData.h @@ -0,0 +1,27 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +/** + `MXKCellData` objects contain data that is displayed by objects implementing `MXKCellRendering`. + + The goal of `MXKCellData` is mainly to cache computed data in order to avoid to compute it each time + a cell is displayed. + */ +@interface MXKCellData : NSObject + +@end diff --git a/Riot/Modules/MatrixKit/Models/MXKCellData.m b/Riot/Modules/MatrixKit/Models/MXKCellData.m new file mode 100644 index 000000000..faea25676 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXKCellData.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKCellData.h" + +@implementation MXKCellData + +@end diff --git a/Riot/Modules/MatrixKit/Models/MXKDataSource.h b/Riot/Modules/MatrixKit/Models/MXKDataSource.h new file mode 100644 index 000000000..a1332d6e2 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXKDataSource.h @@ -0,0 +1,225 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import +#import "MXKCellRendering.h" + +/** + List data source states. + */ +typedef enum : NSUInteger { + /** + Default value (used when all resources have been disposed). + The instance cannot be used anymore. + */ + MXKDataSourceStateUnknown, + + /** + Initialisation is in progress. + */ + MXKDataSourceStatePreparing, + + /** + Something wrong happens during initialisation. + */ + MXKDataSourceStateFailed, + + /** + Data source is ready to be used. + */ + MXKDataSourceStateReady + +} MXKDataSourceState; + +@protocol MXKDataSourceDelegate; + +/** + `MXKDataSource` is the base class for data sources managed by MatrixKit. + + Inherited 'MXKDataSource' instances are used to handle table or collection data. + They may conform to UITableViewDataSource or UICollectionViewDataSource protocol to be used as data source delegate + for a UITableView or a UICollectionView instance. + */ +@interface MXKDataSource : NSObject +{ +@protected + MXKDataSourceState state; +} + +/** + The matrix session. + */ +@property (nonatomic, weak, readonly) MXSession *mxSession; + +/** + The data source state + */ +@property (nonatomic, readonly) MXKDataSourceState state; + +/** + The delegate notified when the data has been updated. + */ +@property (weak, nonatomic) id delegate; + + +#pragma mark - Life cycle +/** + Base constructor of data source. + + Customization like class registrations must be done before loading data (see '[MXKDataSource registerCellDataClass: forCellIdentifier:]') . + That is why 3 steps should be considered during 'MXKDataSource' initialization: + 1- call [MXKDataSource initWithMatrixSession:] to initialize a new allocated object. + 2- customize classes and others... + 3- call [MXKDataSource finalizeInitialization] to finalize the initialization. + + @param mxSession the Matrix session to get data from. + @return the newly created instance. + */ +- (instancetype)initWithMatrixSession:(MXSession*)mxSession; + +/** + Finalize the initialization by adding an observer on matrix session state change. + */ +- (void)finalizeInitialization; + +/** + Dispose all resources. + */ +- (void)destroy; + +/** + This method is called when the state of the attached Matrix session has changed. + */ +- (void)didMXSessionStateChange; + + +#pragma mark - MXKCellData classes +/** + Register the MXKCellData class that will be used to process and store data for cells + with the designated identifier. + + @param cellDataClass a MXKCellData-inherited class that will handle data for cells. + @param identifier the identifier of targeted cell. + */ +- (void)registerCellDataClass:(Class)cellDataClass forCellIdentifier:(NSString *)identifier; + +/** + Return the MXKCellData class that handles data for cells with the designated identifier. + + @param identifier the cell identifier. + @return the associated MXKCellData-inherited class. + */ +- (Class)cellDataClassForCellIdentifier:(NSString *)identifier; + +#pragma mark - Pending HTTP requests + +/** + Cancel all registered requests. + */ +- (void)cancelAllRequests; + +@end + +@protocol MXKDataSourceDelegate + +/** + Ask the delegate which MXKCellRendering-compliant class must be used to render this cell data. + + This method is called when MXKDataSource instance is used as the data source delegate of a table or a collection. + CAUTION: The table or the collection MUST have registered the returned class with the same identifier than the one returned by [cellReuseIdentifierForCellData:]. + + @param cellData the cell data to display. + @return a MXKCellRendering-compliant class which inherits UITableViewCell or UICollectionViewCell class (nil if the cellData is not supported). + */ +- (Class)cellViewClassForCellData:(MXKCellData*)cellData; + +/** + Ask the delegate which identifier must be used to dequeue reusable cell for this cell data. + + This method is called when MXKDataSource instance is used as the data source delegate of a table or a collection. + CAUTION: The table or the collection MUST have registered the right class with the returned identifier (see [cellViewClassForCellData:]). + + @param cellData the cell data to display. + @return the reuse identifier for the cell (nil if the cellData is not supported). + */ +- (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData; + +/** + Tells the delegate that some cell data/views have been changed. + + @param dataSource the involved data source. + @param changes contains the index paths of objects that changed. + */ +- (void)dataSource:(MXKDataSource*)dataSource didCellChange:(id /* @TODO*/)changes; + +@optional + +/** + Tells the delegate that data source state changed + + @param dataSource the involved data source. + @param state the new data source state. + */ +- (void)dataSource:(MXKDataSource*)dataSource didStateChange:(MXKDataSourceState)state; + +/** + Relevant only for data source which support multi-sessions. + Tells the delegate that a matrix session has been added. + + @param dataSource the involved data source. + @param mxSession the new added session. + */ +- (void)dataSource:(MXKDataSource*)dataSource didAddMatrixSession:(MXSession*)mxSession; + +/** + Relevant only for data source which support multi-sessions. + Tells the delegate that a matrix session has been removed. + + @param dataSource the involved data source. + @param mxSession the removed session. + */ +- (void)dataSource:(MXKDataSource*)dataSource didRemoveMatrixSession:(MXSession*)mxSession; + +/** + Tells the delegate when a user action is observed inside a cell. + + @see `MXKCellRenderingDelegate` for more details. + + @param dataSource the involved data source. + @param actionIdentifier an identifier indicating the action type (tap, long press...) and which part of the cell is concerned. + @param cell the cell in which action has been observed. + @param userInfo a dict containing additional information. It depends on actionIdentifier. May be nil. + */ +- (void)dataSource:(MXKDataSource*)dataSource didRecognizeAction:(NSString*)actionIdentifier inCell:(id)cell userInfo:(NSDictionary*)userInfo; + +/** + Asks the delegate if a user action (click on a link) can be done. + + @see `MXKCellRenderingDelegate` for more details. + + @param dataSource the involved data source. + @param actionIdentifier an identifier indicating the action type (link click) and which part of the cell is concerned. + @param cell the cell in which action has been observed. + @param userInfo a dict containing additional information. It depends on actionIdentifier. May be nil. + @param defaultValue the value to return by default if the action is not handled. + @return a boolean value which depends on actionIdentifier. + */ +- (BOOL)dataSource:(MXKDataSource*)dataSource shouldDoAction:(NSString *)actionIdentifier inCell:(id)cell userInfo:(NSDictionary *)userInfo defaultValue:(BOOL)defaultValue; + +@end + diff --git a/Riot/Modules/MatrixKit/Models/MXKDataSource.m b/Riot/Modules/MatrixKit/Models/MXKDataSource.m new file mode 100644 index 000000000..49a787ae0 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXKDataSource.m @@ -0,0 +1,148 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKDataSource.h" + +#import "MXKCellData.h" +#import "MXKCellRendering.h" + +@interface MXKDataSource () +{ + /** + The mapping between cell identifiers and MXKCellData classes. + */ + NSMutableDictionary *cellDataMap; +} +@end + +@implementation MXKDataSource +@synthesize state; + +#pragma mark - Life cycle + +- (instancetype)init +{ + self = [super init]; + if (self) + { + state = MXKDataSourceStateUnknown; + cellDataMap = [NSMutableDictionary dictionary]; + } + return self; +} + +- (instancetype)initWithMatrixSession:(MXSession *)matrixSession +{ + self = [self init]; + if (self) + { + _mxSession = matrixSession; + state = MXKDataSourceStatePreparing; + } + return self; +} + +- (void)finalizeInitialization +{ + // Add an observer on matrix session state change (prevent multiple registrations). + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionStateDidChangeNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXSessionStateChange:) name:kMXSessionStateDidChangeNotification object:nil]; + + // Call the registered callback to finalize the initialisation step. + [self didMXSessionStateChange]; +} + +- (void)destroy +{ + state = MXKDataSourceStateUnknown; + if (_delegate && [_delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [_delegate dataSource:self didStateChange:state]; + } + + _mxSession = nil; + _delegate = nil; + + [self cancelAllRequests]; + + [[NSNotificationCenter defaultCenter] removeObserver:self]; + cellDataMap = nil; +} + +#pragma mark - MXSessionStateDidChangeNotification +- (void)didMXSessionStateChange:(NSNotification *)notif +{ + // Check this is our Matrix session that has changed + if (notif.object == _mxSession) + { + [self didMXSessionStateChange]; + } +} + +- (void)didMXSessionStateChange +{ + // The inherited class is highly invited to override this method for its business logic +} + + +#pragma mark - MXKCellData classes +- (void)registerCellDataClass:(Class)cellDataClass forCellIdentifier:(NSString *)identifier +{ + // Sanity check: accept only MXKCellData classes or sub-classes + NSParameterAssert([cellDataClass isSubclassOfClass:MXKCellData.class]); + + cellDataMap[identifier] = cellDataClass; +} + +- (Class)cellDataClassForCellIdentifier:(NSString *)identifier +{ + return cellDataMap[identifier]; +} + +#pragma mark - MXKCellRenderingDelegate +- (void)cell:(id)cell didRecognizeAction:(NSString*)actionIdentifier userInfo:(NSDictionary *)userInfo +{ + // The data source simply relays the information to its delegate + if (_delegate && [_delegate respondsToSelector:@selector(dataSource:didRecognizeAction:inCell:userInfo:)]) + { + [_delegate dataSource:self didRecognizeAction:actionIdentifier inCell:cell userInfo:userInfo]; + } +} + +- (BOOL)cell:(id)cell shouldDoAction:(NSString *)actionIdentifier userInfo:(NSDictionary *)userInfo defaultValue:(BOOL)defaultValue +{ + BOOL shouldDoAction = defaultValue; + + // The data source simply relays the question to its delegate + if (_delegate && [_delegate respondsToSelector:@selector(dataSource:shouldDoAction:inCell:userInfo:defaultValue:)]) + { + shouldDoAction = [_delegate dataSource:self shouldDoAction:actionIdentifier inCell:cell userInfo:userInfo defaultValue:defaultValue]; + } + + return shouldDoAction; +} + + +#pragma mark - Pending HTTP requests +/** + Cancel all registered requests. + */ +- (void)cancelAllRequests +{ + // The inherited class is invited to override this method +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/MXKPasteboardManager.swift b/Riot/Modules/MatrixKit/Models/MXKPasteboardManager.swift new file mode 100644 index 000000000..814ae10b4 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/MXKPasteboardManager.swift @@ -0,0 +1,33 @@ +/* + Copyright 2020 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import UIKit + +@objcMembers +public class MXKPasteboardManager: NSObject { + + public static let shared = MXKPasteboardManager(withPasteboard: .general) + + private init(withPasteboard pasteboard: UIPasteboard) { + self.pasteboard = pasteboard + super.init() + } + + /// Pasteboard to use on copy operations. Defaults to `UIPasteboard.generalPasteboard`. + public var pasteboard: UIPasteboard + +} diff --git a/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellData.h b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellData.h new file mode 100644 index 000000000..e98dd16e1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellData.h @@ -0,0 +1,27 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKDirectoryServerCellDataStoring.h" + +/** + `MXKRoomMemberCellData` modelised the data for a `MXKRoomMemberTableViewCell` cell. + */ +@interface MXKDirectoryServerCellData : MXKCellData + +@end diff --git a/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellData.m b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellData.m new file mode 100644 index 000000000..e1f80368b --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellData.m @@ -0,0 +1,66 @@ +/* + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKDirectoryServerCellData.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKDirectoryServerCellData; +@synthesize desc, icon; +@synthesize homeserver, includeAllNetworks; +@synthesize thirdPartyProtocolInstance, thirdPartyProtocol; +@synthesize mediaManager; + +- (id)initWithHomeserver:(NSString *)theHomeserver includeAllNetworks:(BOOL)theIncludeAllNetworks +{ + self = [super init]; + if (self) + { + homeserver = theHomeserver; + includeAllNetworks = theIncludeAllNetworks; + + if (theIncludeAllNetworks) + { + desc = homeserver; + icon = nil; + } + else + { + // Use the Matrix name and logo when looking for Matrix rooms only + desc = [MatrixKitL10n matrix]; + icon = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"network_matrix"]; + } + } + return self; +} + +- (id)initWithProtocolInstance:(MXThirdPartyProtocolInstance *)instance protocol:(MXThirdPartyProtocol *)protocol +{ + self = [super init]; + if (self) + { + thirdPartyProtocolInstance = instance; + thirdPartyProtocol = protocol; + desc = thirdPartyProtocolInstance.desc; + icon = nil; + } + return self; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellDataStoring.h b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellDataStoring.h new file mode 100644 index 000000000..01bba13ea --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServerCellDataStoring.h @@ -0,0 +1,75 @@ +/* + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKCellData.h" + +/** + `MXKDirectoryServerCellDataStoring` defines a protocol a class must conform in order to + store directory cell data managed by `MXKDirectoryServersDataSource`. + */ +@protocol MXKDirectoryServerCellDataStoring + +#pragma mark - Data displayed by a server cell + +/** + The name of the directory server. + */ +@property (nonatomic) NSString *desc; + +/** + The icon of the server. + */ +@property (nonatomic) UIImage *icon; + +/** + The optional media manager used to download the icon of the server. + */ +@property (nonatomic) MXMediaManager *mediaManager; + +/** + In case the cell data represents a homeserver, its description. + */ +@property (nonatomic, readonly) NSString *homeserver; +@property (nonatomic, readonly) BOOL includeAllNetworks; + +/** + In case the cell data represents a third-party protocol instance, its description. + */ +@property (nonatomic, readonly) MXThirdPartyProtocolInstance *thirdPartyProtocolInstance; +@property (nonatomic, readonly) MXThirdPartyProtocol *thirdPartyProtocol; + +/** + Define a MXKDirectoryServerCellData that will store a homeserver. + + @param homeserver the homeserver name (ex: "matrix.org). + @param includeAllNetworks YES to list all public rooms on the homeserver whatever their protocol. + NO to list only matrix rooms. + */ +- (id)initWithHomeserver:(NSString*)homeserver includeAllNetworks:(BOOL)includeAllNetworks; + +/** + Define a MXKDirectoryServerCellData that will store a third-party protocol instance. + + @param instance the instance of the protocol. + @param protocol the protocol description. + */ +- (id)initWithProtocolInstance:(MXThirdPartyProtocolInstance*)instance protocol:(MXThirdPartyProtocol*)protocol; + +@end diff --git a/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServersDataSource.h b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServersDataSource.h new file mode 100644 index 000000000..c7079343c --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServersDataSource.h @@ -0,0 +1,82 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKDataSource.h" +#import "MXKDirectoryServerCellDataStoring.h" + +/** + Identifier to use for cells that display a server in the servers list. + */ +FOUNDATION_EXPORT NSString *const kMXKDirectorServerCellIdentifier; + +/** + `DirectoryServersDataSource` is a base class to list servers and third-party protocols + instances available on the user homeserver. + + We can then list public rooms from the directory of these servers. This is done + with `PublicRoomsDirectoryDataSource`. + + As a `MXKDataSource` child class, the class has a state where values have the following meanings: + - MXKDataSourceStatePreparing: the data source is not yet ready or it is fetching data from the homeserver. + - MXKDataSourceStateReady: the data source data is ready. + - MXKDataSourceStateFailed: the data source failed to fetch data. + + There is no way in Matrix to be notified when there is a change. + */ +@interface MXKDirectoryServersDataSource : MXKDataSource +{ +@protected + /** + The data for the cells served by `DirectoryServersDataSource`. + */ + NSMutableArray> *cellDataArray; + + /** + The filtered servers: sub-list of `cellDataArray` defined by `searchWithPatterns:`. + */ + NSMutableArray> *filteredCellDataArray; +} + +/** + Additional room directory servers the datasource will list. + */ +@property (nonatomic) NSArray *roomDirectoryServers; + +/** + Fetch the data source data. + */ +- (void)loadData; + +/** + Filter the current recents list according to the provided patterns. + When patterns are not empty, the search result is stored in `filteredCellDataArray`, + this array provides then data for the cells served by `MXKDirectoryServersDataSource`. + + @param patternsList the list of patterns to match with. Set nil to cancel search. + */ +- (void)searchWithPatterns:(NSArray *)patternsList; + +/** + Get the data for the cell at the given index path. + + @param indexPath the index of the cell. + @return the cell data. + */ +- (id)cellDataAtIndexPath:(NSIndexPath*)indexPath; + +@end diff --git a/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServersDataSource.m b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServersDataSource.m new file mode 100644 index 000000000..9520a16fb --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/PublicRoomList/DirectoryServerList/MXKDirectoryServersDataSource.m @@ -0,0 +1,230 @@ +/* + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKDirectoryServersDataSource.h" + +#import "MXKDirectoryServerCellData.h" + +NSString *const kMXKDirectorServerCellIdentifier = @"kMXKDirectorServerCellIdentifier"; + +#pragma mark - DirectoryServersDataSource + +@interface MXKDirectoryServersDataSource () +{ + // The pending request to load third-party protocols. + MXHTTPOperation *request; +} + +@end + +@implementation MXKDirectoryServersDataSource + +- (instancetype)init +{ + self = [super init]; + if (self) + { + cellDataArray = [NSMutableArray array]; + filteredCellDataArray = nil; + + // Set default data w classes + [self registerCellDataClass:MXKDirectoryServerCellData.class forCellIdentifier:kMXKDirectorServerCellIdentifier]; + } + return self; +} + +- (void)destroy +{ + cellDataArray = nil; + filteredCellDataArray = nil; +} + +- (void)cancelAllRequests +{ + [super cancelAllRequests]; + + [request cancel]; + request = nil; +} + +- (void)loadData +{ + // Cancel the previous request + if (request) + { + [request cancel]; + } + + // Reset all vars + [cellDataArray removeAllObjects]; + + [self setState:MXKDataSourceStatePreparing]; + + Class class = [self cellDataClassForCellIdentifier:kMXKDirectorServerCellIdentifier]; + + // Add user's HS + NSString *userHomeserver = self.mxSession.matrixRestClient.credentials.homeServerName; + id cellData = [[class alloc] initWithHomeserver:userHomeserver includeAllNetworks:YES]; + [cellDataArray addObject:cellData]; + + // Add user's HS but for Matrix public rooms only + cellData = [[class alloc] initWithHomeserver:userHomeserver includeAllNetworks:NO]; + [cellDataArray addObject:cellData]; + + // Add custom directory servers + for (NSString *homeserver in _roomDirectoryServers) + { + if (![homeserver isEqualToString:userHomeserver]) + { + cellData = [[class alloc] initWithHomeserver:homeserver includeAllNetworks:YES]; + [cellDataArray addObject:cellData]; + } + } + + MXWeakify(self); + request = [self.mxSession.matrixRestClient thirdpartyProtocols:^(MXThirdpartyProtocolsResponse *thirdpartyProtocolsResponse) { + + MXStrongifyAndReturnIfNil(self); + for (NSString *protocolName in thirdpartyProtocolsResponse.protocols) + { + MXThirdPartyProtocol *protocol = thirdpartyProtocolsResponse.protocols[protocolName]; + + for (MXThirdPartyProtocolInstance *instance in protocol.instances) + { + id cellData = [[class alloc] initWithProtocolInstance:instance protocol:protocol]; + cellData.mediaManager = self.mxSession.mediaManager; + [self->cellDataArray addObject:cellData]; + } + } + + [self setState:MXKDataSourceStateReady]; + + } failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + if (!self->request || self->request.isCancelled) + { + // Do not take into account error coming from a cancellation + return; + } + + self->request = nil; + + MXLogDebug(@"[MXKDirectoryServersDataSource] Failed to fecth third-party protocols. The HS may be too old to support third party networks"); + + [self setState:MXKDataSourceStateReady]; + }]; +} + +- (void)searchWithPatterns:(NSArray*)patternsList +{ + if (patternsList.count) + { + if (filteredCellDataArray) + { + [filteredCellDataArray removeAllObjects]; + } + else + { + filteredCellDataArray = [NSMutableArray arrayWithCapacity:cellDataArray.count]; + } + + for (id cellData in cellDataArray) + { + for (NSString* pattern in patternsList) + { + if ([cellData.desc rangeOfString:pattern options:NSCaseInsensitiveSearch].location != NSNotFound) + { + [filteredCellDataArray addObject:cellData]; + break; + } + } + } + } + else + { + filteredCellDataArray = nil; + } + + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } +} + +/** + Get the data for the cell at the given index path. + + @param indexPath the index of the cell. + @return the cell data. + */ +- (id)cellDataAtIndexPath:(NSIndexPath*)indexPath; +{ + if (filteredCellDataArray) + { + return filteredCellDataArray[indexPath.row]; + } + return cellDataArray[indexPath.row]; +} + + +#pragma mark - Private methods + +// Update the MXKDataSource state and the delegate +- (void)setState:(MXKDataSourceState)newState +{ + state = newState; + if (self.delegate && [self.delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [self.delegate dataSource:self didStateChange:state]; + } +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + if (filteredCellDataArray) + { + return filteredCellDataArray.count; + } + return cellDataArray.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + id cellData = [self cellDataAtIndexPath:indexPath]; + + if (cellData && self.delegate) + { + NSString *identifier = [self.delegate cellReuseIdentifierForCellData:cellData]; + if (identifier) + { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier forIndexPath:indexPath]; + + // Make the cell display the data + [cell render:cellData]; + + return cell; + } + } + + // Return a fake cell to prevent app from crashing. + return [[UITableViewCell alloc] init]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKAttachment.h b/Riot/Modules/MatrixKit/Models/Room/MXKAttachment.h new file mode 100644 index 000000000..07e878161 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKAttachment.h @@ -0,0 +1,212 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +@class MXKUTI; + +NS_ASSUME_NONNULL_BEGIN + +extern NSString * const kMXKAttachmentErrorDomain; + +/** + List attachment types + */ +typedef enum : NSUInteger { + MXKAttachmentTypeUndefined, + MXKAttachmentTypeImage, + MXKAttachmentTypeAudio, + MXKAttachmentTypeVoiceMessage, + MXKAttachmentTypeVideo, + MXKAttachmentTypeLocation, + MXKAttachmentTypeFile, + MXKAttachmentTypeSticker + +} MXKAttachmentType; + +/** + `MXKAttachment` represents a room attachment. + */ +@interface MXKAttachment : NSObject + +/** + The media manager instance used to download the attachment data. + */ +@property (nonatomic, readonly) MXMediaManager *mediaManager; + +/** + The attachment type. + */ +@property (nonatomic, readonly) MXKAttachmentType type; + +/** + The attachment information retrieved from the event content during the initialisation. + */ +@property (nonatomic, readonly, nullable) NSString *eventId; +@property (nonatomic, readonly, nullable) NSString *eventRoomId; +@property (nonatomic, readonly) MXEventSentState eventSentState; +@property (nonatomic, readonly, nullable) NSString *contentURL; +@property (nonatomic, readonly, nullable) NSDictionary *contentInfo; + +/** + The URL of a 'standard size' thumbnail. + */ +@property (nonatomic, readonly, nullable) NSString *mxcThumbnailURI; +@property (nonatomic, readonly, nullable) NSString *thumbnailMimeType; + +/** + The download identifier of the attachment content (related to contentURL). + */ +@property (nonatomic, readonly, nullable) NSString *downloadId; +/** + The download identifier of the attachment thumbnail. + */ +@property (nonatomic, readonly, nullable) NSString *thumbnailDownloadId; + +/** + The attached video thumbnail information. + */ +@property (nonatomic, readonly, nullable) NSDictionary *thumbnailInfo; + +/** + The original file name retrieved from the event body (if any). + */ +@property (nonatomic, readonly, nullable) NSString *originalFileName; + +/** + The thumbnail orientation (relevant in case of image). + */ +@property (nonatomic, readonly) UIImageOrientation thumbnailOrientation; + +/** + The cache file path of the attachment. + */ +@property (nonatomic, readonly, nullable) NSString *cacheFilePath; + +/** + The cache file path of the attachment thumbnail (may be nil). + */ +@property (nonatomic, readonly, nullable) NSString *thumbnailCachePath; + +/** + The preview of the attachment (nil by default). + */ +@property (nonatomic, nullable) UIImage *previewImage; + +/** + True if the attachment is encrypted + The encryption status of the thumbnail is not covered by this + property: it is possible for the thumbnail to be encrypted + whether this peoperty is true or false. + */ +@property (nonatomic, readonly) BOOL isEncrypted; + +/** + The UTI of this attachment. + */ +@property (nonatomic, readonly, nullable) MXKUTI *uti; + +/** + Create a `MXKAttachment` instance for the passed event. + The created instance copies the current data of the event (content, event id, sent state...). + It will ignore any future changes of these data. + + @param event a matrix event. + @param mediaManager the media manager instance used to download the attachment data. + @return `MXKAttachment` instance. + */ +- (nullable instancetype)initWithEvent:(MXEvent*)event andMediaManager:(MXMediaManager*)mediaManager; + +- (void)destroy; + +/** + Gets the thumbnail for this attachment if it is in the memory or disk cache, + otherwise return nil + */ +- (nullable UIImage *)getCachedThumbnail; + +/** + For image attachments, gets a UIImage for the full-res image + */ +- (void)getImage:(void (^_Nullable)(MXKAttachment *_Nullable, UIImage *_Nullable))onSuccess failure:(void (^_Nullable)(MXKAttachment *_Nullable, NSError * _Nullable error))onFailure; + +/** + Decrypt the attachment data into memory and provide it as an NSData + */ +- (void)getAttachmentData:(void (^_Nullable)(NSData *_Nullable))onSuccess failure:(void (^_Nullable)(NSError * _Nullable error))onFailure; + +/** + Decrypts the attachment to a newly created temporary file. + If the isEncrypted property is YES, this method (or getImage) should be used to + obtain the full decrypted attachment. The behaviour of this method is undefined + if isEncrypted is NO. + It is the caller's responsibility to delete the temporary file once it is no longer + needed. + */ +- (void)decryptToTempFile:(void (^_Nullable)(NSString *_Nullable))onSuccess failure:(void (^_Nullable)(NSError * _Nullable error))onFailure; + + +/** Deletes all previously created temporary files */ ++ (void)clearCache; + +/** + Gets the thumbnails for this attachment, downloading it or loading it from disk cache + if necessary + */ +- (void)getThumbnail:(void (^_Nullable)(MXKAttachment *_Nullable, UIImage *_Nullable))onSuccess failure:(void (^_Nullable)(MXKAttachment *_Nullable, NSError * _Nullable error))onFailure; + +/** + Download the attachment data if it is not already cached. + + @param onAttachmentReady block called when attachment is available at 'cacheFilePath'. + @param onFailure the block called on failure. + */ +- (void)prepare:(void (^_Nullable)(void))onAttachmentReady failure:(void (^_Nullable)(NSError * _Nullable error))onFailure; + +/** + Save the attachment in user's photo library. This operation is available only for images and video. + + @param onSuccess the block called on success. + @param onFailure the block called on failure. + */ +- (void)save:(void (^_Nullable)(void))onSuccess failure:(void (^_Nullable)(NSError * _Nullable error))onFailure; + +/** + Copy the attachment data in general pasteboard. + + @param onSuccess the block called on success. + @param onFailure the block called on failure. + */ +- (void)copy:(void (^_Nullable)(void))onSuccess failure:(void (^_Nullable)(NSError * _Nullable error))onFailure; + +/** + Prepare the attachment data to share it. The original name of the attachment (if any) is used + to name the prepared file. + + The developer must call 'onShareEnd' when share operation is ended in order to release potential + resources allocated here. + + @param onReadyToShare the block called when attachment is ready to share at the provided file URL. + @param onFailure the block called on failure. + */ +- (void)prepareShare:(void (^_Nullable)(NSURL * _Nullable fileURL))onReadyToShare failure:(void (^_Nullable)(NSError * _Nullable error))onFailure; +- (void)onShareEnded; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKAttachment.m b/Riot/Modules/MatrixKit/Models/Room/MXKAttachment.m new file mode 100644 index 000000000..e15fd70b3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKAttachment.m @@ -0,0 +1,718 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAttachment.h" +#import "MXKSwiftHeader.h" + +@import MatrixSDK; +@import MobileCoreServices; + +#import "MXKTools.h" + +// The size of thumbnail we request from the server +// Note that this is smaller than the ones we upload: when sending, one size +// must fit all, including the web which will want relatively high res thumbnails. +// We, however, are a mobile client and so would prefer smaller thumbnails, which +// we can have if they're being generated by the media repo. +static const int kThumbnailWidth = 320; +static const int kThumbnailHeight = 240; + +NSString *const kMXKAttachmentErrorDomain = @"kMXKAttachmentErrorDomain"; +NSString *const kMXKAttachmentFileNameBase = @"attatchment"; + +@interface MXKAttachment () +{ + /** + The information on the encrypted content. + */ + MXEncryptedContentFile *contentFile; + + /** + The information on the encrypted thumbnail. + */ + MXEncryptedContentFile *thumbnailFile; + + /** + Observe Attachment download + */ + id onAttachmentDownloadObs; + + /** + The local path used to store the attachment with its original name + */ + NSString *documentCopyPath; + + /** + The attachment mimetype. + */ + NSString *mimetype; +} + +@end + +@implementation MXKAttachment + +- (instancetype)initWithEvent:(MXEvent*)event andMediaManager:(MXMediaManager*)mediaManager +{ + self = [super init]; + if (self) + { + _mediaManager = mediaManager; + + // Make a copy as the data can be read at anytime later + _eventId = event.eventId; + _eventRoomId = event.roomId; + _eventSentState = event.sentState; + + NSDictionary *eventContent = event.content; + + // Set default thumbnail orientation + _thumbnailOrientation = UIImageOrientationUp; + + if (event.eventType == MXEventTypeSticker) + { + _type = MXKAttachmentTypeSticker; + MXJSONModelSetDictionary(_thumbnailInfo, eventContent[@"info"][@"thumbnail_info"]); + } + else + { + // Note: mxEvent.eventType is supposed to be MXEventTypeRoomMessage here. + NSString *msgtype = eventContent[@"msgtype"]; + if ([msgtype isEqualToString:kMXMessageTypeImage]) + { + _type = MXKAttachmentTypeImage; + } + else if (event.isVoiceMessage) + { + _type = MXKAttachmentTypeVoiceMessage; + } + else if ([msgtype isEqualToString:kMXMessageTypeAudio]) + { + _type = MXKAttachmentTypeAudio; + } + else if ([msgtype isEqualToString:kMXMessageTypeVideo]) + { + _type = MXKAttachmentTypeVideo; + MXJSONModelSetDictionary(_thumbnailInfo, eventContent[@"info"][@"thumbnail_info"]); + } + else if ([msgtype isEqualToString:kMXMessageTypeLocation]) + { + // Not supported yet + // _type = MXKAttachmentTypeLocation; + return nil; + } + else if ([msgtype isEqualToString:kMXMessageTypeFile]) + { + _type = MXKAttachmentTypeFile; + } + else + { + return nil; + } + } + + MXJSONModelSetString(_originalFileName, eventContent[@"body"]); + MXJSONModelSetDictionary(_contentInfo, eventContent[@"info"]); + MXJSONModelSetMXJSONModel(contentFile, MXEncryptedContentFile, eventContent[@"file"]); + + // Retrieve the content url by taking into account the potential encryption. + if (contentFile) + { + _isEncrypted = YES; + _contentURL = contentFile.url; + + MXJSONModelSetMXJSONModel(thumbnailFile, MXEncryptedContentFile, _contentInfo[@"thumbnail_file"]); + } + else + { + _isEncrypted = NO; + MXJSONModelSetString(_contentURL, eventContent[@"url"]); + } + + mimetype = nil; + if (_contentInfo) + { + MXJSONModelSetString(mimetype, _contentInfo[@"mimetype"]); + } + + _cacheFilePath = [MXMediaManager cachePathForMatrixContentURI:_contentURL andType:mimetype inFolder:_eventRoomId]; + _downloadId = [MXMediaManager downloadIdForMatrixContentURI:_contentURL inFolder:_eventRoomId]; + + // Deduce the thumbnail information from the retrieved data. + _mxcThumbnailURI = [self getThumbnailURI]; + _thumbnailMimeType = [self getThumbnailMimeType]; + _thumbnailCachePath = [self getThumbnailCachePath]; + _thumbnailDownloadId = [self getThumbnailDownloadId]; + } + return self; +} + +- (void)dealloc +{ + [self destroy]; +} + +- (void)destroy +{ + if (onAttachmentDownloadObs) + { + [[NSNotificationCenter defaultCenter] removeObserver:onAttachmentDownloadObs]; + onAttachmentDownloadObs = nil; + } + + // Remove the temporary file created to prepare attachment sharing + if (documentCopyPath) + { + [[NSFileManager defaultManager] removeItemAtPath:documentCopyPath error:nil]; + documentCopyPath = nil; + } + + _previewImage = nil; +} + +- (NSString *)getThumbnailURI +{ + if (thumbnailFile) + { + // there's an encrypted thumbnail: we return the mxc url + return thumbnailFile.url; + } + + // Look for a clear thumbnail url + return _contentInfo[@"thumbnail_url"]; +} + +- (NSString *)getThumbnailMimeType +{ + return _thumbnailInfo[@"mimetype"]; +} + +- (NSString*)getThumbnailCachePath +{ + if (_mxcThumbnailURI) + { + return [MXMediaManager cachePathForMatrixContentURI:_mxcThumbnailURI andType:_thumbnailMimeType inFolder:_eventRoomId]; + } + // In case of an unencrypted image, consider the thumbnail URI deduced from the content URL, except if + // the attachment is currently uploading. + // Note: When the uploading is in progress, the upload id is stored in the content url (nasty trick). + else if (_type == MXKAttachmentTypeImage && !_isEncrypted && _contentURL && ![_contentURL hasPrefix:kMXMediaUploadIdPrefix]) + { + return [MXMediaManager thumbnailCachePathForMatrixContentURI:_contentURL + andType:@"image/jpeg" + inFolder:_eventRoomId + toFitViewSize:CGSizeMake(kThumbnailWidth, kThumbnailHeight) + withMethod:MXThumbnailingMethodScale]; + + + } + return nil; +} + +- (NSString *)getThumbnailDownloadId +{ + if (_mxcThumbnailURI) + { + return [MXMediaManager downloadIdForMatrixContentURI:_mxcThumbnailURI inFolder:_eventRoomId]; + } + // In case of an unencrypted image, consider the thumbnail URI deduced from the content URL, except if + // the attachment is currently uploading. + // Note: When the uploading is in progress, the upload id is stored in the content url (nasty trick). + else if (_type == MXKAttachmentTypeImage && !_isEncrypted && _contentURL && ![_contentURL hasPrefix:kMXMediaUploadIdPrefix]) + { + return [MXMediaManager thumbnailDownloadIdForMatrixContentURI:_contentURL + inFolder:_eventRoomId + toFitViewSize:CGSizeMake(kThumbnailWidth, kThumbnailHeight) + withMethod:MXThumbnailingMethodScale]; + } + return nil; +} + +- (UIImage *)getCachedThumbnail +{ + if (_thumbnailCachePath) + { + UIImage *thumb = [MXMediaManager getFromMemoryCacheWithFilePath:_thumbnailCachePath]; + if (thumb) return thumb; + + if ([[NSFileManager defaultManager] fileExistsAtPath:_thumbnailCachePath]) + { + return [MXMediaManager loadThroughCacheWithFilePath:_thumbnailCachePath]; + } + } + return nil; +} + +- (void)getThumbnail:(void (^)(MXKAttachment *, UIImage *))onSuccess failure:(void (^)(MXKAttachment *, NSError *error))onFailure +{ + // Check whether a thumbnail is defined. + if (!_thumbnailCachePath) + { + // there is no thumbnail: if we're an image, return the full size image. Otherwise, nothing we can do. + if (_type == MXKAttachmentTypeImage) + { + [self getImage:onSuccess failure:onFailure]; + } + else if (onFailure) + { + onFailure(self, nil); + } + + return; + } + + // Check the current memory cache. + UIImage *thumb = [MXMediaManager getFromMemoryCacheWithFilePath:_thumbnailCachePath]; + if (thumb) + { + onSuccess(self, thumb); + return; + } + + if (thumbnailFile) + { + MXWeakify(self); + + void (^decryptAndCache)(void) = ^{ + MXStrongifyAndReturnIfNil(self); + NSInputStream *instream = [[NSInputStream alloc] initWithFileAtPath:self.thumbnailCachePath]; + NSOutputStream *outstream = [[NSOutputStream alloc] initToMemory]; + [MXEncryptedAttachments decryptAttachment:self->thumbnailFile inputStream:instream outputStream:outstream success:^{ + UIImage *img = [UIImage imageWithData:[outstream propertyForKey:NSStreamDataWrittenToMemoryStreamKey]]; + // Save this image to in-memory cache. + [MXMediaManager cacheImage:img withCachePath:self.thumbnailCachePath]; + onSuccess(self, img); + } failure:^(NSError *err) { + if (err) { + MXLogDebug(@"Error decrypting attachment! %@", err.userInfo); + if (onFailure) onFailure(self, err); + return; + } + }]; + }; + + if ([[NSFileManager defaultManager] fileExistsAtPath:_thumbnailCachePath]) + { + decryptAndCache(); + } + else + { + [_mediaManager downloadEncryptedMediaFromMatrixContentFile:thumbnailFile + mimeType:_thumbnailMimeType + inFolder:_eventRoomId + success:^(NSString *outputFilePath) { + decryptAndCache(); + } + failure:^(NSError *error) { + if (onFailure) onFailure(self, error); + }]; + } + } + else + { + if ([[NSFileManager defaultManager] fileExistsAtPath:_thumbnailCachePath]) + { + onSuccess(self, [MXMediaManager loadThroughCacheWithFilePath:_thumbnailCachePath]); + } + else if (_mxcThumbnailURI) + { + [_mediaManager downloadMediaFromMatrixContentURI:_mxcThumbnailURI + withType:_thumbnailMimeType + inFolder:_eventRoomId + success:^(NSString *outputFilePath) { + // Here outputFilePath = thumbnailCachePath + onSuccess(self, [MXMediaManager loadThroughCacheWithFilePath:outputFilePath]); + } + failure:^(NSError *error) { + if (onFailure) onFailure(self, error); + }]; + } + else + { + // Here _thumbnailCachePath is defined, so a thumbnail is available. + // Because _mxcThumbnailURI is null, this means we have to consider the content uri (see getThumbnailCachePath). + [_mediaManager downloadThumbnailFromMatrixContentURI:_contentURL + withType:@"image/jpeg" + inFolder:_eventRoomId + toFitViewSize:CGSizeMake(kThumbnailWidth, kThumbnailHeight) + withMethod:MXThumbnailingMethodScale + success:^(NSString *outputFilePath) { + // Here outputFilePath = thumbnailCachePath + onSuccess(self, [MXMediaManager loadThroughCacheWithFilePath:outputFilePath]); + } + failure:^(NSError *error) { + if (onFailure) onFailure(self, error); + }]; + } + } +} + +- (void)getImage:(void (^)(MXKAttachment *, UIImage *))onSuccess failure:(void (^)(MXKAttachment *, NSError *error))onFailure +{ + [self getAttachmentData:^(NSData *data) { + + UIImage *img = [UIImage imageWithData:data]; + + if (img) + { + if (onSuccess) + { + onSuccess(self, img); + } + } + else + { + if (onFailure) + { + NSError *error = [NSError errorWithDomain:kMXKAttachmentErrorDomain code:0 userInfo:@{@"err": @"error_get_image_from_data"}]; + onFailure(self, error); + } + } + + } failure:^(NSError *error) { + + if (onFailure) onFailure(self, error); + + }]; +} + +- (void)getAttachmentData:(void (^)(NSData *))onSuccess failure:(void (^)(NSError *error))onFailure +{ + MXWeakify(self); + [self prepare:^{ + MXStrongifyAndReturnIfNil(self); + if (self.isEncrypted) + { + // decrypt the encrypted file + NSInputStream *instream = [[NSInputStream alloc] initWithFileAtPath:self.cacheFilePath]; + NSOutputStream *outstream = [[NSOutputStream alloc] initToMemory]; + [MXEncryptedAttachments decryptAttachment:self->contentFile inputStream:instream outputStream:outstream success:^{ + onSuccess([outstream propertyForKey:NSStreamDataWrittenToMemoryStreamKey]); + } failure:^(NSError *err) { + if (err) + { + MXLogDebug(@"Error decrypting attachment! %@", err.userInfo); + return; + } + }]; + } + else + { + onSuccess([NSData dataWithContentsOfFile:self.cacheFilePath]); + } + } failure:onFailure]; +} + +- (void)decryptToTempFile:(void (^)(NSString *))onSuccess failure:(void (^)(NSError *error))onFailure +{ + MXWeakify(self); + [self prepare:^{ + MXStrongifyAndReturnIfNil(self); + NSString *tempPath = [self getTempFile]; + if (!tempPath) + { + if (onFailure) onFailure([NSError errorWithDomain:kMXKAttachmentErrorDomain code:0 userInfo:@{@"err": @"error_creating_temp_file"}]); + return; + } + + NSInputStream *inStream = [NSInputStream inputStreamWithFileAtPath:self.cacheFilePath]; + NSOutputStream *outStream = [NSOutputStream outputStreamToFileAtPath:tempPath append:NO]; + + [MXEncryptedAttachments decryptAttachment:self->contentFile inputStream:inStream outputStream:outStream success:^{ + onSuccess(tempPath); + } failure:^(NSError *err) { + if (err) { + if (onFailure) onFailure(err); + return; + } + }]; + } failure:onFailure]; +} + +- (NSString *)getTempFile +{ + // create a file with an appropriate extension because iOS detects based on file extension + // all over the place + NSString *ext = [MXTools fileExtensionFromContentType:mimetype]; + NSString *filenameTemplate = [NSString stringWithFormat:@"%@.XXXXXX%@", kMXKAttachmentFileNameBase, ext]; + NSString *template = [NSTemporaryDirectory() stringByAppendingPathComponent:filenameTemplate]; + + const char *templateCstr = [template fileSystemRepresentation]; + char *tempPathCstr = (char *)malloc(strlen(templateCstr) + 1); + strcpy(tempPathCstr, templateCstr); + + int fd = mkstemps(tempPathCstr, (int)ext.length); + if (!fd) + { + return nil; + } + close(fd); + + NSString *tempPath = [[NSFileManager defaultManager] stringWithFileSystemRepresentation:tempPathCstr + length:strlen(tempPathCstr)]; + free(tempPathCstr); + return tempPath; +} + ++ (void)clearCache +{ + NSString *temporaryDirectoryPath = NSTemporaryDirectory(); + NSDirectoryEnumerator *enumerator = [NSFileManager.defaultManager enumeratorAtPath:temporaryDirectoryPath]; + + NSString *filePath; + while (filePath = [enumerator nextObject]) { + if(![filePath containsString:kMXKAttachmentFileNameBase]) { + continue; + } + + NSError *error; + BOOL result = [NSFileManager.defaultManager removeItemAtPath:[temporaryDirectoryPath stringByAppendingPathComponent:filePath] error:&error]; + if (!result && error) { + MXLogError(@"[MXKAttachment] Failed deleting temporary file with error: %@", error); + } + } +} + +- (void)prepare:(void (^)(void))onAttachmentReady failure:(void (^)(NSError *error))onFailure +{ + if ([[NSFileManager defaultManager] fileExistsAtPath:_cacheFilePath]) + { + // Done + if (onAttachmentReady) + { + onAttachmentReady(); + } + } + else + { + // Trigger download if it is not already in progress + MXMediaLoader* loader = [MXMediaManager existingDownloaderWithIdentifier:_downloadId]; + if (!loader) + { + if (_isEncrypted) + { + loader = [_mediaManager downloadEncryptedMediaFromMatrixContentFile:contentFile + mimeType:mimetype + inFolder:_eventRoomId]; + } + else + { + loader = [_mediaManager downloadMediaFromMatrixContentURI:_contentURL + withType:mimetype + inFolder:_eventRoomId]; + } + } + + if (loader) + { + MXWeakify(self); + + // Add observers + onAttachmentDownloadObs = [[NSNotificationCenter defaultCenter] addObserverForName:kMXMediaLoaderStateDidChangeNotification object:loader queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXStrongifyAndReturnIfNil(self); + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + switch (loader.state) { + case MXMediaLoaderStateDownloadCompleted: + [[NSNotificationCenter defaultCenter] removeObserver:self->onAttachmentDownloadObs]; + self->onAttachmentDownloadObs = nil; + if (onAttachmentReady) + { + onAttachmentReady (); + } + break; + case MXMediaLoaderStateDownloadFailed: + case MXMediaLoaderStateCancelled: + [[NSNotificationCenter defaultCenter] removeObserver:self->onAttachmentDownloadObs]; + self->onAttachmentDownloadObs = nil; + if (onFailure) + { + onFailure (loader.error); + } + break; + default: + break; + } + }]; + } + else if (onFailure) + { + onFailure (nil); + } + } +} + +- (void)save:(void (^)(void))onSuccess failure:(void (^)(NSError *error))onFailure +{ + if (_type == MXKAttachmentTypeImage || _type == MXKAttachmentTypeVideo) + { + MXWeakify(self); + if (self.isEncrypted) { + [self decryptToTempFile:^(NSString *path) { + MXStrongifyAndReturnIfNil(self); + NSURL* url = [NSURL fileURLWithPath:path]; + + [MXMediaManager saveMediaToPhotosLibrary:url + isImage:(self.type == MXKAttachmentTypeImage) + success:^(NSURL *assetURL){ + if (onSuccess) + { + [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; + onSuccess(); + } + } + failure:onFailure]; + } failure:onFailure]; + } + else + { + [self prepare:^{ + MXStrongifyAndReturnIfNil(self); + NSURL* url = [NSURL fileURLWithPath:self.cacheFilePath]; + + [MXMediaManager saveMediaToPhotosLibrary:url + isImage:(self.type == MXKAttachmentTypeImage) + success:^(NSURL *assetURL){ + if (onSuccess) + { + onSuccess(); + } + } + failure:onFailure]; + } failure:onFailure]; + } + } + else + { + // Not supported + if (onFailure) + { + onFailure(nil); + } + } +} + +- (void)copy:(void (^)(void))onSuccess failure:(void (^)(NSError *error))onFailure +{ + MXWeakify(self); + [self prepare:^{ + MXStrongifyAndReturnIfNil(self); + if (self.type == MXKAttachmentTypeImage) + { + [self getImage:^(MXKAttachment *attachment, UIImage *img) { + MXKPasteboardManager.shared.pasteboard.image = img; + if (onSuccess) + { + onSuccess(); + } + } failure:^(MXKAttachment *attachment, NSError *error) { + if (onFailure) onFailure(error); + }]; + } + else + { + MXWeakify(self); + [self getAttachmentData:^(NSData *data) { + if (data) + { + MXStrongifyAndReturnIfNil(self); + NSString* UTI = (__bridge_transfer NSString *) UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (__bridge CFStringRef)[self.cacheFilePath pathExtension] , NULL); + + if (UTI) + { + [MXKPasteboardManager.shared.pasteboard setData:data forPasteboardType:UTI]; + if (onSuccess) + { + onSuccess(); + } + } + } + } failure:onFailure]; + } + + // Unexpected error + if (onFailure) + { + onFailure(nil); + } + + } failure:onFailure]; +} + +- (MXKUTI *)uti +{ + return [[MXKUTI alloc] initWithMimeType:mimetype]; +} + +- (void)prepareShare:(void (^)(NSURL *fileURL))onReadyToShare failure:(void (^)(NSError *error))onFailure +{ + MXWeakify(self); + void (^haveFile)(NSString *) = ^(NSString *path) { + // Prepare the file URL by considering the original file name (if any) + NSURL *fileUrl; + MXStrongifyAndReturnIfNil(self); + // Check whether the original name retrieved from event body has extension + if (self.originalFileName && [self.originalFileName pathExtension].length) + { + // Copy the cached file to restore its original name + // Note: We used previously symbolic link (instead of copy) but UIDocumentInteractionController failed to open Office documents (.docx, .pptx...). + self->documentCopyPath = [[MXMediaManager getCachePath] stringByAppendingPathComponent:self.originalFileName]; + + [[NSFileManager defaultManager] removeItemAtPath:self->documentCopyPath error:nil]; + if ([[NSFileManager defaultManager] copyItemAtPath:path toPath:self->documentCopyPath error:nil]) + { + fileUrl = [NSURL fileURLWithPath:self->documentCopyPath]; + [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; + } + } + + if (!fileUrl) + { + // Use the cached file by default + fileUrl = [NSURL fileURLWithPath:path]; + self->documentCopyPath = path; + } + + onReadyToShare (fileUrl); + }; + + if (self.isEncrypted) + { + [self decryptToTempFile:^(NSString *path) { + haveFile(path); + } failure:onFailure]; + } + else + { + // First download data if it is not already done + [self prepare:^{ + haveFile(self.cacheFilePath); + } failure:onFailure]; + } +} + +- (void)onShareEnded +{ + // Remove the temporary file created to prepare attachment sharing + if (documentCopyPath) + { + [[NSFileManager defaultManager] removeItemAtPath:documentCopyPath error:nil]; + documentCopyPath = nil; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKQueuedEvent.h b/Riot/Modules/MatrixKit/Models/Room/MXKQueuedEvent.h new file mode 100644 index 000000000..6639eb15c --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKQueuedEvent.h @@ -0,0 +1,52 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +/** + `MXKQueuedEvent` represents an event waiting to be processed. + */ +@interface MXKQueuedEvent : NSObject + +/** + The event. + */ +@property (nonatomic, readonly) MXEvent *event; + +/** + The state of the room when the event has been received. + */ +@property (nonatomic, readonly) MXRoomState *state; + +/** + The direction of reception. Is it a live event or an event from the history? + */ +@property (nonatomic, readonly) MXTimelineDirection direction; + +/** + Tells whether the event is queued during server sync or not. + */ +@property (nonatomic) BOOL serverSyncEvent; + +/** + Date of the `event`. If event has a valid `originServerTs`, it's converted to a date object, otherwise current date. + */ +@property (nonatomic, readonly) NSDate *eventDate; + +- (instancetype)initWithEvent:(MXEvent*)event andRoomState:(MXRoomState*)state direction:(MXTimelineDirection)direction; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKQueuedEvent.m b/Riot/Modules/MatrixKit/Models/Room/MXKQueuedEvent.m new file mode 100644 index 000000000..d06d475bf --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKQueuedEvent.m @@ -0,0 +1,43 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKQueuedEvent.h" + +@implementation MXKQueuedEvent + +- (instancetype)initWithEvent:(MXEvent *)event andRoomState:(MXRoomState *)state direction:(MXTimelineDirection)direction +{ + self = [super init]; + if (self) + { + _event = event; + _state = state; + _direction = direction; + } + return self; +} + +- (NSDate *)eventDate +{ + if (_event.originServerTs != kMXUndefinedTimestamp) + { + return [NSDate dateWithTimeIntervalSince1970:(double)_event.originServerTs/1000]; + } + + return [NSDate date]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h new file mode 100644 index 000000000..691f092e6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h @@ -0,0 +1,166 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKCellData.h" +#import "MXKRoomBubbleCellDataStoring.h" + +#import "MXKRoomBubbleComponent.h" + +#define MXKROOMBUBBLECELLDATA_TEXTVIEW_DEFAULT_VERTICAL_INSET 8 + +/** + `MXKRoomBubbleCellData` instances compose data for `MXKRoomBubbleTableViewCell` cells. + + This is the basic implementation which considers only one component (event) by bubble. + `MXKRoomBubbleCellDataWithAppendingMode` extends this class to merge consecutive messages from the same sender into one bubble. + */ +@interface MXKRoomBubbleCellData : MXKCellData +{ +@protected + /** + The data source owner of this instance. + */ + __weak MXKRoomDataSource *roomDataSource; + + /** + Array of bubble components. Each bubble is supposed to have at least one component. + */ + NSMutableArray *bubbleComponents; + + /** + The body of the message with sets of attributes, or kind of content description in case of attachment (e.g. "image attachment") + */ + NSAttributedString *attributedTextMessage; + + /** + The optional text pattern to be highlighted in the body of the message. + */ + NSString *highlightedPattern; + UIColor *highlightedPatternColor; + UIFont *highlightedPatternFont; +} + +/** + The matrix session. + */ +@property (nonatomic, readonly) MXSession *mxSession; + +/** + Returns bubble components list (`MXKRoomBubbleComponent` instances). + */ +@property (nonatomic, readonly) NSArray *bubbleComponents; + +/** + Read receipts per event. + */ +@property(nonatomic) NSMutableDictionary *> *readReceipts; + +/** + Aggregated reactions per event. + */ +@property(nonatomic) NSMutableDictionary *reactions; + +/** + Whether there is a link to preview in the components. + */ +@property (nonatomic, readonly) BOOL hasLink; + +/** + Event formatter + */ +@property (nonatomic) MXKEventFormatter *eventFormatter; + +/** + The max width of the text view used to display the text message (relevant only for text message or attached file). + */ +@property (nonatomic) CGFloat maxTextViewWidth; + +/** + The bubble content size depends on its type: + - Text: returns suitable content size of a text view to display the whole text message (respecting maxTextViewWidth). + - Attached image or video: returns suitable content size for an image view in order to display + attachment thumbnail or icon. + - Attached file: returns suitable content size of a text view to display the file name (no icon is used presently). + */ +@property (nonatomic) CGSize contentSize; + +/** + Set of flags indicating fixes that need to be applied at display time. + */ +@property (nonatomic, readonly) MXKRoomBubbleComponentDisplayFix displayFix; + +/** + Attachment upload + */ +@property (nonatomic) NSString *uploadId; +@property (nonatomic) CGFloat uploadProgress; + +/** + Indicate a bubble component needs to show encryption badge. + */ +@property (nonatomic, readonly) BOOL containsBubbleComponentWithEncryptionBadge; + +/** + Indicate that the current text message layout is no longer valid and should be recomputed + before presentation in a bubble cell. This could be due to the content changing, or the + available space for the cell has been updated. + + This will clear the current `attributedTextMessage` allowing it to be + rebuilt on demand when requested. + */ +- (void)invalidateTextLayout; + +/** + Check and refresh the position of each component. + */ +- (void)prepareBubbleComponentsPosition; + +/** + Return the raw height of the provided text by removing any vertical margin/inset. + + @param attributedText the attributed text to measure + @return the computed height + */ +- (CGFloat)rawTextHeight:(NSAttributedString*)attributedText; + +/** + Return the content size of a text view initialized with the provided attributed text. + CAUTION: This method runs only on main thread. + + @param attributedText the attributed text to measure + @param removeVerticalInset tell whether the computation should remove vertical inset in text container. + @return the computed size content + */ +- (CGSize)textContentSize:(NSAttributedString*)attributedText removeVerticalInset:(BOOL)removeVerticalInset; + +/** + Get bubble component index from event id. + + @param eventId Event id of bubble component. + @return Index of bubble component associated to event id or NSNotFound + */ +- (NSInteger)bubbleComponentIndexForEventId:(NSString *)eventId; + +/** + Get the first visible component. + + @return First visible component or nil. + */ +- (MXKRoomBubbleComponent*)getFirstBubbleComponentWithDisplay; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m new file mode 100644 index 000000000..b20bcb668 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m @@ -0,0 +1,923 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#define MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH 192 + +#define MXKROOMBUBBLECELLDATA_DEFAULT_MAX_TEXTVIEW_WIDTH 200 + +@import MatrixSDK; + +#import "MXKRoomBubbleCellData.h" + +#import "MXKTools.h" + +@implementation MXKRoomBubbleCellData +@synthesize senderId, targetId, roomId, senderDisplayName, senderAvatarUrl, senderAvatarPlaceholder, targetDisplayName, targetAvatarUrl, targetAvatarPlaceholder, isEncryptedRoom, isPaginationFirstBubble, shouldHideSenderInformation, date, isIncoming, isAttachmentWithThumbnail, isAttachmentWithIcon, attachment, senderFlair; +@synthesize textMessage, attributedTextMessage; +@synthesize shouldHideSenderName, isTyping, showBubbleDateTime, showBubbleReceipts, useCustomDateTimeLabel, useCustomReceipts, useCustomUnsentButton, hasNoDisplay; +@synthesize tag; +@synthesize collapsable, collapsed, collapsedAttributedTextMessage, prevCollapsableCellData, nextCollapsableCellData, collapseState; + +#pragma mark - MXKRoomBubbleCellDataStoring + +- (instancetype)initWithEvent:(MXEvent *)event andRoomState:(MXRoomState *)roomState andRoomDataSource:(MXKRoomDataSource *)roomDataSource2 +{ + self = [self init]; + if (self) + { + roomDataSource = roomDataSource2; + + // Initialize read receipts + self.readReceipts = [NSMutableDictionary dictionary]; + + // Create the bubble component based on matrix event + MXKRoomBubbleComponent *firstComponent = [[MXKRoomBubbleComponent alloc] initWithEvent:event roomState:roomState eventFormatter:roomDataSource.eventFormatter session:roomDataSource.mxSession]; + if (firstComponent) + { + bubbleComponents = [NSMutableArray array]; + [bubbleComponents addObject:firstComponent]; + + senderId = event.sender; + targetId = [event.type isEqualToString:kMXEventTypeStringRoomMember] ? event.stateKey : nil; + roomId = roomDataSource.roomId; + senderDisplayName = [roomDataSource.eventFormatter senderDisplayNameForEvent:event withRoomState:roomState]; + senderAvatarUrl = [roomDataSource.eventFormatter senderAvatarUrlForEvent:event withRoomState:roomState]; + senderAvatarPlaceholder = nil; + targetDisplayName = [roomDataSource.eventFormatter targetDisplayNameForEvent:event withRoomState:roomState]; + targetAvatarUrl = [roomDataSource.eventFormatter targetAvatarUrlForEvent:event withRoomState:roomState]; + targetAvatarPlaceholder = nil; + isEncryptedRoom = roomState.isEncrypted; + isIncoming = ([event.sender isEqualToString:roomDataSource.mxSession.myUser.userId] == NO); + + // Check attachment if any + if ([roomDataSource.eventFormatter isSupportedAttachment:event]) + { + // Note: event.eventType is equal here to MXEventTypeRoomMessage or MXEventTypeSticker + attachment = [[MXKAttachment alloc] initWithEvent:event andMediaManager:roomDataSource.mxSession.mediaManager]; + if (attachment && attachment.type == MXKAttachmentTypeImage) + { + // Check the current thumbnail orientation. Rotate the current content size (if need) + if (attachment.thumbnailOrientation == UIImageOrientationLeft || attachment.thumbnailOrientation == UIImageOrientationRight) + { + _contentSize = CGSizeMake(_contentSize.height, _contentSize.width); + } + } + } + + // Report the attributed string (This will initialize _contentSize attribute) + self.attributedTextMessage = firstComponent.attributedTextMessage; + + // Initialize rendering attributes + _maxTextViewWidth = MXKROOMBUBBLECELLDATA_DEFAULT_MAX_TEXTVIEW_WIDTH; + } + else + { + // Ignore this event + self = nil; + } + } + return self; +} + +- (void)dealloc +{ + // Reset any observer on publicised groups by user. + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidUpdatePublicisedGroupsForUsersNotification object:self.mxSession]; + + roomDataSource = nil; + bubbleComponents = nil; +} + +- (NSUInteger)updateEvent:(NSString *)eventId withEvent:(MXEvent *)event +{ + NSUInteger count = 0; + + @synchronized(bubbleComponents) + { + // Retrieve the component storing the event and update it + for (NSUInteger index = 0; index < bubbleComponents.count; index++) + { + MXKRoomBubbleComponent *roomBubbleComponent = [bubbleComponents objectAtIndex:index]; + if ([roomBubbleComponent.event.eventId isEqualToString:eventId]) + { + [roomBubbleComponent updateWithEvent:event roomState:roomDataSource.roomState session:self.mxSession]; + if (!roomBubbleComponent.textMessage.length) + { + [bubbleComponents removeObjectAtIndex:index]; + } + // Indicate that the text message layout should be recomputed. + [self invalidateTextLayout]; + + // Handle here attachment update. + // For example: the case of update of attachment event happens when an echo is replaced by its true event + // received back by the events stream. + if (attachment) + { + // Check the current content url, to update it with the actual one + // Retrieve content url/info + NSString *eventContentURL = event.content[@"url"]; + if (event.content[@"file"][@"url"]) + { + eventContentURL = event.content[@"file"][@"url"]; + } + + if (!eventContentURL.length) + { + // The attachment has been redacted. + attachment = nil; + _contentSize = CGSizeZero; + } + else if (![attachment.eventId isEqualToString:event.eventId] || ![attachment.contentURL isEqualToString:eventContentURL]) + { + MXKAttachment *updatedAttachment = [[MXKAttachment alloc] initWithEvent:event andMediaManager:roomDataSource.mxSession.mediaManager]; + + // Sanity check on attachment type + if (updatedAttachment && attachment.type == updatedAttachment.type) + { + // Re-use the current image as preview to prevent the cell from flashing + updatedAttachment.previewImage = [attachment getCachedThumbnail]; + if (!updatedAttachment.previewImage && attachment.type == MXKAttachmentTypeImage) + { + updatedAttachment.previewImage = [MXMediaManager loadPictureFromFilePath:attachment.cacheFilePath]; + } + + // Clean the cache by removing the useless data + if (![updatedAttachment.cacheFilePath isEqualToString:attachment.cacheFilePath]) + { + [[NSFileManager defaultManager] removeItemAtPath:attachment.cacheFilePath error:nil]; + } + if (![updatedAttachment.thumbnailCachePath isEqualToString:attachment.thumbnailCachePath]) + { + [[NSFileManager defaultManager] removeItemAtPath:attachment.thumbnailCachePath error:nil]; + } + + // Update the current attachment description + attachment = updatedAttachment; + + if (attachment.type == MXKAttachmentTypeImage) + { + // Reset content size + _contentSize = CGSizeZero; + + // Check the current thumbnail orientation. Rotate the current content size (if need) + if (attachment.thumbnailOrientation == UIImageOrientationLeft || attachment.thumbnailOrientation == UIImageOrientationRight) + { + _contentSize = CGSizeMake(_contentSize.height, _contentSize.width); + } + } + } + else + { + MXLogDebug(@"[MXKRoomBubbleCellData] updateEvent: Warning: Does not support change of attachment type"); + } + } + } + else if ([roomDataSource.eventFormatter isSupportedAttachment:event]) + { + // The event is updated to an event with attachement + attachment = [[MXKAttachment alloc] initWithEvent:event andMediaManager:roomDataSource.mxSession.mediaManager]; + if (attachment && attachment.type == MXKAttachmentTypeImage) + { + // Check the current thumbnail orientation. Rotate the current content size (if need) + if (attachment.thumbnailOrientation == UIImageOrientationLeft || attachment.thumbnailOrientation == UIImageOrientationRight) + { + _contentSize = CGSizeMake(_contentSize.height, _contentSize.width); + } + } + } + + break; + } + } + + count = bubbleComponents.count; + } + + return count; +} + +- (NSUInteger)removeEvent:(NSString *)eventId +{ + NSUInteger count = 0; + + @synchronized(bubbleComponents) + { + for (MXKRoomBubbleComponent *roomBubbleComponent in bubbleComponents) + { + if ([roomBubbleComponent.event.eventId isEqualToString:eventId]) + { + [bubbleComponents removeObject:roomBubbleComponent]; + + // Indicate that the text message layout should be recomputed. + [self invalidateTextLayout]; + + break; + } + } + + count = bubbleComponents.count; + } + + return count; +} + +- (NSUInteger)removeEventsFromEvent:(NSString*)eventId removedEvents:(NSArray**)removedEvents; +{ + NSMutableArray *cuttedEvents = [NSMutableArray array]; + + @synchronized(bubbleComponents) + { + NSInteger componentIndex = [self bubbleComponentIndexForEventId:eventId]; + + if (NSNotFound != componentIndex) + { + NSArray *newBubbleComponents = [bubbleComponents subarrayWithRange:NSMakeRange(0, componentIndex)]; + + for (NSUInteger i = componentIndex; i < bubbleComponents.count; i++) + { + MXKRoomBubbleComponent *roomBubbleComponent = bubbleComponents[i]; + [cuttedEvents addObject:roomBubbleComponent.event]; + } + + bubbleComponents = [NSMutableArray arrayWithArray:newBubbleComponents]; + + // Indicate that the text message layout should be recomputed. + [self invalidateTextLayout]; + } + } + + *removedEvents = cuttedEvents; + return bubbleComponents.count; +} + +- (BOOL)hasSameSenderAsBubbleCellData:(id)bubbleCellData +{ + // Sanity check: accept only object of MXKRoomBubbleCellData classes or sub-classes + NSParameterAssert([bubbleCellData isKindOfClass:[MXKRoomBubbleCellData class]]); + + // NOTE: Same sender means here same id, same display name and same avatar + + // Check first user id + if ([senderId isEqualToString:bubbleCellData.senderId] == NO) + { + return NO; + } + // Check sender name + if ((senderDisplayName.length || bubbleCellData.senderDisplayName.length) && ([senderDisplayName isEqualToString:bubbleCellData.senderDisplayName] == NO)) + { + return NO; + } + // Check avatar url + if ((senderAvatarUrl.length || bubbleCellData.senderAvatarUrl.length) && ([senderAvatarUrl isEqualToString:bubbleCellData.senderAvatarUrl] == NO)) + { + return NO; + } + + return YES; +} + +- (MXKRoomBubbleComponent*) getFirstBubbleComponent +{ + MXKRoomBubbleComponent* first = nil; + + @synchronized(bubbleComponents) + { + if (bubbleComponents.count) + { + first = [bubbleComponents firstObject]; + } + } + + return first; +} + +- (MXKRoomBubbleComponent*) getFirstBubbleComponentWithDisplay +{ + // Look for the first component which is actually displayed (some event are ignored in room history display). + MXKRoomBubbleComponent* first = nil; + + @synchronized(bubbleComponents) + { + for (NSInteger index = 0; index < bubbleComponents.count; index++) + { + MXKRoomBubbleComponent *component = bubbleComponents[index]; + if (component.attributedTextMessage) + { + first = component; + break; + } + } + } + + return first; +} + +- (NSAttributedString*)attributedTextMessageWithHighlightedEvent:(NSString*)eventId tintColor:(UIColor*)tintColor +{ + NSAttributedString *customAttributedTextMsg; + + // By default only one component is supported, consider here the first component + MXKRoomBubbleComponent *firstComponent = [self getFirstBubbleComponent]; + + if (firstComponent) + { + customAttributedTextMsg = firstComponent.attributedTextMessage; + + // Sanity check + if (customAttributedTextMsg && [firstComponent.event.eventId isEqualToString:eventId]) + { + NSMutableAttributedString *customComponentString = [[NSMutableAttributedString alloc] initWithAttributedString:customAttributedTextMsg]; + UIColor *color = tintColor ? tintColor : [UIColor lightGrayColor]; + [customComponentString addAttribute:NSBackgroundColorAttributeName value:color range:NSMakeRange(0, customComponentString.length)]; + customAttributedTextMsg = customComponentString; + } + } + + return customAttributedTextMsg; +} + +- (void)highlightPatternInTextMessage:(NSString*)pattern withForegroundColor:(UIColor*)patternColor andFont:(UIFont*)patternFont +{ + highlightedPattern = pattern; + highlightedPatternColor = patternColor; + highlightedPatternFont = patternFont; + + // Indicate that the text message layout should be recomputed. + [self invalidateTextLayout]; +} + +- (void)setShouldHideSenderInformation:(BOOL)inShouldHideSenderInformation +{ + shouldHideSenderInformation = inShouldHideSenderInformation; + + if (!shouldHideSenderInformation) + { + // Refresh the flair + [self refreshSenderFlair]; + } +} + +- (void)refreshSenderFlair +{ + // Reset by default any observer on publicised groups by user. + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidUpdatePublicisedGroupsForUsersNotification object:self.mxSession]; + + // Check first whether the room enabled the flair for some groups + NSArray *roomRelatedGroups = roomDataSource.roomState.relatedGroups; + if (roomRelatedGroups.count && senderId) + { + NSArray *senderPublicisedGroups; + + senderPublicisedGroups = [self.mxSession publicisedGroupsForUser:senderId]; + + if (senderPublicisedGroups.count) + { + // Cross the 2 arrays to keep only the common group ids + NSMutableArray *flair = [NSMutableArray arrayWithCapacity:roomRelatedGroups.count]; + + for (NSString *groupId in roomRelatedGroups) + { + if ([senderPublicisedGroups indexOfObject:groupId] != NSNotFound) + { + MXGroup *group = [roomDataSource groupWithGroupId:groupId]; + [flair addObject:group]; + } + } + + if (flair.count) + { + self.senderFlair = flair; + } + else + { + self.senderFlair = nil; + } + } + else + { + self.senderFlair = nil; + } + + // Observe any change on publicised groups for the message sender + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXSessionUpdatePublicisedGroupsForUsers:) name:kMXSessionDidUpdatePublicisedGroupsForUsersNotification object:self.mxSession]; + } +} + +#pragma mark - + +- (void)invalidateTextLayout +{ + self.attributedTextMessage = nil; +} + +- (void)prepareBubbleComponentsPosition +{ + // Consider here only the first component if any + MXKRoomBubbleComponent *firstComponent = [self getFirstBubbleComponent]; + + if (firstComponent) + { + CGFloat positionY = (attachment == nil || attachment.type == MXKAttachmentTypeFile || attachment.type == MXKAttachmentTypeAudio || attachment.type == MXKAttachmentTypeVoiceMessage) ? MXKROOMBUBBLECELLDATA_TEXTVIEW_DEFAULT_VERTICAL_INSET : 0; + firstComponent.position = CGPointMake(0, positionY); + } +} + +- (NSInteger)bubbleComponentIndexForEventId:(NSString *)eventId +{ + return [self.bubbleComponents indexOfObjectPassingTest:^BOOL(MXKRoomBubbleComponent * _Nonnull bubbleComponent, NSUInteger idx, BOOL * _Nonnull stop) { + if ([bubbleComponent.event.eventId isEqualToString:eventId]) + { + *stop = YES; + return YES; + } + return NO; + }]; +} + +#pragma mark - Text measuring + +// Return the raw height of the provided text by removing any margin +- (CGFloat)rawTextHeight: (NSAttributedString*)attributedText +{ + __block CGSize textSize; + if ([NSThread currentThread] != [NSThread mainThread]) + { + dispatch_sync(dispatch_get_main_queue(), ^{ + textSize = [self textContentSize:attributedText removeVerticalInset:YES]; + }); + } + else + { + textSize = [self textContentSize:attributedText removeVerticalInset:YES]; + } + + return textSize.height; +} + +- (CGSize)textContentSize:(NSAttributedString*)attributedText removeVerticalInset:(BOOL)removeVerticalInset +{ + static UITextView* measurementTextView = nil; + static UITextView* measurementTextViewWithoutInset = nil; + + if (attributedText.length) + { + if (!measurementTextView) + { + measurementTextView = [[UITextView alloc] init]; + + measurementTextViewWithoutInset = [[UITextView alloc] init]; + // Remove the container inset: this operation impacts only the vertical margin. + // Note: consider textContainer.lineFragmentPadding to remove horizontal margin + measurementTextViewWithoutInset.textContainerInset = UIEdgeInsetsZero; + } + + // Select the right text view for measurement + UITextView *selectedTextView = (removeVerticalInset ? measurementTextViewWithoutInset : measurementTextView); + + selectedTextView.frame = CGRectMake(0, 0, _maxTextViewWidth, MAXFLOAT); + selectedTextView.attributedText = attributedText; + + CGSize size = [selectedTextView sizeThatFits:selectedTextView.frame.size]; + + // Manage the case where a string attribute has a single paragraph with a left indent + // In this case, [UITextView sizeThatFits] ignores the indent and return the width + // of the text only. + // So, add this indent afterwards + NSRange textRange = NSMakeRange(0, attributedText.length); + NSRange longestEffectiveRange; + NSParagraphStyle *paragraphStyle = [attributedText attribute:NSParagraphStyleAttributeName atIndex:0 longestEffectiveRange:&longestEffectiveRange inRange:textRange]; + + if (NSEqualRanges(textRange, longestEffectiveRange)) + { + size.width = size.width + paragraphStyle.headIndent; + } + + return size; + } + + return CGSizeZero; +} + +#pragma mark - Properties + +- (MXSession*)mxSession +{ + return roomDataSource.mxSession; +} + +- (NSArray*)bubbleComponents +{ + NSArray* copy; + + @synchronized(bubbleComponents) + { + copy = [bubbleComponents copy]; + } + + return copy; +} + +- (NSString*)textMessage +{ + return self.attributedTextMessage.string; +} + +- (void)setAttributedTextMessage:(NSAttributedString *)inAttributedTextMessage +{ + attributedTextMessage = inAttributedTextMessage; + + if (attributedTextMessage.length && highlightedPattern) + { + [self highlightPattern]; + } + + // Reset content size + _contentSize = CGSizeZero; +} + +- (NSAttributedString*)attributedTextMessage +{ + if (self.hasAttributedTextMessage && !attributedTextMessage.length) + { + // By default only one component is supported, consider here the first component + MXKRoomBubbleComponent *firstComponent = [self getFirstBubbleComponent]; + + if (firstComponent) + { + attributedTextMessage = firstComponent.attributedTextMessage; + + if (attributedTextMessage.length && highlightedPattern) + { + [self highlightPattern]; + } + } + } + + return attributedTextMessage; +} + +- (BOOL)hasAttributedTextMessage +{ + // Determine if the event formatter will return at least one string for the events in this cell. + // No string means that the event formatter has been configured so that it did not accept all events + // of the cell. + BOOL hasAttributedTextMessage = NO; + + @synchronized(bubbleComponents) + { + for (MXKRoomBubbleComponent *roomBubbleComponent in bubbleComponents) + { + if (roomBubbleComponent.attributedTextMessage) + { + hasAttributedTextMessage = YES; + break; + } + } + } + return hasAttributedTextMessage; +} + +- (BOOL)hasLink +{ + @synchronized (bubbleComponents) { + for (MXKRoomBubbleComponent *component in bubbleComponents) + { + if (component.link) + { + return YES; + } + } + } + + return NO; +} + +- (MXKRoomBubbleComponentDisplayFix)displayFix +{ + MXKRoomBubbleComponentDisplayFix displayFix = MXKRoomBubbleComponentDisplayFixNone; + + @synchronized(bubbleComponents) + { + for (MXKRoomBubbleComponent *component in self.bubbleComponents) + { + displayFix |= component.displayFix; + } + } + return displayFix; +} + +- (BOOL)shouldHideSenderName +{ + BOOL res = NO; + + MXKRoomBubbleComponent *firstDisplayedComponent = [self getFirstBubbleComponentWithDisplay]; + NSString *senderDisplayName = self.senderDisplayName; + + if (firstDisplayedComponent) + { + res = (firstDisplayedComponent.event.isEmote || (firstDisplayedComponent.event.isState && senderDisplayName && [firstDisplayedComponent.textMessage hasPrefix:senderDisplayName])); + } + + return res; +} + +- (NSArray*)events +{ + NSMutableArray* eventsArray; + + @synchronized(bubbleComponents) + { + eventsArray = [NSMutableArray arrayWithCapacity:bubbleComponents.count]; + for (MXKRoomBubbleComponent *roomBubbleComponent in bubbleComponents) + { + if (roomBubbleComponent.event) + { + [eventsArray addObject:roomBubbleComponent.event]; + } + } + } + return eventsArray; +} + +- (NSDate*)date +{ + MXKRoomBubbleComponent *firstDisplayedComponent = [self getFirstBubbleComponentWithDisplay]; + + if (firstDisplayedComponent) + { + return firstDisplayedComponent.date; + } + + return nil; +} + +- (BOOL)hasNoDisplay +{ + BOOL noDisplay = YES; + + // Check whether at least one component has a string description. + @synchronized(bubbleComponents) + { + if (self.collapsed) + { + // Collapsed cells have no display except their cell header + noDisplay = !self.collapsedAttributedTextMessage; + } + else + { + for (MXKRoomBubbleComponent *roomBubbleComponent in bubbleComponents) + { + if (roomBubbleComponent.attributedTextMessage) + { + noDisplay = NO; + break; + } + } + } + } + + return (noDisplay && !attachment); +} + +- (BOOL)isAttachmentWithThumbnail +{ + return (attachment && (attachment.type == MXKAttachmentTypeImage || attachment.type == MXKAttachmentTypeVideo || attachment.type == MXKAttachmentTypeSticker)); +} + +- (BOOL)isAttachmentWithIcon +{ + // Not supported yet (TODO for audio, file). + return NO; +} + +- (void)setMaxTextViewWidth:(CGFloat)inMaxTextViewWidth +{ + // Check change + if (inMaxTextViewWidth != _maxTextViewWidth) + { + _maxTextViewWidth = inMaxTextViewWidth; + // Reset content size + _contentSize = CGSizeZero; + } +} + +- (CGSize)contentSize +{ + if (CGSizeEqualToSize(_contentSize, CGSizeZero)) + { + if (attachment == nil) + { + // Here the bubble is a text message + if ([NSThread currentThread] != [NSThread mainThread]) + { + dispatch_sync(dispatch_get_main_queue(), ^{ + self->_contentSize = [self textContentSize:self.attributedTextMessage removeVerticalInset:NO]; + }); + } + else + { + _contentSize = [self textContentSize:self.attributedTextMessage removeVerticalInset:NO]; + } + } + else if (self.isAttachmentWithThumbnail) + { + CGFloat width, height; + + // Set default content size + width = height = MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH; + + if (attachment.thumbnailInfo || attachment.contentInfo) + { + if (attachment.thumbnailInfo && attachment.thumbnailInfo[@"w"] && attachment.thumbnailInfo[@"h"]) + { + width = [attachment.thumbnailInfo[@"w"] integerValue]; + height = [attachment.thumbnailInfo[@"h"] integerValue]; + } + else if (attachment.contentInfo[@"w"] && attachment.contentInfo[@"h"]) + { + width = [attachment.contentInfo[@"w"] integerValue]; + height = [attachment.contentInfo[@"h"] integerValue]; + } + + if (width > MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH || height > MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH) + { + if (width > height) + { + height = (height * MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH) / width; + height = floorf(height / 2) * 2; + width = MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH; + } + else + { + width = (width * MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH) / height; + width = floorf(width / 2) * 2; + height = MXKROOMBUBBLECELLDATA_MAX_ATTACHMENTVIEW_WIDTH; + } + } + } + + // Check here thumbnail orientation + if (attachment.thumbnailOrientation == UIImageOrientationLeft || attachment.thumbnailOrientation == UIImageOrientationRight) + { + _contentSize = CGSizeMake(height, width); + } + else + { + _contentSize = CGSizeMake(width, height); + } + } + else if (attachment.type == MXKAttachmentTypeFile || attachment.type == MXKAttachmentTypeAudio) + { + // Presently we displayed only the file name for attached file (no icon yet) + // Return suitable content size of a text view to display the file name (available in text message). + if ([NSThread currentThread] != [NSThread mainThread]) + { + dispatch_sync(dispatch_get_main_queue(), ^{ + self->_contentSize = [self textContentSize:self.attributedTextMessage removeVerticalInset:NO]; + }); + } + else + { + _contentSize = [self textContentSize:self.attributedTextMessage removeVerticalInset:NO]; + } + } + else + { + _contentSize = CGSizeMake(40, 40); + } + } + return _contentSize; +} + +- (MXKEventFormatter *)eventFormatter +{ + MXKRoomBubbleComponent *firstComponent = [bubbleComponents firstObject]; + + // Retrieve event formatter from the first component + if (firstComponent) + { + return firstComponent.eventFormatter; + } + + return nil; +} + +- (BOOL)showAntivirusScanStatus +{ + MXKRoomBubbleComponent *firstBubbleComponent = self.bubbleComponents.firstObject; + + if (self.attachment == nil || firstBubbleComponent == nil) + { + return NO; + } + + MXEventScan *eventScan = firstBubbleComponent.eventScan; + + return eventScan != nil && eventScan.antivirusScanStatus != MXAntivirusScanStatusTrusted; +} + +- (BOOL)containsBubbleComponentWithEncryptionBadge +{ + BOOL containsBubbleComponentWithEncryptionBadge = NO; + + @synchronized(bubbleComponents) + { + for (MXKRoomBubbleComponent *component in bubbleComponents) + { + if (component.showEncryptionBadge) + { + containsBubbleComponentWithEncryptionBadge = YES; + break; + } + } + } + + return containsBubbleComponentWithEncryptionBadge; +} + +#pragma mark - Bubble collapsing + +- (BOOL)collapseWith:(id)cellData +{ + // NO by default + return NO; +} + +#pragma mark - Internals + +- (void)highlightPattern +{ + NSMutableAttributedString *customAttributedTextMsg = nil; + + NSString *currentTextMessage = self.textMessage; + NSRange range = [currentTextMessage rangeOfString:highlightedPattern options:NSCaseInsensitiveSearch]; + + if (range.location != NSNotFound) + { + customAttributedTextMsg = [[NSMutableAttributedString alloc] initWithAttributedString:self.attributedTextMessage]; + + while (range.location != NSNotFound) + { + if (highlightedPatternColor) + { + // Update text color + [customAttributedTextMsg addAttribute:NSForegroundColorAttributeName value:highlightedPatternColor range:range]; + } + + if (highlightedPatternFont) + { + // Update text font + [customAttributedTextMsg addAttribute:NSFontAttributeName value:highlightedPatternFont range:range]; + } + + // Look for the next pattern occurrence + range.location += range.length; + if (range.location < currentTextMessage.length) + { + range.length = currentTextMessage.length - range.location; + range = [currentTextMessage rangeOfString:highlightedPattern options:NSCaseInsensitiveSearch range:range]; + } + else + { + range.location = NSNotFound; + } + } + } + + if (customAttributedTextMsg) + { + // Update resulting message body + attributedTextMessage = customAttributedTextMsg; + } +} + +- (void)didMXSessionUpdatePublicisedGroupsForUsers:(NSNotification *)notif +{ + // Retrieved the list of the concerned users + NSArray *userIds = notif.userInfo[kMXSessionNotificationUserIdsArrayKey]; + if (userIds.count && self.senderId) + { + // Check whether the current sender is concerned. + if ([userIds indexOfObject:self.senderId] != NSNotFound) + { + [self refreshSenderFlair]; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataStoring.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataStoring.h new file mode 100644 index 000000000..06b1584c4 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataStoring.h @@ -0,0 +1,348 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKRoomDataSource.h" + +#import "MXKAttachment.h" + +#import "MXEvent+MatrixKit.h" + +@class MXKRoomDataSource; +/** + `MXKRoomBubbleCellDataStoring` defines a protocol a class must conform in order to store MXKRoomBubble cell data + managed by `MXKRoomDataSource`. + */ +@protocol MXKRoomBubbleCellDataStoring + +#pragma mark - Data displayed by a room bubble cell + +/** + The sender Id + */ +@property (nonatomic) NSString *senderId; + +/** + The target Id (may be nil) + + @discussion "target" refers to the room member who is the target of this event (if any), e.g. + the invitee, the person being banned, etc. + */ +@property (nonatomic) NSString *targetId; + +/** + The room id + */ +@property (nonatomic) NSString *roomId; + +/** + The sender display name composed when event occured + */ +@property (nonatomic) NSString *senderDisplayName; + +/** + The sender avatar url retrieved when event occured + */ +@property (nonatomic) NSString *senderAvatarUrl; + +/** + The sender avatar placeholder (may be nil) - Used when url is nil, or during avatar download. + */ +@property (nonatomic) UIImage *senderAvatarPlaceholder; + +/** + The target display name composed when event occured (may be nil) + + @discussion "target" refers to the room member who is the target of this event (if any), e.g. + the invitee, the person being banned, etc. + */ +@property (nonatomic) NSString *targetDisplayName; + +/** + The target avatar url retrieved when event occured (may be nil) + + @discussion "target" refers to the room member who is the target of this event (if any), e.g. + the invitee, the person being banned, etc. + */ +@property (nonatomic) NSString *targetAvatarUrl; + +/** + The target avatar placeholder (may be nil) - Used when url is nil, or during avatar download. + + @discussion "target" refers to the room member who is the target of this event (if any), e.g. + the invitee, the person being banned, etc. + */ +@property (nonatomic) UIImage *targetAvatarPlaceholder; + +/** + The current sender flair (list of the publicised groups in the sender profile which matches the room flair settings) + */ +@property (nonatomic) NSArray *senderFlair; + +/** + Tell whether the room is encrypted. + */ +@property (nonatomic) BOOL isEncryptedRoom; + +/** + Tell whether a new pagination starts with this bubble. + */ +@property (nonatomic) BOOL isPaginationFirstBubble; + +/** + Tell whether the sender information is relevant for this bubble + (For example this information should be hidden in case of 2 consecutive bubbles from the same sender). + */ +@property (nonatomic) BOOL shouldHideSenderInformation; + +/** + Tell whether this bubble has nothing to display (neither a message nor an attachment). + */ +@property (nonatomic, readonly) BOOL hasNoDisplay; + +/** + The list of events (`MXEvent` instances) handled by this bubble. + */ +@property (nonatomic, readonly) NSArray *events; + +/** + The bubble attachment (if any). + */ +@property (nonatomic) MXKAttachment *attachment; + +/** + The bubble date + */ +@property (nonatomic) NSDate *date; + +/** + YES when the bubble is composed by incoming event(s). + */ +@property (nonatomic) BOOL isIncoming; + +/** + YES when the bubble correspond to an attachment displayed with a thumbnail (see image, video). + */ +@property (nonatomic) BOOL isAttachmentWithThumbnail; + +/** + YES when the bubble correspond to an attachment displayed with an icon (audio, file...). + */ +@property (nonatomic) BOOL isAttachmentWithIcon; + +/** + Flag that indicates that self.attributedTextMessage will be not nil. + This avoids the computation of self.attributedTextMessage that can take time. + */ +@property (nonatomic, readonly) BOOL hasAttributedTextMessage; + +/** + The body of the message with sets of attributes, or kind of content description in case of attachment (e.g. "image attachment") + */ +@property (nonatomic) NSAttributedString *attributedTextMessage; + +/** + The raw text message (without attributes) + */ +@property (nonatomic) NSString *textMessage; + +/** + Tell whether the sender's name is relevant or not for this bubble. + Return YES if the first component of the bubble message corresponds to an emote, or a state event in which + the sender's name appears at the beginning of the message text (for example membership events). + */ +@property (nonatomic) BOOL shouldHideSenderName; + +/** + YES if the sender is currently typing in the current room + */ +@property (nonatomic) BOOL isTyping; + +/** + Show the date time label in rendered bubble cell. NO by default. + */ +@property (nonatomic) BOOL showBubbleDateTime; + +/** + A Boolean value that determines whether the date time labels are customized (By default date time display is handled by MatrixKit). NO by default. + */ +@property (nonatomic) BOOL useCustomDateTimeLabel; + +/** + Show the receipts in rendered bubble cell. YES by default. + */ +@property (nonatomic) BOOL showBubbleReceipts; + +/** + A Boolean value that determines whether the read receipts are customized (By default read receipts display is handled by MatrixKit). NO by default. + */ +@property (nonatomic) BOOL useCustomReceipts; + +/** + A Boolean value that determines whether the unsent button is customized (By default an 'Unsent' button is displayed by MatrixKit in front of unsent events). NO by default. + */ +@property (nonatomic) BOOL useCustomUnsentButton; + +/** + An integer that you can use to identify cell data in your application. + The default value is 0. You can set the value of this tag and use that value to identify the cell data later. + */ +@property (nonatomic) NSInteger tag; + +/** + Indicate if antivirus scan status should be shown. + */ +@property (nonatomic, readonly) BOOL showAntivirusScanStatus; + +#pragma mark - Public methods +/** + Create a new `MXKRoomBubbleCellDataStoring` object for a new bubble cell. + + @param event the event to be displayed in the cell. + @param roomState the room state when the event occured. + @param roomDataSource the `MXKRoomDataSource` object that will use this instance. + @return the newly created instance. + */ +- (instancetype)initWithEvent:(MXEvent*)event andRoomState:(MXRoomState*)roomState andRoomDataSource:(MXKRoomDataSource*)roomDataSource; + +/** +Update the event because its sent state changed or it is has been redacted. + + @param eventId the id of the event to change. + @param event the new event data + @return the number of events hosting by the object after the update. + */ +- (NSUInteger)updateEvent:(NSString*)eventId withEvent:(MXEvent*)event; + +/** + Remove the event from the `MXKRoomBubbleCellDataStoring` object. + + @param eventId the id of the event to remove. + @return the number of events still hosting by the object after the removal + */ +- (NSUInteger)removeEvent:(NSString*)eventId; + +/** + Remove the passed event and all events after it. + + @param eventId the id of the event where to start removing. + @param removedEvents removedEvents will contain the list of removed events. + @return the number of events still hosting by the object after the removal. + */ +- (NSUInteger)removeEventsFromEvent:(NSString*)eventId removedEvents:(NSArray**)removedEvents; + +/** + Check if the receiver has the same sender as another bubble. + + @param bubbleCellData an object conforms to `MXKRoomBubbleCellDataStoring` protocol. + @return YES if the receiver has the same sender as the provided bubble + */ +- (BOOL)hasSameSenderAsBubbleCellData:(id)bubbleCellData; + +/** + Highlight text message of an event in the resulting message body. + + @param eventId the id of the event to highlight. + @param tintColor optional tint color + @return The body of the message by highlighting the content related to the provided event id + */ +- (NSAttributedString*)attributedTextMessageWithHighlightedEvent:(NSString*)eventId tintColor:(UIColor*)tintColor; + +/** + Highlight all the occurrences of a pattern in the resulting message body 'attributedTextMessage'. + + @param pattern the text pattern to highlight. + @param patternColor optional text color (the pattern text color is unchanged if nil). + @param patternFont optional text font (the pattern font is unchanged if nil). + */ +- (void)highlightPatternInTextMessage:(NSString*)pattern withForegroundColor:(UIColor*)patternColor andFont:(UIFont*)patternFont; + +/** + Refresh the sender flair information + */ +- (void)refreshSenderFlair; + +/** + Indicate that the current text message layout is no longer valid and should be recomputed + before presentation in a bubble cell. This could be due to the content changing, or the + available space for the cell has been updated. + */ +- (void)invalidateTextLayout; + +#pragma mark - Bubble collapsing + +/** + A Boolean value that indicates if the cell is collapsable. + */ +@property (nonatomic) BOOL collapsable; + +/** + A Boolean value that indicates if the cell and its series is collapsed. + */ +@property (nonatomic) BOOL collapsed; + +/** + The attributed string to display when the collapsable cells series is collapsed. + It is not nil only for the start cell of the cells series. + */ +@property (nonatomic) NSAttributedString *collapsedAttributedTextMessage; + +/** + Bidirectional linked list of cells that can be collapsed together. + If prevCollapsableCellData is nil, this cell data instance is the data of the start + cell of the collapsable cells series. + */ +@property (nonatomic) id prevCollapsableCellData; +@property (nonatomic) id nextCollapsableCellData; + +/** + The room state to use for computing or updating the data to display for the series when it is + collapsed. + It is not nil only for the start cell of the cells series. + */ +@property (nonatomic) MXRoomState *collapseState; + +/** + Check whether the two cells can be collapsable together. + + @return YES if YES. + */ +- (BOOL)collapseWith:(id)cellData; + +@optional +/** + Attempt to add a new event to the bubble. + + @param event the event to be displayed in the cell. + @param roomState the room state when the event occured. + @return YES if the model accepts that the event can concatenated to events already in the bubble. + */ +- (BOOL)addEvent:(MXEvent*)event andRoomState:(MXRoomState*)roomState; + +/** + The receiver appends to its content the provided bubble cell data, if both have the same sender. + + @param bubbleCellData an object conforms to `MXKRoomBubbleCellDataStoring` protocol. + @return YES if the provided cell data has been merged into receiver. + */ +- (BOOL)mergeWithBubbleCellData:(id)bubbleCellData; + + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithAppendingMode.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithAppendingMode.h new file mode 100644 index 000000000..8fc2cc2de --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithAppendingMode.h @@ -0,0 +1,45 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleCellData.h" + +/** + `MXKRoomBubbleCellDataWithAppendingMode` class inherits from `MXKRoomBubbleCellData`, it merges + consecutive events from the same sender into one bubble. + Each concatenated event is represented by a bubble component. + */ +@interface MXKRoomBubbleCellDataWithAppendingMode : MXKRoomBubbleCellData +{ +@protected + /** + YES if position of each component must be refreshed + */ + BOOL shouldUpdateComponentsPosition; +} + +/** + The string appended to the current message before adding a new component text. + */ ++ (NSAttributedString *)messageSeparator; + +/** + The maximum number of components in each bubble. Default is 10. + We limit the number of components to reduce the computation time required during bubble handling. + Indeed some process like [prepareBubbleComponentsPosition] is time consuming. + */ +@property (nonatomic) NSUInteger maxComponentCount; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithAppendingMode.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithAppendingMode.m new file mode 100644 index 000000000..680086ddd --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithAppendingMode.m @@ -0,0 +1,356 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleCellDataWithAppendingMode.h" + +static NSAttributedString *messageSeparator = nil; + +@implementation MXKRoomBubbleCellDataWithAppendingMode + +#pragma mark - MXKRoomBubbleCellDataStoring + +- (instancetype)initWithEvent:(MXEvent *)event andRoomState:(MXRoomState *)roomState andRoomDataSource:(MXKRoomDataSource *)roomDataSource2 +{ + self = [super initWithEvent:event andRoomState:roomState andRoomDataSource:roomDataSource2]; + if (self) + { + // Set default settings + self.maxComponentCount = 10; + } + + return self; +} + +- (BOOL)addEvent:(MXEvent*)event andRoomState:(MXRoomState*)roomState +{ + // We group together text messages from the same user (attachments are not merged). + if ([event.sender isEqualToString:self.senderId] && (self.attachment == nil) && (self.bubbleComponents.count < self.maxComponentCount)) + { + // Attachments (image, video, sticker ...) cannot be added here + if ([roomDataSource.eventFormatter isSupportedAttachment:event]) + { + return NO; + } + + // Check sender information + NSString *eventSenderName = [roomDataSource.eventFormatter senderDisplayNameForEvent:event withRoomState:roomState]; + NSString *eventSenderAvatar = [roomDataSource.eventFormatter senderAvatarUrlForEvent:event withRoomState:roomState]; + if ((self.senderDisplayName || eventSenderName) && + ([self.senderDisplayName isEqualToString:eventSenderName] == NO)) + { + return NO; + } + if ((self.senderAvatarUrl || eventSenderAvatar) && + ([self.senderAvatarUrl isEqualToString:eventSenderAvatar] == NO)) + { + return NO; + } + + // Take into account here the rendered bubbles pagination + if (roomDataSource.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay) + { + // Event must be sent the same day than the existing bubble. + NSString *bubbleDateString = [roomDataSource.eventFormatter dateStringFromDate:self.date withTime:NO]; + NSString *eventDateString = [roomDataSource.eventFormatter dateStringFromEvent:event withTime:NO]; + if (bubbleDateString && eventDateString && ![bubbleDateString isEqualToString:eventDateString]) + { + return NO; + } + } + + // Create new message component + MXKRoomBubbleComponent *addedComponent = [[MXKRoomBubbleComponent alloc] initWithEvent:event roomState:roomState eventFormatter:roomDataSource.eventFormatter session:self.mxSession]; + if (addedComponent) + { + [self addComponent:addedComponent]; + } + // else the event is ignored, we consider it as handled + return YES; + } + return NO; +} + +- (BOOL)mergeWithBubbleCellData:(id)bubbleCellData +{ + if ([self hasSameSenderAsBubbleCellData:bubbleCellData]) + { + MXKRoomBubbleCellData *cellData = (MXKRoomBubbleCellData*)bubbleCellData; + // Only text messages are merged (Attachments are not merged). + if ((self.attachment == nil) && (cellData.attachment == nil)) + { + // Take into account here the rendered bubbles pagination + if (roomDataSource.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay) + { + // bubble components must be sent the same day than self. + NSString *selfDateString = [roomDataSource.eventFormatter dateStringFromDate:self.date withTime:NO]; + NSString *bubbleDateString = [roomDataSource.eventFormatter dateStringFromDate:bubbleCellData.date withTime:NO]; + if (![bubbleDateString isEqualToString:selfDateString]) + { + return NO; + } + } + + // Add all components of the provided message + for (MXKRoomBubbleComponent* component in cellData.bubbleComponents) + { + [self addComponent:component]; + } + return YES; + } + } + return NO; +} + +- (NSAttributedString*)attributedTextMessageWithHighlightedEvent:(NSString*)eventId tintColor:(UIColor*)tintColor +{ + // Create attributed string + NSMutableAttributedString *customAttributedTextMsg; + NSAttributedString *componentString; + + @synchronized(bubbleComponents) + { + for (MXKRoomBubbleComponent* component in bubbleComponents) + { + componentString = component.attributedTextMessage; + + if (componentString) + { + if ([component.event.eventId isEqualToString:eventId]) + { + NSMutableAttributedString *customComponentString = [[NSMutableAttributedString alloc] initWithAttributedString:componentString]; + UIColor *color = tintColor ? tintColor : [UIColor lightGrayColor]; + [customComponentString addAttribute:NSBackgroundColorAttributeName value:color range:NSMakeRange(0, customComponentString.length)]; + componentString = customComponentString; + } + + if (!customAttributedTextMsg) + { + customAttributedTextMsg = [[NSMutableAttributedString alloc] initWithAttributedString:componentString]; + } + else + { + // Append attributed text + [customAttributedTextMsg appendAttributedString:[MXKRoomBubbleCellDataWithAppendingMode messageSeparator]]; + [customAttributedTextMsg appendAttributedString:componentString]; + } + } + } + } + + return customAttributedTextMsg; +} + +#pragma mark - + +- (void)prepareBubbleComponentsPosition +{ + // Set position of the first component + [super prepareBubbleComponentsPosition]; + + @synchronized(bubbleComponents) + { + // Check whether the position of other components need to be refreshed + if (!self.attachment && shouldUpdateComponentsPosition && bubbleComponents.count > 1) + { + // Init attributed string with the first text component not nil. + MXKRoomBubbleComponent *component = bubbleComponents.firstObject; + CGFloat positionY = component.position.y; + NSMutableAttributedString *attributedString; + NSUInteger index = 0; + + for (; index < bubbleComponents.count; index++) + { + component = [bubbleComponents objectAtIndex:index]; + + component.position = CGPointMake(0, positionY); + + if (component.attributedTextMessage) + { + attributedString = [[NSMutableAttributedString alloc] initWithAttributedString:component.attributedTextMessage]; + [attributedString appendAttributedString:[MXKRoomBubbleCellDataWithAppendingMode messageSeparator]]; + break; + } + } + + for (index++; index < bubbleComponents.count; index++) + { + // Append the next text component + component = [bubbleComponents objectAtIndex:index]; + + if (component.attributedTextMessage) + { + [attributedString appendAttributedString:component.attributedTextMessage]; + + // Compute the height of the resulting string + CGFloat cumulatedHeight = [self rawTextHeight:attributedString]; + + // Deduce the position of the beginning of this component + CGFloat positionY = MXKROOMBUBBLECELLDATA_TEXTVIEW_DEFAULT_VERTICAL_INSET + (cumulatedHeight - [self rawTextHeight:component.attributedTextMessage]); + + component.position = CGPointMake(0, positionY); + + [attributedString appendAttributedString:[MXKRoomBubbleCellDataWithAppendingMode messageSeparator]]; + } + else + { + // Apply the current vertical position on this empty component. + component.position = CGPointMake(0, positionY); + } + } + } + } + + shouldUpdateComponentsPosition = NO; +} + +#pragma mark - + +- (NSString*)textMessage +{ + NSString *rawText = nil; + + if (self.attributedTextMessage) + { + // Append all components text message + NSMutableString *currentTextMsg; + @synchronized(bubbleComponents) + { + for (MXKRoomBubbleComponent* component in bubbleComponents) + { + if (component.textMessage == nil) + { + continue; + } + if (!currentTextMsg) + { + currentTextMsg = [NSMutableString stringWithString:component.textMessage]; + } + else + { + // Append text message + [currentTextMsg appendString:@"\n"]; + [currentTextMsg appendString:component.textMessage]; + } + } + } + rawText = currentTextMsg; + } + + return rawText; +} + +- (void)setAttributedTextMessage:(NSAttributedString *)inAttributedTextMessage +{ + super.attributedTextMessage = inAttributedTextMessage; + + // Position of each components should be computed again + shouldUpdateComponentsPosition = YES; +} + +- (NSAttributedString*)attributedTextMessage +{ + @synchronized(bubbleComponents) + { + if (self.hasAttributedTextMessage && !attributedTextMessage.length) + { + // Create attributed string + NSMutableAttributedString *currentAttributedTextMsg; + + for (MXKRoomBubbleComponent* component in bubbleComponents) + { + if (component.attributedTextMessage) + { + if (!currentAttributedTextMsg) + { + currentAttributedTextMsg = [[NSMutableAttributedString alloc] initWithAttributedString:component.attributedTextMessage]; + } + else + { + // Append attributed text + [currentAttributedTextMsg appendAttributedString:[MXKRoomBubbleCellDataWithAppendingMode messageSeparator]]; + [currentAttributedTextMsg appendAttributedString:component.attributedTextMessage]; + } + } + } + self.attributedTextMessage = currentAttributedTextMsg; + } + } + + return attributedTextMessage; +} + +- (void)setMaxTextViewWidth:(CGFloat)inMaxTextViewWidth +{ + CGFloat previousMaxWidth = self.maxTextViewWidth; + + [super setMaxTextViewWidth:inMaxTextViewWidth]; + + // Check change + if (previousMaxWidth != self.maxTextViewWidth) + { + // Position of each components should be computed again + shouldUpdateComponentsPosition = YES; + } +} + +#pragma mark - + ++ (NSAttributedString *)messageSeparator +{ + @synchronized(self) + { + if(messageSeparator == nil) + { + messageSeparator = [[NSAttributedString alloc] initWithString:@"\n\n" attributes:@{NSForegroundColorAttributeName : [UIColor blackColor], + NSFontAttributeName: [UIFont systemFontOfSize:4]}]; + } + } + return messageSeparator; +} + +#pragma mark - Privates + +- (void)addComponent:(MXKRoomBubbleComponent*)addedComponent +{ + @synchronized(bubbleComponents) + { + // Check date of existing components to insert this new one + NSUInteger index = bubbleComponents.count; + + // Component without date is added at the end by default + if (addedComponent.date) + { + while (index) + { + MXKRoomBubbleComponent *msgComponent = [bubbleComponents objectAtIndex:(--index)]; + if (msgComponent.date && [msgComponent.date compare:addedComponent.date] != NSOrderedDescending) + { + // New component will be inserted here + index ++; + break; + } + } + } + + // Insert new component + [bubbleComponents insertObject:addedComponent atIndex:index]; + + // Indicate that the data's text message layout should be recomputed. + [self invalidateTextLayout]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithIncomingAppendingMode.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithIncomingAppendingMode.h new file mode 100644 index 000000000..49b050595 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithIncomingAppendingMode.h @@ -0,0 +1,27 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleCellDataWithAppendingMode.h" + +/** + `MXKRoomBubbleCellDataWithIncomingAppendingMode` class inherits from `MXKRoomBubbleCellDataWithAppendingMode`, + only the incoming message cells are merged. + */ +@interface MXKRoomBubbleCellDataWithIncomingAppendingMode : MXKRoomBubbleCellDataWithAppendingMode +{ +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithIncomingAppendingMode.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithIncomingAppendingMode.m new file mode 100644 index 000000000..ebe30784f --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellDataWithIncomingAppendingMode.m @@ -0,0 +1,45 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleCellDataWithIncomingAppendingMode.h" + +@implementation MXKRoomBubbleCellDataWithIncomingAppendingMode + +#pragma mark - MXKRoomBubbleCellDataStoring + +- (BOOL)addEvent:(MXEvent*)event andRoomState:(MXRoomState*)roomState +{ + // Do not merge outgoing events + if ([event.sender isEqualToString:roomDataSource.mxSession.myUser.userId]) + { + return NO; + } + + return [super addEvent:event andRoomState:roomState]; +} + +- (BOOL)mergeWithBubbleCellData:(id)bubbleCellData +{ + // Do not merge outgoing events + if ([bubbleCellData.senderId isEqualToString:roomDataSource.mxSession.myUser.userId]) + { + return NO; + } + + return [super mergeWithBubbleCellData:bubbleCellData]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.h new file mode 100644 index 000000000..3912932c8 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.h @@ -0,0 +1,127 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKEventFormatter.h" +#import "MXKURLPreviewDataProtocol.h" + +/** + Flags to indicate if a fix is required at the display time. + */ +typedef enum : NSUInteger { + + /** + No fix required. + */ + MXKRoomBubbleComponentDisplayFixNone = 0, + + /** + Borders for HTML blockquotes need to be fixed. + */ + MXKRoomBubbleComponentDisplayFixHtmlBlockquote = 0x1 + +} MXKRoomBubbleComponentDisplayFix; + +/** + `MXKRoomBubbleComponent` class compose data related to one `MXEvent` instance. + */ +@interface MXKRoomBubbleComponent : NSObject + +/** + The body of the message, or kind of content description in case of attachment (e.g. "image attachment"). + */ +@property (nonatomic) NSString *textMessage; + +/** + The `textMessage` with sets of attributes. + */ +@property (nonatomic) NSAttributedString *attributedTextMessage; + +/** + The event date + */ +@property (nonatomic) NSDate *date; + +/** + Event formatter + */ +@property (nonatomic) MXKEventFormatter *eventFormatter; + +/** + The event on which the component is based (used in case of redaction) + */ +@property (nonatomic, readonly) MXEvent *event; + +// The following properties are defined to store information on component. +// They must be handled by the object which creates the MXKRoomBubbleComponent instance. +//@property (nonatomic) CGFloat height; +@property (nonatomic) CGPoint position; + +/** + Set of flags indicating fixes that need to be applied at display time. + */ +@property (nonatomic) MXKRoomBubbleComponentDisplayFix displayFix; + +/** + The first link detected in the event's content, otherwise nil. + */ +@property (nonatomic) NSURL *link; + +/** + Any data necessary to show a URL preview. + Note: MatrixKit is unable to display this data by itself. + */ +@property (nonatomic) id urlPreviewData; + +/** + Whether a URL preview should be displayed for this cell. + Note: MatrixKit is unable to display URL previews by itself. + */ +@property (nonatomic) BOOL showURLPreview; + +/** + Event antivirus scan. Present only if antivirus is enabled and event contains media. + */ +@property (nonatomic) MXEventScan *eventScan; + +/** + Indicate if an encryption badge should be shown. + */ +@property (nonatomic, readonly) BOOL showEncryptionBadge; + +/** + Create a new `MXKRoomBubbleComponent` object based on a `MXEvent` instance. + + @param event the event used to compose the bubble component. + @param roomState the room state when the event occured. + @param eventFormatter object used to format event into displayable string. + @param session the related matrix session. + @return the newly created instance. + */ +- (instancetype)initWithEvent:(MXEvent*)event roomState:(MXRoomState*)roomState eventFormatter:(MXKEventFormatter*)eventFormatter session:(MXSession*)session; + +/** + Update the event because its sent state changed or it is has been redacted. + + @param event the new event data. + @param roomState the up-to-date state of the room. + @param session the related matrix session. + */ +- (void)updateWithEvent:(MXEvent*)event roomState:(MXRoomState*)roomState session:(MXSession*)session; + +@end + diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m new file mode 100644 index 000000000..565519ba9 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m @@ -0,0 +1,189 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleComponent.h" + +#import "MXEvent+MatrixKit.h" +#import "MXKSwiftHeader.h" + +@implementation MXKRoomBubbleComponent + +- (instancetype)initWithEvent:(MXEvent*)event roomState:(MXRoomState*)roomState eventFormatter:(MXKEventFormatter*)eventFormatter session:(MXSession*)session; +{ + if (self = [super init]) + { + // Build text component related to this event + _eventFormatter = eventFormatter; + MXKEventFormatterError error; + + NSAttributedString *eventString = [_eventFormatter attributedStringFromEvent:event withRoomState:roomState error:&error]; + + // Store the potential error + event.mxkEventFormatterError = error; + + _textMessage = nil; + _attributedTextMessage = eventString; + + // Set date time + if (event.originServerTs != kMXUndefinedTimestamp) + { + _date = [NSDate dateWithTimeIntervalSince1970:(double)event.originServerTs/1000]; + } + else + { + _date = nil; + } + + // Keep ref on event (used to handle the read marker, or a potential event redaction). + _event = event; + + _displayFix = MXKRoomBubbleComponentDisplayFixNone; + if ([event.content[@"format"] isEqualToString:kMXRoomMessageFormatHTML]) + { + if ([((NSString*)event.content[@"formatted_body"]) containsString:@" +#import + +#import + +@class MXSession; + +/** + `MXKRoomCreationInputs` objects lists all the fields considered for a new room creation. + */ +@interface MXKRoomCreationInputs : NSObject + +/** + The selected matrix session in which the new room should be created. + */ +@property (nonatomic) MXSession* mxSession; + +/** + The room name. + */ +@property (nonatomic) NSString* roomName; + +/** + The room alias. + */ +@property (nonatomic) NSString* roomAlias; + +/** + The room topic. + */ +@property (nonatomic) NSString* roomTopic; + +/** + The room picture. + */ +@property (nonatomic) UIImage *roomPicture; + +/** + The room visibility (kMXRoomVisibilityPrivate by default). + */ +@property (nonatomic) MXRoomDirectoryVisibility roomVisibility; + +/** + The room participants (nil by default). + */ +@property (nonatomic) NSArray *roomParticipants; + +/** + Add a participant. + + @param participantId The matrix user id of the participant. + */ +- (void)addParticipant:(NSString *)participantId; + +/** + Remove a participant. + + @param participantId The matrix user id of the participant. + */ +- (void)removeParticipant:(NSString *)participantId; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomCreationInputs.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomCreationInputs.m new file mode 100644 index 000000000..f5cda5bb8 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomCreationInputs.m @@ -0,0 +1,74 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomCreationInputs.h" + +#import + +@interface MXKRoomCreationInputs () +{ + NSMutableArray *participants; +} +@end + +@implementation MXKRoomCreationInputs + +- (instancetype)init +{ + self = [super init]; + if (self) + { + _roomVisibility = kMXRoomDirectoryVisibilityPrivate; + } + return self; +} + +- (void)setRoomParticipants:(NSArray *)roomParticipants +{ + participants = [NSMutableArray arrayWithArray:roomParticipants]; +} + +- (NSArray*)roomParticipants +{ + return participants; +} + +- (void)addParticipant:(NSString *)participantId +{ + if (participantId.length) + { + if (!participants) + { + participants = [NSMutableArray array]; + } + [participants addObject:participantId]; + } +} + +- (void)removeParticipant:(NSString *)participantId +{ + if (participantId.length) + { + [participants removeObject:participantId]; + + if (!participants.count) + { + participants = nil; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.h new file mode 100644 index 000000000..8559fba2d --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.h @@ -0,0 +1,779 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKDataSource.h" +#import "MXKRoomBubbleCellDataStoring.h" +#import "MXKEventFormatter.h" + +@class MXKQueuedEvent; + +/** + Define the threshold which triggers a bubbles count flush. + */ +#define MXKROOMDATASOURCE_CACHED_BUBBLES_COUNT_THRESHOLD 30 + +/** + Define the number of messages to preload around the initial event. + */ +#define MXKROOMDATASOURCE_PAGINATION_LIMIT_AROUND_INITIAL_EVENT 30 + +/** + List the supported pagination of the rendered room bubble cells + */ +typedef enum : NSUInteger +{ + /** + No pagination + */ + MXKRoomDataSourceBubblesPaginationNone, + /** + The rendered room bubble cells are paginated per day + */ + MXKRoomDataSourceBubblesPaginationPerDay + +} MXKRoomDataSourceBubblesPagination; + + +#pragma mark - Cells identifiers + +/** + String identifying the object used to store and prepare room bubble data. + */ +extern NSString *const kMXKRoomBubbleCellDataIdentifier; + + +#pragma mark - Notifications + +/** + Posted when a server sync starts or ends (depend on 'serverSyncEventCount'). + The notification object is the `MXKRoomDataSource` instance. + */ +extern NSString *const kMXKRoomDataSourceSyncStatusChanged; + +/** + Posted when the data source has failed to paginate around an event. + The notification object is the `MXKRoomDataSource` instance. The `userInfo` dictionary contains the following key: + - kMXKRoomDataTimelineErrorErrorKey: The NSError. + */ +extern NSString *const kMXKRoomDataSourceTimelineError; + +/** + Notifications `userInfo` keys + */ +extern NSString *const kMXKRoomDataSourceTimelineErrorErrorKey; + +#pragma mark - MXKRoomDataSource +@protocol MXKRoomBubbleCellDataStoring; +@class MXKRoomBubbleCellData; + +/** + The data source for `MXKRoomViewController`. + */ +@interface MXKRoomDataSource : MXKDataSource +{ +@protected + + /** + The data for the cells served by `MXKRoomDataSource`. + */ + NSMutableArray> *bubbles; + + /** + The queue of events that need to be processed in order to compute their display. + */ + NSMutableArray *eventsToProcess; + + /** + The dictionary of the related groups that the current user did not join. + */ + NSMutableDictionary *externalRelatedGroups; +} + +/** + The id of the room managed by the data source. + */ +@property (nonatomic, readonly) NSString *roomId; + +/** + The id of the secondary room managed by the data source. Events with specified types from the secondary room will be provided from the data source. + @see `secondaryRoomEventTypes`. + Can be nil. + */ +@property (nonatomic, copy) NSString *secondaryRoomId; + +/** + Types of events to include from the secondary room. Default is all call events. + */ +@property (nonatomic, copy) NSArray *secondaryRoomEventTypes; + +/** + The room the data comes from. + The object is defined when the MXSession has data for the room + */ +@property (nonatomic, readonly) MXRoom *room; + +/** + The preloaded room.state. + */ +@property (nonatomic, readonly) MXRoomState *roomState; + +/** + The timeline being managed. It can be the live timeline of the room + or a timeline from a past event, initialEventId. + */ +@property (nonatomic, readonly) MXEventTimeline *timeline; + +/** + Flag indicating if the data source manages, or will manage, a live timeline. + */ +@property (nonatomic, readonly) BOOL isLive; + +/** + Flag indicating if the data source is used to peek into a room, ie it gets data from + a room the user has not joined yet. + */ +@property (nonatomic, readonly) BOOL isPeeking; + +/** + The list of the attachments with thumbnail in the current available bubbles (MXKAttachment instances). + Note: the stickers are excluded from the returned list. + Note2: the attachments for which the antivirus scan status is not available are excluded too. + */ +@property (nonatomic, readonly) NSArray *attachmentsWithThumbnail; + +/** + The events are processed asynchronously. This property counts the number of queued events + during server sync for which the process is pending. + */ +@property (nonatomic, readonly) NSInteger serverSyncEventCount; + +/** + The current text message partially typed in text input (use nil to reset it). + */ +@property (nonatomic) NSString *partialTextMessage; + +#pragma mark - Configuration +/** + The text formatter applied on the events. + By default, the events are filtered according to the value stored in the shared application settings (see [MXKAppSettings standardAppSettings].eventsFilterForMessages). + The events whose the type doesn't belong to the this list are not displayed. + `MXKRoomBubbleCellDataStoring` instances can use it to format text. + */ +@property (nonatomic) MXKEventFormatter *eventFormatter; + +/** + Show the date time label in rendered room bubble cells. NO by default. + */ +@property (nonatomic) BOOL showBubblesDateTime; + +/** + A Boolean value that determines whether the date time labels are customized (By default date time display is handled by MatrixKit). NO by default. + */ +@property (nonatomic) BOOL useCustomDateTimeLabel; + +/** + Show the read marker (if any) in the rendered room bubble cells. YES by default. + */ +@property (nonatomic) BOOL showReadMarker; + +/** + Show the receipts in rendered bubble cell. YES by default. + */ +@property (nonatomic) BOOL showBubbleReceipts; + +/** + A Boolean value that determines whether the read receipts are customized (By default read receipts display is handled by MatrixKit). NO by default. + */ +@property (nonatomic) BOOL useCustomReceipts; + +/** + Show the reactions in rendered bubble cell. NO by default. + */ +@property (nonatomic) BOOL showReactions; + +/** + Show only reactions with single Emoji. NO by default. + */ +@property (nonatomic) BOOL showOnlySingleEmojiReactions; + +/** + A Boolean value that determines whether the unsent button is customized (By default an 'Unsent' button is displayed by MatrixKit in front of unsent events). NO by default. + */ +@property (nonatomic) BOOL useCustomUnsentButton; + +/** + Show the typing notifications of other room members in the chat history (NO by default). + */ +@property (nonatomic) BOOL showTypingNotifications; + +/** + The pagination applied on the rendered room bubble cells (MXKRoomDataSourceBubblesPaginationNone by default). + */ +@property (nonatomic) MXKRoomDataSourceBubblesPagination bubblesPagination; + +/** + Max nbr of cached bubbles when there is no delegate. + The default value is 30. + */ +@property (nonatomic) unsigned long maxBackgroundCachedBubblesCount; + +/** + The number of messages to preload around the initial event. + The default value is 30. + */ +@property (nonatomic) NSUInteger paginationLimitAroundInitialEvent; + +/** + Tell whether only the message events with an url key in their content must be handled. NO by default. + Note: The stickers are not retained by this filter. + */ +@property (nonatomic) BOOL filterMessagesWithURL; + +#pragma mark - Life cycle + +/** + Asynchronously create a data source to serve data corresponding to the passed room. + + This method preloads room data, like the room state, to make it available once + the room data source is created. + + @param roomId the id of the room to get data from. + @param mxSession the Matrix session to get data from. + @param onComplete a block providing the newly created instance. + */ ++ (void)loadRoomDataSourceWithRoomId:(NSString*)roomId andMatrixSession:(MXSession*)mxSession onComplete:(void (^)(id roomDataSource))onComplete; + +/** + Asynchronously create adata source to serve data corresponding to an event in the + past of a room. + + This method preloads room data, like the room state, to make it available once + the room data source is created. + + @param roomId the id of the room to get data from. + @param initialEventId the id of the event where to start the timeline. + @param mxSession the Matrix session to get data from. + @param onComplete a block providing the newly created instance. + */ ++ (void)loadRoomDataSourceWithRoomId:(NSString*)roomId initialEventId:(NSString*)initialEventId andMatrixSession:(MXSession*)mxSession onComplete:(void (^)(id roomDataSource))onComplete; + +/** + Asynchronously create a data source to peek into a room. + + The data source will close the `peekingRoom` instance on [self destroy]. + + This method preloads room data, like the room state, to make it available once + the room data source is created. + + @param peekingRoom the room to peek. + @param initialEventId the id of the event where to start the timeline. nil means the live + timeline. + @param onComplete a block providing the newly created instance. + */ ++ (void)loadRoomDataSourceWithPeekingRoom:(MXPeekingRoom*)peekingRoom andInitialEventId:(NSString*)initialEventId onComplete:(void (^)(id roomDataSource))onComplete; + +#pragma mark - Constructors (Should not be called directly) + +/** + Initialise the data source to serve data corresponding to the passed room. + + @param roomId the id of the room to get data from. + @param mxSession the Matrix session to get data from. + @return the newly created instance. + */ +- (instancetype)initWithRoomId:(NSString*)roomId andMatrixSession:(MXSession*)mxSession; + +/** + Initialise the data source to serve data corresponding to an event in the + past of a room. + + @param roomId the id of the room to get data from. + @param initialEventId the id of the event where to start the timeline. + @param mxSession the Matrix session to get data from. + @return the newly created instance. + */ +- (instancetype)initWithRoomId:(NSString*)roomId initialEventId:(NSString*)initialEventId andMatrixSession:(MXSession*)mxSession; + +/** + Initialise the data source to peek into a room. + + The data source will close the `peekingRoom` instance on [self destroy]. + + @param peekingRoom the room to peek. + @param initialEventId the id of the event where to start the timeline. nil means the live + timeline. + @return the newly created instance. + */ +- (instancetype)initWithPeekingRoom:(MXPeekingRoom*)peekingRoom andInitialEventId:(NSString*)initialEventId; + +/** + Mark all messages as read in the room. + */ +- (void)markAllAsRead; + +/** + Reduce memory usage by releasing room data if the number of bubbles is over the provided limit 'maxBubbleNb'. + + This operation is ignored if some local echoes are pending or if unread messages counter is not nil. + + @param maxBubbleNb The room bubble data are released only if the number of bubbles is over this limit. + */ +- (void)limitMemoryUsage:(NSInteger)maxBubbleNb; + +/** + Force data reload. + */ +- (void)reload; + +/** + Called when room property changed. Designed to be used by subclasses. + */ +- (void)roomDidSet; + +#pragma mark - Public methods +/** + Get the data for the cell at the given index. + + @param index the index of the cell in the array + @return the cell data + */ +- (id)cellDataAtIndex:(NSInteger)index; + +/** + Get the data for the cell which contains the event with the provided event id. + + @param eventId the event identifier + @return the cell data + */ +- (id)cellDataOfEventWithEventId:(NSString*)eventId; + +/** + Get the index of the cell which contains the event with the provided event id. + + @param eventId the event identifier + @return the index of the concerned cell (NSNotFound if none). + */ +- (NSInteger)indexOfCellDataWithEventId:(NSString *)eventId; + +/** + Get height of the cell at the given index. + + @param index the index of the cell in the array. + @param maxWidth the maximum available width. + @return the cell height (0 if no data is available for this cell, or if the delegate is undefined). + */ +- (CGFloat)cellHeightAtIndex:(NSInteger)index withMaximumWidth:(CGFloat)maxWidth; + + +/** + Force bubbles cell data message recalculation. + */ +- (void)invalidateBubblesCellDataCache; + +#pragma mark - Pagination +/** + Load more messages. + This method fails (with nil error) if the data source is not ready (see `MXKDataSourceStateReady`). + + @param numItems the number of items to get. + @param direction backwards or forwards. + @param onlyFromStore if YES, return available events from the store, do not make a pagination request to the homeserver. + @param success a block called when the operation succeeds. This block returns the number of added cells. + (Note this count may be 0 if paginated messages have been concatenated to the current first cell). + @param failure a block called when the operation fails. + */ +- (void)paginate:(NSUInteger)numItems direction:(MXTimelineDirection)direction onlyFromStore:(BOOL)onlyFromStore success:(void (^)(NSUInteger addedCellNumber))success failure:(void (^)(NSError *error))failure; + +/** + Load enough messages to fill the rect. + This method fails (with nil error) if the data source is not ready (see `MXKDataSourceStateReady`), + or if the delegate is undefined (this delegate is required to compute the actual size of the cells). + + @param rect the rect to fill. + @param direction backwards or forwards. + @param minRequestMessagesCount if messages are not available in the store, a request to the homeserver + is required. minRequestMessagesCount indicates the minimum messages count to retrieve from the hs. + @param success a block called when the operation succeeds. + @param failure a block called when the operation fails. + */ +- (void)paginateToFillRect:(CGRect)rect direction:(MXTimelineDirection)direction withMinRequestMessagesCount:(NSUInteger)minRequestMessagesCount success:(void (^)(void))success failure:(void (^)(NSError *error))failure; + + +#pragma mark - Sending +/** + Send a text message to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param text the text to send. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendTextMessage:(NSString*)text + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + +/** + Send a reply to an event with text message to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param eventIdToReply the id of event to reply. + @param text the text to send. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendReplyToEventWithId:(NSString*)eventIdToReply + withTextMessage:(NSString *)text + success:(void (^)(NSString *))success + failure:(void (^)(NSError *))failure; + +/** + Indicates if replying to the provided event is supported. + Only event of type 'MXEventTypeRoomMessage' are supported for the moment, and for certain msgtype. + + @param eventId The id of the event. + @return YES if it is possible to reply to this event. + */ +- (BOOL)canReplyToEventWithId:(NSString*)eventId; + +/** + Send an image to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param image the UIImage containing the image to send. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendImage:(UIImage*)image + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + +/** + Send an image to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param imageData the full-sized image data of the image to send. + @param mimetype the mime type of the image + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendImage:(NSData*)imageData mimeType:(NSString*)mimetype success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure; + +/** + Send a video to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param videoLocalURL the local filesystem path of the video to send. + @param videoThumbnail the UIImage hosting a video thumbnail. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendVideo:(NSURL*)videoLocalURL + withThumbnail:(UIImage*)videoThumbnail + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + +/** + Send a video to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param videoAsset the AVAsset that represents the video to send. + @param videoThumbnail the UIImage hosting a video thumbnail. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendVideoAsset:(AVAsset*)videoAsset + withThumbnail:(UIImage*)videoThumbnail + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + +/** + Send an audio file to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param audioFileLocalURL the local filesystem path of the audio file to send. + @param mimeType the mime type of the file. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendAudioFile:(NSURL *)audioFileLocalURL + mimeType:mimeType + success:(void (^)(NSString *))success + failure:(void (^)(NSError *))failure; + +/** + Send a voice message to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param audioFileLocalURL the local filesystem path of the audio file to send. + @param mimeType (optional) the mime type of the file. Defaults to `audio/ogg` + @param duration the length of the voice message in milliseconds + @param samples an array of floating point values normalized to [0, 1], boxed within NSNumbers + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendVoiceMessage:(NSURL *)audioFileLocalURL + mimeType:mimeType + duration:(NSUInteger)duration + samples:(NSArray *)samples + success:(void (^)(NSString *))success + failure:(void (^)(NSError *))failure; + +/** + Send a file to the room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param fileLocalURL the local filesystem path of the file to send. + @param mimeType the mime type of the file. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendFile:(NSURL*)fileLocalURL + mimeType:(NSString*)mimeType + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + +/** + Send a room message to a room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param content the message content that will be sent to the server as a JSON object. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendMessageWithContent:(NSDictionary*)content + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + +/** + Send a generic non state event to a room. + + While sending, a fake event will be echoed in the messages list. + Once complete, this local echo will be replaced by the event saved by the homeserver. + + @param eventTypeString the type of the event. @see MXEventType. + @param content the content that will be sent to the server as a JSON object. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)sendEventOfType:(MXEventTypeString)eventTypeString + content:(NSDictionary*)content + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + +/** + Resend a room message event. + + The echo message corresponding to the event will be removed and a new echo message + will be added at the end of the room history. + + @param eventId of the event to resend. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver + @param failure A block object called when the operation fails. + */ +- (void)resendEventWithEventId:(NSString*)eventId + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + + +#pragma mark - Events management +/** + Get an event loaded in this room datasource. + + @param eventId of the event to retrieve. + @return the MXEvent object or nil if not found. + */ +- (MXEvent *)eventWithEventId:(NSString *)eventId; + +/** + Remove an event from the events loaded by room datasource. + + @param eventId of the event to remove. + */ +- (void)removeEventWithEventId:(NSString *)eventId; + +/** + This method is called for each read receipt event received in forward mode. + + By default, it tells the delegate that some cell data/views have been changed. + You may override this method to handle the receipt event according to the application needs. + + You should not call this method directly. + You may override it in inherited 'MXKRoomDataSource' class. + + @param receiptEvent an event with 'm.receipt' type. + @param roomState the room state right before the event + */ +- (void)didReceiveReceiptEvent:(MXEvent *)receiptEvent roomState:(MXRoomState *)roomState; + +/** + Update read receipts for an event in a bubble cell data. + + @param cellData The cell data to update. + @param readReceipts The new read receipts. + @param eventId The id of the event. + */ +- (void)updateCellData:(MXKRoomBubbleCellData*)cellData withReadReceipts:(NSArray*)readReceipts forEventId:(NSString*)eventId; + +/** + Overridable method to customise the way how unsent messages are managed. + By default, they are added to the end of the timeline. + */ +- (void)handleUnsentMessages; + +#pragma mark - Asynchronous events processing +/** + The dispatch queue to process room messages. + + This processing can consume time. Handling it on a separated thread avoids to block the main thread. + All MXKRoomDataSource instances share the same dispatch queue. + */ ++ (dispatch_queue_t)processingQueue; + +#pragma mark - Bubble collapsing + +/** + Collapse or expand a series of collapsable bubbles. + + @param bubbleData the first bubble of the series. + @param collapsed YES to collapse. NO to expand. + */ +- (void)collapseRoomBubble:(id)bubbleData collapsed:(BOOL)collapsed; + +#pragma mark - Groups + +/** + Get a MXGroup instance for a group. + This method is used by the bubble to retrieve a related groups of the room. + + @param groupId The identifier to the group. + @return the MXGroup instance. + */ +- (MXGroup *)groupWithGroupId:(NSString*)groupId; + +#pragma mark - Reactions + +/** + Indicates if it's possible to react on the event. + + @param eventId The id of the event. + @return True to indicates reaction possibility for this event. + */ +- (BOOL)canReactToEventWithId:(NSString*)eventId; + +/** + Send a reaction to an event. + + @param reaction Reaction to add. + @param eventId The id of the event. + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)addReaction:(NSString *)reaction + forEventId:(NSString *)eventId + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + Unreact a reaction to an event. + + @param reaction Reaction to unreact. + @param eventId The id of the event. + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)removeReaction:(NSString *)reaction + forEventId:(NSString *)eventId + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +#pragma mark - Editions + +/** + Indicates if it's possible to edit the event content. + + @param eventId The id of the event. + @return True to indicates edition possibility for this event. + */ +- (BOOL)canEditEventWithId:(NSString*)eventId; + +/** + Replace a text in an event. + + @param eventId The eventId of event to replace. + @param text The new message text. + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the homeserver. + @param failure A block object called when the operation fails. + */ +- (void)replaceTextMessageForEventWithId:(NSString *)eventId + withTextMessage:(NSString *)text + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure; + + +/** + Update reactions for an event in a bubble cell data. + + @param cellData The cell data to update. + @param eventId The id of the event. + */ +- (void)updateCellDataReactions:(id)cellData forEventId:(NSString*)eventId; + +/** + Retrieve editable text message from an event. + + @param event An event. + @return Event text editable by user. + */ +- (NSString*)editableTextMessageForEvent:(MXEvent*)event; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m new file mode 100644 index 000000000..cfe7e1a31 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m @@ -0,0 +1,4127 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomDataSource.h" + +@import MatrixSDK; + +#import "MXKQueuedEvent.h" +#import "MXKRoomBubbleTableViewCell.h" + +#import "MXKRoomBubbleCellData.h" + +#import "MXKTools.h" +#import "MXAggregatedReactions+MatrixKit.h" + +#import "MXKAppSettings.h" + +#import "MXKSendReplyEventStringLocalizer.h" +#import "MXKSlashCommands.h" + + +#pragma mark - Constant definitions + +NSString *const kMXKRoomBubbleCellDataIdentifier = @"kMXKRoomBubbleCellDataIdentifier"; + +NSString *const kMXKRoomDataSourceSyncStatusChanged = @"kMXKRoomDataSourceSyncStatusChanged"; +NSString *const kMXKRoomDataSourceFailToLoadTimelinePosition = @"kMXKRoomDataSourceFailToLoadTimelinePosition"; +NSString *const kMXKRoomDataSourceTimelineError = @"kMXKRoomDataSourceTimelineError"; +NSString *const kMXKRoomDataSourceTimelineErrorErrorKey = @"kMXKRoomDataSourceTimelineErrorErrorKey"; + +NSString * const MXKRoomDataSourceErrorDomain = @"kMXKRoomDataSourceErrorDomain"; + +typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { + MXKRoomDataSourceErrorResendGeneric = 10001, + MXKRoomDataSourceErrorResendInvalidMessageType = 10002, + MXKRoomDataSourceErrorResendInvalidLocalFilePath = 10003, +}; + + +@interface MXKRoomDataSource () +{ + /** + If the data is not from a live timeline, `initialEventId` is the event in the past + where the timeline starts. + */ + NSString *initialEventId; + + /** + Current pagination request (if any) + */ + MXHTTPOperation *paginationRequest; + + /** + The actual listener related to the current pagination in the timeline. + */ + id paginationListener; + + /** + The listener to incoming events in the room. + */ + id liveEventsListener; + + /** + The listener to redaction events in the room. + */ + id redactionListener; + + /** + The listener to receipts events in the room. + */ + id receiptsListener; + + /** + The listener to the related groups state events in the room. + */ + id relatedGroupsListener; + + /** + The listener to reactions changed in the room. + */ + id reactionsChangeListener; + + /** + The listener to edits in the room. + */ + id eventEditsListener; + + /** + Current secondary pagination request (if any) + */ + MXHTTPOperation *secondaryPaginationRequest; + + /** + The listener to incoming events in the secondary room. + */ + id secondaryLiveEventsListener; + + /** + The listener to redaction events in the secondary room. + */ + id secondaryRedactionListener; + + /** + The actual listener related to the current pagination in the secondary timeline. + */ + id secondaryPaginationListener; + + /** + Mapping between events ids and bubbles. + */ + NSMutableDictionary *eventIdToBubbleMap; + + /** + Typing notifications listener. + */ + id typingNotifListener; + + /** + List of members who are typing in the room. + */ + NSArray *currentTypingUsers; + + /** + Snapshot of the queued events. + */ + NSMutableArray *eventsToProcessSnapshot; + + /** + Snapshot of the bubbles used during events processing. + */ + NSMutableArray> *bubblesSnapshot; + + /** + The room being peeked, if any. + */ + MXPeekingRoom *peekingRoom; + + /** + If any, the non terminated series of collapsable events at the start of self.bubbles. + (Such series is determined by the cell data of its oldest event). + */ + id collapsableSeriesAtStart; + + /** + If any, the non terminated series of collapsable events at the end of self.bubbles. + (Such series is determined by the cell data of its oldest event). + */ + id collapsableSeriesAtEnd; + + /** + Observe UIApplicationSignificantTimeChangeNotification to trigger cell change on time formatting change. + */ + id UIApplicationSignificantTimeChangeNotificationObserver; + + /** + Observe NSCurrentLocaleDidChangeNotification to trigger cell change on time formatting change. + */ + id NSCurrentLocaleDidChangeNotificationObserver; + + /** + Observe kMXRoomDidFlushDataNotification to trigger cell change when existing room history has been flushed during server sync. + */ + id roomDidFlushDataNotificationObserver; + + /** + Observe kMXRoomDidUpdateUnreadNotification to refresh unread counters. + */ + id roomDidUpdateUnreadNotificationObserver; + + /** + Emote slash command prefix @"/me " + */ + NSString *emoteMessageSlashCommandPrefix; +} + +/** + Indicate to stop back-paginating when finding an un-decryptable event as previous event. + It is used to hide pre join UTD events before joining the room. + */ +@property (nonatomic, assign) BOOL shouldPreventBackPaginationOnPreviousUTDEvent; + +/** + Indicate to stop back-paginating. + */ +@property (nonatomic, assign) BOOL shouldStopBackPagination; + +@property (nonatomic, readwrite) MXRoom *room; + +@property (nonatomic, readwrite) MXRoom *secondaryRoom; +@property (nonatomic, strong) MXEventTimeline *secondaryTimeline; + +@end + +@implementation MXKRoomDataSource + ++ (void)loadRoomDataSourceWithRoomId:(NSString*)roomId andMatrixSession:(MXSession*)mxSession onComplete:(void (^)(id roomDataSource))onComplete +{ + MXKRoomDataSource *roomDataSource = [[self alloc] initWithRoomId:roomId andMatrixSession:mxSession]; + [self ensureSessionStateForDataSource:roomDataSource initialEventId:nil andMatrixSession:mxSession onComplete:onComplete]; +} + ++ (void)loadRoomDataSourceWithRoomId:(NSString*)roomId initialEventId:(NSString*)initialEventId andMatrixSession:(MXSession*)mxSession onComplete:(void (^)(id roomDataSource))onComplete +{ + MXKRoomDataSource *roomDataSource = [[self alloc] initWithRoomId:roomId initialEventId:initialEventId andMatrixSession:mxSession]; + [self ensureSessionStateForDataSource:roomDataSource initialEventId:initialEventId andMatrixSession:mxSession onComplete:onComplete]; +} + ++ (void)loadRoomDataSourceWithPeekingRoom:(MXPeekingRoom*)peekingRoom andInitialEventId:(NSString*)initialEventId onComplete:(void (^)(id roomDataSource))onComplete +{ + MXKRoomDataSource *roomDataSource = [[self alloc] initWithPeekingRoom:peekingRoom andInitialEventId:initialEventId]; + [self finalizeRoomDataSource:roomDataSource onComplete:onComplete]; +} + +/// Ensure session state to be store data ready for the roomDataSource. ++ (void)ensureSessionStateForDataSource:(MXKRoomDataSource*)roomDataSource initialEventId:(NSString*)initialEventId andMatrixSession:(MXSession*)mxSession onComplete:(void (^)(id roomDataSource))onComplete +{ + // if store is not ready, roomDataSource.room will be nil. So onComplete block will never be called. + // In order to successfully fetch the room, we should wait for store to be ready. + if (mxSession.state >= MXSessionStateStoreDataReady) + { + [self finalizeRoomDataSource:roomDataSource onComplete:onComplete]; + } + else + { + // wait for session state to be store data ready + __block id sessionStateObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXSessionStateDidChangeNotification object:mxSession queue:nil usingBlock:^(NSNotification * _Nonnull note) { + if (mxSession.state >= MXSessionStateStoreDataReady) + { + [[NSNotificationCenter defaultCenter] removeObserver:sessionStateObserver]; + [self finalizeRoomDataSource:roomDataSource onComplete:onComplete]; + } + }]; + } +} + ++ (void)finalizeRoomDataSource:(MXKRoomDataSource*)roomDataSource onComplete:(void (^)(id roomDataSource))onComplete +{ + if (roomDataSource) + { + [roomDataSource finalizeInitialization]; + + // Asynchronously preload data here so that the data will be ready later + // to synchronously respond to that request + [roomDataSource.room liveTimeline:^(MXEventTimeline *liveTimeline) { + onComplete(roomDataSource); + }]; + } +} + +- (instancetype)initWithRoomId:(NSString *)roomId andMatrixSession:(MXSession *)matrixSession +{ + self = [super initWithMatrixSession:matrixSession]; + if (self) + { + MXLogVerbose(@"[MXKRoomDataSource][%p] initWithRoomId: %@", self, roomId); + + _roomId = roomId; + _secondaryRoomEventTypes = @[ + kMXEventTypeStringCallInvite, + kMXEventTypeStringCallCandidates, + kMXEventTypeStringCallAnswer, + kMXEventTypeStringCallSelectAnswer, + kMXEventTypeStringCallHangup, + kMXEventTypeStringCallReject, + kMXEventTypeStringCallNegotiate, + kMXEventTypeStringCallReplaces, + kMXEventTypeStringCallRejectReplacement + ]; + NSString *virtualRoomId = [matrixSession virtualRoomOf:_roomId]; + if (virtualRoomId) + { + _secondaryRoomId = virtualRoomId; + } + _isLive = YES; + bubbles = [NSMutableArray array]; + eventsToProcess = [NSMutableArray array]; + eventIdToBubbleMap = [NSMutableDictionary dictionary]; + + externalRelatedGroups = [NSMutableDictionary dictionary]; + + _filterMessagesWithURL = NO; + + emoteMessageSlashCommandPrefix = [NSString stringWithFormat:@"%@ ", kMXKSlashCmdEmote]; + + // Set default data and view classes + // Cell data + [self registerCellDataClass:MXKRoomBubbleCellData.class forCellIdentifier:kMXKRoomBubbleCellDataIdentifier]; + + // Set default MXEvent -> NSString formatter + self.eventFormatter = [[MXKEventFormatter alloc] initWithMatrixSession:self.mxSession]; + // Apply here the event types filter to display only the wanted event types. + self.eventFormatter.eventTypesFilterForMessages = [MXKAppSettings standardAppSettings].eventsFilterForMessages; + + // display the read receips by default + self.showBubbleReceipts = YES; + + // show the read marker by default + self.showReadMarker = YES; + + // Disable typing notification in cells by default. + self.showTypingNotifications = NO; + + self.useCustomDateTimeLabel = NO; + self.useCustomReceipts = NO; + self.useCustomUnsentButton = NO; + + _maxBackgroundCachedBubblesCount = MXKROOMDATASOURCE_CACHED_BUBBLES_COUNT_THRESHOLD; + _paginationLimitAroundInitialEvent = MXKROOMDATASOURCE_PAGINATION_LIMIT_AROUND_INITIAL_EVENT; + + // Observe UIApplicationSignificantTimeChangeNotification to refresh bubbles if date/time are shown. + // UIApplicationSignificantTimeChangeNotification is posted if DST is updated, carrier time is updated + UIApplicationSignificantTimeChangeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationSignificantTimeChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + [self onDateTimeFormatUpdate]; + }]; + + // Observe NSCurrentLocaleDidChangeNotification to refresh bubbles if date/time are shown. + // NSCurrentLocaleDidChangeNotification is triggered when the time swicthes to AM/PM to 24h time format + NSCurrentLocaleDidChangeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:NSCurrentLocaleDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + [self onDateTimeFormatUpdate]; + + }]; + + // Listen to the event sent state changes + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(eventDidChangeSentState:) name:kMXEventDidChangeSentStateNotification object:nil]; + // Listen to events decrypted + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(eventDidDecrypt:) name:kMXEventDidDecryptNotification object:nil]; + // Listen to virtual rooms change + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(virtualRoomsDidChange:) name:kMXSessionVirtualRoomsDidChangeNotification object:matrixSession]; + } + return self; +} + +- (instancetype)initWithRoomId:(NSString*)roomId initialEventId:(NSString*)initialEventId2 andMatrixSession:(MXSession*)mxSession +{ + self = [self initWithRoomId:roomId andMatrixSession:mxSession]; + if (self) + { + if (initialEventId2) + { + initialEventId = initialEventId2; + _isLive = NO; + } + } + + return self; +} + +- (instancetype)initWithPeekingRoom:(MXPeekingRoom*)peekingRoom2 andInitialEventId:(NSString*)theInitialEventId +{ + self = [self initWithRoomId:peekingRoom2.roomId initialEventId:theInitialEventId andMatrixSession:peekingRoom2.mxSession]; + if (self) + { + peekingRoom = peekingRoom2; + _isPeeking = YES; + } + return self; +} + +- (void)dealloc +{ + [self unregisterEventEditsListener]; + [self unregisterScanManagerNotifications]; + [self unregisterReactionsChangeListener]; +} + +- (MXRoomState *)roomState +{ + // @TODO(async-state): Just here for dev + NSAssert(_timeline.state, @"[MXKRoomDataSource] Room state must be preloaded before accessing to MXKRoomDataSource.roomState"); + return _timeline.state; +} + +- (void)onDateTimeFormatUpdate +{ + // update the date and the time formatters + [self.eventFormatter initDateTimeFormatters]; + + // refresh the UI if it is required + if (self.showBubblesDateTime && self.delegate) + { + // Reload all the table + [self.delegate dataSource:self didCellChange:nil]; + } +} + +- (void)markAllAsRead +{ + [_room.summary markAllAsRead]; +} + +- (void)limitMemoryUsage:(NSInteger)maxBubbleNb +{ + NSInteger bubbleCount; + @synchronized(bubbles) + { + bubbleCount = bubbles.count; + } + + if (bubbleCount > maxBubbleNb) + { + // Do nothing if some local echoes are in progress. + NSArray* outgoingMessages = _room.outgoingMessages; + + for (NSInteger index = 0; index < outgoingMessages.count; index++) + { + MXEvent *outgoingMessage = [outgoingMessages objectAtIndex:index]; + + if (outgoingMessage.sentState == MXEventSentStateSending || + outgoingMessage.sentState == MXEventSentStatePreparing || + outgoingMessage.sentState == MXEventSentStateEncrypting || + outgoingMessage.sentState == MXEventSentStateUploading) + { + MXLogDebug(@"[MXKRoomDataSource][%p] cancel limitMemoryUsage because some messages are being sent", self); + return; + } + } + + // Reset the room data source (return in initial state: minimum memory usage). + [self reload]; + } +} + +- (void)reset +{ + [self resetNotifying:YES]; +} + +- (void)resetNotifying:(BOOL)notify +{ + [externalRelatedGroups removeAllObjects]; + + if (roomDidFlushDataNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:roomDidFlushDataNotificationObserver]; + roomDidFlushDataNotificationObserver = nil; + } + + if (roomDidUpdateUnreadNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:roomDidUpdateUnreadNotificationObserver]; + roomDidUpdateUnreadNotificationObserver = nil; + } + + if (paginationRequest) + { + // We have to remove here the listener. A new pagination request may be triggered whereas the cancellation of this one is in progress + [_timeline removeListener:paginationListener]; + paginationListener = nil; + + [paginationRequest cancel]; + paginationRequest = nil; + } + + if (secondaryPaginationRequest) + { + // We have to remove here the listener. A new pagination request may be triggered whereas the cancellation of this one is in progress + [_secondaryTimeline removeListener:secondaryPaginationListener]; + secondaryPaginationListener = nil; + + [secondaryPaginationRequest cancel]; + secondaryPaginationRequest = nil; + } + + if (_room && liveEventsListener) + { + [_timeline removeListener:liveEventsListener]; + liveEventsListener = nil; + + [_timeline removeListener:redactionListener]; + redactionListener = nil; + + [_timeline removeListener:receiptsListener]; + receiptsListener = nil; + + [_timeline removeListener:relatedGroupsListener]; + relatedGroupsListener = nil; + } + + if (_secondaryRoom && secondaryLiveEventsListener) + { + [_secondaryTimeline removeListener:secondaryLiveEventsListener]; + secondaryLiveEventsListener = nil; + + [_secondaryTimeline removeListener:secondaryRedactionListener]; + secondaryRedactionListener = nil; + } + + if (_room && typingNotifListener) + { + [_timeline removeListener:typingNotifListener]; + typingNotifListener = nil; + } + currentTypingUsers = nil; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXRoomInitialSyncNotification object:nil]; + + @synchronized(eventsToProcess) + { + MXLogVerbose(@"[MXKRoomDataSource][%p] Reset eventsToProcess", self); + [eventsToProcess removeAllObjects]; + } + + // Suspend the reset operation if some events is under processing + @synchronized(eventsToProcessSnapshot) + { + eventsToProcessSnapshot = nil; + bubblesSnapshot = nil; + + @synchronized(bubbles) + { + for (id bubble in bubbles) { + bubble.prevCollapsableCellData = nil; + bubble.nextCollapsableCellData = nil; + } + [bubbles removeAllObjects]; + } + + @synchronized(eventIdToBubbleMap) + { + [eventIdToBubbleMap removeAllObjects]; + } + + self.room = nil; + self.secondaryRoom = nil; + } + + _serverSyncEventCount = 0; + + // Notify the delegate to reload its tableview + if (notify && self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } +} + +- (void)reload +{ + [self reloadNotifying:YES]; +} + +- (void)reloadNotifying:(BOOL)notify +{ + MXLogVerbose(@"[MXKRoomDataSource][%p] Reload - room id: %@", self, _roomId); + + [self setState:MXKDataSourceStatePreparing]; + + [self resetNotifying:notify]; + + // Reload + [self didMXSessionStateChange]; +} + +- (void)destroy +{ + MXLogDebug(@"[MXKRoomDataSource][%p] Destroy - room id: %@", self, _roomId); + + [self unregisterScanManagerNotifications]; + [self unregisterReactionsChangeListener]; + [self unregisterEventEditsListener]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidUpdatePublicisedGroupsForUsersNotification object:self.mxSession]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeSentStateNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidDecryptNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeIdentifierNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionVirtualRoomsDidChangeNotification object:nil]; + + if (NSCurrentLocaleDidChangeNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:NSCurrentLocaleDidChangeNotificationObserver]; + NSCurrentLocaleDidChangeNotificationObserver = nil; + } + + if (UIApplicationSignificantTimeChangeNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:UIApplicationSignificantTimeChangeNotificationObserver]; + UIApplicationSignificantTimeChangeNotificationObserver = nil; + } + + // If the room data source was used to peek into a room, stop the events stream on this room + if (peekingRoom) + { + [_room.mxSession stopPeeking:peekingRoom]; + } + + [self reset]; + + self.eventFormatter = nil; + + eventsToProcess = nil; + bubbles = nil; + eventIdToBubbleMap = nil; + + [_timeline destroy]; + [_secondaryTimeline destroy]; + + externalRelatedGroups = nil; + + [super destroy]; +} + +- (void)didMXSessionStateChange +{ + if (MXSessionStateStoreDataReady <= self.mxSession.state) + { + // Check whether the room is not already set + if (!_room) + { + // Are we peeking into a random room or displaying a room the user is part of? + if (peekingRoom) + { + self.room = peekingRoom; + } + else + { + self.room = [self.mxSession roomWithRoomId:_roomId]; + } + + if (_room) + { + // This is the time to set up the timeline according to the called init method + if (_isLive) + { + // LIVE + MXWeakify(self); + [_room liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + self->_timeline = liveTimeline; + + // Only one pagination process can be done at a time by an MXRoom object. + // This assumption is satisfied by MatrixKit. Only MXRoomDataSource does it. + [self.timeline resetPagination]; + + // Observe room history flush (sync with limited timeline, or state event redaction) + self->roomDidFlushDataNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXRoomDidFlushDataNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXRoom *room = notif.object; + if (self.mxSession == room.mxSession && ([self.roomId isEqualToString:room.roomId] || + ([self.secondaryRoomId isEqualToString:room.roomId]))) + { + // The existing room history has been flushed during server sync because a gap has been observed between local and server storage. + [self reload]; + } + + }]; + + // Add the event listeners, by considering all the event types (the event filtering is applying by the event formatter), + // except if only the events with a url key in their content must be handled. + [self refreshEventListeners:(self.filterMessagesWithURL ? @[kMXEventTypeStringRoomMessage] : [MXKAppSettings standardAppSettings].allEventTypesForMessages)]; + + // display typing notifications is optional + // the inherited class can manage them by its own. + if (self.showTypingNotifications) + { + // Register on typing notif + [self listenTypingNotifications]; + } + + // Manage unsent messages + [self handleUnsentMessages]; + + // Update here data source state if it is not already ready + if (!self->_secondaryRoomId) + { + [self setState:MXKDataSourceStateReady]; + } + + // Check user membership in this room + MXMembership membership = self.room.summary.membership; + if (membership == MXMembershipUnknown || membership == MXMembershipInvite) + { + // Here the initial sync is not ended or the room is a pending invitation. + // Note: In case of invitation, a full sync will be triggered if the user joins this room. + + // We have to observe here 'kMXRoomInitialSyncNotification' to reload room data when room sync is done. + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXRoomInitialSynced:) name:kMXRoomInitialSyncNotification object:self.room]; + } + }]; + + if (!_secondaryRoom && _secondaryRoomId) + { + _secondaryRoom = [self.mxSession roomWithRoomId:_secondaryRoomId]; + + if (_secondaryRoom) + { + MXWeakify(self); + [_secondaryRoom liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + self->_secondaryTimeline = liveTimeline; + + // Only one pagination process can be done at a time by an MXRoom object. + // This assumption is satisfied by MatrixKit. Only MXRoomDataSource does it. + [self.secondaryTimeline resetPagination]; + + // Add the secondary event listeners, by considering the event types in self.secondaryRoomEventTypes + [self refreshSecondaryEventListeners:self.secondaryRoomEventTypes]; + + // Update here data source state if it is not already ready + [self setState:MXKDataSourceStateReady]; + + // Check user membership in the secondary room + MXMembership membership = self.secondaryRoom.summary.membership; + if (membership == MXMembershipUnknown || membership == MXMembershipInvite) + { + // Here the initial sync is not ended or the room is a pending invitation. + // Note: In case of invitation, a full sync will be triggered if the user joins this room. + + // We have to observe here 'kMXRoomInitialSyncNotification' to reload room data when room sync is done. + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXRoomInitialSynced:) name:kMXRoomInitialSyncNotification object:self.secondaryRoom]; + } + }]; + } + } + } + else + { + // Past timeline + // Less things need to configured + _timeline = [_room timelineOnEvent:initialEventId]; + + // Refresh the event listeners. Note: events for past timelines come only from pagination request + [self refreshEventListeners:nil]; + + MXWeakify(self); + + // Preload the state and some messages around the initial event + [_timeline resetPaginationAroundInitialEventWithLimit:_paginationLimitAroundInitialEvent success:^{ + + MXStrongifyAndReturnIfNil(self); + + // Do a "classic" reset. The room view controller will paginate + // from the events stored in the timeline store + [self.timeline resetPagination]; + + // Update here data source state if it is not already ready + [self setState:MXKDataSourceStateReady]; + + } failure:^(NSError *error) { + + MXStrongifyAndReturnIfNil(self); + + MXLogDebug(@"[MXKRoomDataSource][%p] Failed to resetPaginationAroundInitialEventWithLimit", self); + + // Notify the error + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKRoomDataSourceTimelineError + object:self + userInfo:@{ + kMXKRoomDataSourceTimelineErrorErrorKey: error + }]; + }]; + } + } + else + { + MXLogDebug(@"[MXKRoomDataSource][%p] Warning: The user does not know the room %@", self, _roomId); + + // Update here data source state if it is not already ready + [self setState:MXKDataSourceStateFailed]; + } + } + + if (_room && MXSessionStateRunning == self.mxSession.state) + { + // Flair handling: observe the update in the publicised groups by users when the flair is enabled in the room. + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidUpdatePublicisedGroupsForUsersNotification object:self.mxSession]; + [self.room state:^(MXRoomState *roomState) { + if (roomState.relatedGroups.count) + { + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXSessionUpdatePublicisedGroupsForUsers:) name:kMXSessionDidUpdatePublicisedGroupsForUsersNotification object:self.mxSession]; + + // Get a fresh profile for all the related groups. Trigger a table refresh when all requests are done. + __block NSUInteger count = roomState.relatedGroups.count; + for (NSString *groupId in roomState.relatedGroups) + { + MXGroup *group = [self.mxSession groupWithGroupId:groupId]; + if (!group) + { + // Create a group instance for the groups that the current user did not join. + group = [[MXGroup alloc] initWithGroupId:groupId]; + [self->externalRelatedGroups setObject:group forKey:groupId]; + } + + // Refresh the group profile from server. + [self.mxSession updateGroupProfile:group success:^{ + + if (self.delegate && !(--count)) + { + // All the requests have been done. + [self.delegate dataSource:self didCellChange:nil]; + } + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomDataSource][%p] group profile update failed %@", self, groupId); + + if (self.delegate && !(--count)) + { + // All the requests have been done. + [self.delegate dataSource:self didCellChange:nil]; + } + + }]; + } + } + }]; + } + } +} + +- (NSArray *)attachmentsWithThumbnail +{ + NSMutableArray *attachments = [NSMutableArray array]; + + @synchronized(bubbles) + { + for (id bubbleData in bubbles) + { + if (bubbleData.isAttachmentWithThumbnail && bubbleData.attachment.type != MXKAttachmentTypeSticker && !bubbleData.showAntivirusScanStatus) + { + [attachments addObject:bubbleData.attachment]; + } + } + } + + return attachments; +} + +- (NSString *)partialTextMessage +{ + return _room.partialTextMessage; +} + +- (void)setPartialTextMessage:(NSString *)partialTextMessage +{ + _room.partialTextMessage = partialTextMessage; +} + +- (void)refreshEventListeners:(NSArray *)liveEventTypesFilterForMessages +{ + // Remove the existing listeners + if (liveEventsListener) + { + [_timeline removeListener:liveEventsListener]; + [_timeline removeListener:redactionListener]; + [_timeline removeListener:receiptsListener]; + [_timeline removeListener:relatedGroupsListener]; + } + + // Listen to live events only for live timeline + // Events for past timelines come only from pagination request + if (_isLive) + { + // Register a new one with the requested filter + MXWeakify(self); + liveEventsListener = [_timeline listenToEventsOfTypes:liveEventTypesFilterForMessages onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + + MXStrongifyAndReturnIfNil(self); + + if (MXTimelineDirectionForwards == direction) + { + // Check for local echo suppression + MXEvent *localEcho; + if (self.room.outgoingMessages.count && [event.sender isEqualToString:self.mxSession.myUser.userId]) + { + localEcho = [self.room pendingLocalEchoRelatedToEvent:event]; + if (localEcho) + { + // Check whether the local echo has a timestamp (in this case, it is replaced with the actual event). + if (localEcho.originServerTs != kMXUndefinedTimestamp) + { + // Replace the local echo by the true event sent by the homeserver + [self replaceEvent:localEcho withEvent:event]; + } + else + { + // Remove the local echo, and process independently the true event. + [self replaceEvent:localEcho withEvent:nil]; + localEcho = nil; + } + } + } + + if (self.secondaryRoom) + { + [self reloadNotifying:NO]; + } + else if (nil == localEcho) + { + // Process here incoming events, and outgoing events sent from another device. + [self queueEventForProcessing:event withRoomState:roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } + } + }]; + + receiptsListener = [_timeline listenToEventsOfTypes:@[kMXEventTypeStringReceipt] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + + if (MXTimelineDirectionForwards == direction) + { + // Handle this read receipt + [self didReceiveReceiptEvent:event roomState:roomState]; + } + }]; + + // Flair handling: register a listener for the related groups state event in this room. + relatedGroupsListener = [_timeline listenToEventsOfTypes:@[kMXEventTypeStringRoomRelatedGroups] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + + if (MXTimelineDirectionForwards == direction) + { + // The flair settings have been updated: flush the current bubble data and rebuild them. + [self reload]; + } + }]; + } + + // Register a listener to handle redaction which can affect live and past timelines + redactionListener = [_timeline listenToEventsOfTypes:@[kMXEventTypeStringRoomRedaction] onEvent:^(MXEvent *redactionEvent, MXTimelineDirection direction, MXRoomState *roomState) { + + // Consider only live redaction events + if (direction == MXTimelineDirectionForwards) + { + // Do the processing on the processing queue + dispatch_async(MXKRoomDataSource.processingQueue, ^{ + + // Check whether a message contains the redacted event + id bubbleData = [self cellDataOfEventWithEventId:redactionEvent.redacts]; + if (bubbleData) + { + BOOL shouldRemoveBubbleData = NO; + BOOL hasChanged = NO; + MXEvent *redactedEvent = nil; + + @synchronized (bubbleData) + { + // Retrieve the original event to redact it + NSArray *events = bubbleData.events; + + for (MXEvent *event in events) + { + if ([event.eventId isEqualToString:redactionEvent.redacts]) + { + // Check whether the event was not already redacted (Redaction may be handled by event timeline too). + if (!event.isRedactedEvent) + { + redactedEvent = [event prune]; + redactedEvent.redactedBecause = redactionEvent.JSONDictionary; + } + + break; + } + } + + if (redactedEvent) + { + // Update bubble data + NSUInteger remainingEvents = [bubbleData updateEvent:redactionEvent.redacts withEvent:redactedEvent]; + + hasChanged = YES; + + // Remove the bubble if there is no more events + shouldRemoveBubbleData = (remainingEvents == 0); + } + } + + // Check whether the bubble should be removed + if (shouldRemoveBubbleData) + { + [self removeCellData:bubbleData]; + } + + if (hasChanged) + { + // Update the delegate on main thread + dispatch_async(dispatch_get_main_queue(), ^{ + + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + + }); + } + } + + }); + } + }]; +} + +- (void)refreshSecondaryEventListeners:(NSArray *)liveEventTypesFilterForMessages +{ + // Remove the existing listeners + if (secondaryLiveEventsListener) + { + [_secondaryTimeline removeListener:secondaryLiveEventsListener]; + [_secondaryTimeline removeListener:secondaryRedactionListener]; + } + + // Listen to live events only for live timeline + // Events for past timelines come only from pagination request + if (_isLive) + { + // Register a new one with the requested filter + MXWeakify(self); + secondaryLiveEventsListener = [_secondaryTimeline listenToEventsOfTypes:liveEventTypesFilterForMessages onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + + MXStrongifyAndReturnIfNil(self); + + if (MXTimelineDirectionForwards == direction) + { + // Check for local echo suppression + MXEvent *localEcho; + if (self.secondaryRoom.outgoingMessages.count && [event.sender isEqualToString:self.mxSession.myUserId]) + { + localEcho = [self.secondaryRoom pendingLocalEchoRelatedToEvent:event]; + if (localEcho) + { + // Check whether the local echo has a timestamp (in this case, it is replaced with the actual event). + if (localEcho.originServerTs != kMXUndefinedTimestamp) + { + // Replace the local echo by the true event sent by the homeserver + [self replaceEvent:localEcho withEvent:event]; + } + else + { + // Remove the local echo, and process independently the true event. + [self replaceEvent:localEcho withEvent:nil]; + localEcho = nil; + } + } + } + + if (nil == localEcho) + { + // Process here incoming events, and outgoing events sent from another device. + [self queueEventForProcessing:event withRoomState:roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } + } + }]; + + } + + // Register a listener to handle redaction which can affect live and past timelines + secondaryRedactionListener = [_secondaryTimeline listenToEventsOfTypes:@[kMXEventTypeStringRoomRedaction] onEvent:^(MXEvent *redactionEvent, MXTimelineDirection direction, MXRoomState *roomState) { + + // Consider only live redaction events + if (direction == MXTimelineDirectionForwards) + { + // Do the processing on the processing queue + dispatch_async(MXKRoomDataSource.processingQueue, ^{ + + // Check whether a message contains the redacted event + id bubbleData = [self cellDataOfEventWithEventId:redactionEvent.redacts]; + if (bubbleData) + { + BOOL shouldRemoveBubbleData = NO; + BOOL hasChanged = NO; + MXEvent *redactedEvent = nil; + + @synchronized (bubbleData) + { + // Retrieve the original event to redact it + NSArray *events = bubbleData.events; + + for (MXEvent *event in events) + { + if ([event.eventId isEqualToString:redactionEvent.redacts]) + { + // Check whether the event was not already redacted (Redaction may be handled by event timeline too). + if (!event.isRedactedEvent) + { + redactedEvent = [event prune]; + redactedEvent.redactedBecause = redactionEvent.JSONDictionary; + } + + break; + } + } + + if (redactedEvent) + { + // Update bubble data + NSUInteger remainingEvents = [bubbleData updateEvent:redactionEvent.redacts withEvent:redactedEvent]; + + hasChanged = YES; + + // Remove the bubble if there is no more events + shouldRemoveBubbleData = (remainingEvents == 0); + } + } + + // Check whether the bubble should be removed + if (shouldRemoveBubbleData) + { + [self removeCellData:bubbleData]; + } + + if (hasChanged) + { + // Update the delegate on main thread + dispatch_async(dispatch_get_main_queue(), ^{ + + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + + }); + } + } + + }); + } + }]; +} + +- (void)setFilterMessagesWithURL:(BOOL)filterMessagesWithURL +{ + _filterMessagesWithURL = filterMessagesWithURL; + + if (_isLive && _room) + { + // Update the event listeners by considering the right types for the live events. + [self refreshEventListeners:(_filterMessagesWithURL ? @[kMXEventTypeStringRoomMessage] : [MXKAppSettings standardAppSettings].allEventTypesForMessages)]; + } +} + +- (void)setEventFormatter:(MXKEventFormatter *)eventFormatter +{ + if (_eventFormatter) + { + // Remove observers on previous event formatter settings + [_eventFormatter.settings removeObserver:self forKeyPath:@"showRedactionsInRoomHistory"]; + [_eventFormatter.settings removeObserver:self forKeyPath:@"showUnsupportedEventsInRoomHistory"]; + } + + _eventFormatter = eventFormatter; + + if (_eventFormatter) + { + // Add observer to flush stored data on settings changes + [_eventFormatter.settings addObserver:self forKeyPath:@"showRedactionsInRoomHistory" options:0 context:nil]; + [_eventFormatter.settings addObserver:self forKeyPath:@"showUnsupportedEventsInRoomHistory" options:0 context:nil]; + } +} + +- (void)setShowBubblesDateTime:(BOOL)showBubblesDateTime +{ + _showBubblesDateTime = showBubblesDateTime; + + if (self.delegate) + { + // Reload all the table + [self.delegate dataSource:self didCellChange:nil]; + } +} + +- (void)setShowTypingNotifications:(BOOL)shouldShowTypingNotifications +{ + _showTypingNotifications = shouldShowTypingNotifications; + + if (shouldShowTypingNotifications) + { + // Register on typing notif + [self listenTypingNotifications]; + } + else + { + // Remove the live listener + if (typingNotifListener) + { + [_timeline removeListener:typingNotifListener]; + currentTypingUsers = nil; + typingNotifListener = nil; + } + } +} + +- (void)listenTypingNotifications +{ + // Remove the previous live listener + if (typingNotifListener) + { + [_timeline removeListener:typingNotifListener]; + currentTypingUsers = nil; + } + + // Add typing notification listener + MXWeakify(self); + + typingNotifListener = [_timeline listenToEventsOfTypes:@[kMXEventTypeStringTypingNotification] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) + { + MXStrongifyAndReturnIfNil(self); + + // Handle only live events + if (direction == MXTimelineDirectionForwards) + { + // Retrieve typing users list + NSMutableArray *typingUsers = [NSMutableArray arrayWithArray:self.room.typingUsers]; + + // Remove typing info for the current user + NSUInteger index = [typingUsers indexOfObject:self.mxSession.myUser.userId]; + if (index != NSNotFound) + { + [typingUsers removeObjectAtIndex:index]; + } + // Ignore this notification if both arrays are empty + if (self->currentTypingUsers.count || typingUsers.count) + { + self->currentTypingUsers = typingUsers; + + if (self.delegate) + { + // refresh all the table + [self.delegate dataSource:self didCellChange:nil]; + } + } + } + }]; + + currentTypingUsers = _room.typingUsers; +} + +- (void)cancelAllRequests +{ + if (paginationRequest) + { + // We have to remove here the listener. A new pagination request may be triggered whereas the cancellation of this one is in progress + [_timeline removeListener:paginationListener]; + paginationListener = nil; + + [paginationRequest cancel]; + paginationRequest = nil; + } + + [super cancelAllRequests]; +} + +- (void)setDelegate:(id)delegate +{ + super.delegate = delegate; + + // Register to MXScanManager notification only when a delegate is set + if (delegate && self.mxSession.scanManager) + { + [self registerScanManagerNotifications]; + } + + // Register to reaction notification only when a delegate is set + if (delegate) + { + [self registerReactionsChangeListener]; + [self registerEventEditsListener]; + } +} + +- (void)setRoom:(MXRoom *)room +{ + if (![_room isEqual:room]) + { + _room = room; + + [self roomDidSet]; + } +} + +- (void)roomDidSet +{ + +} + +#pragma mark - KVO + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +{ + if ([@"showRedactionsInRoomHistory" isEqualToString:keyPath] || [@"showUnsupportedEventsInRoomHistory" isEqualToString:keyPath]) + { + // Flush the current bubble data and rebuild them + [self reload]; + } +} + +#pragma mark - Public methods +- (id)cellDataAtIndex:(NSInteger)index +{ + id bubbleData; + @synchronized(bubbles) + { + if (index < bubbles.count) + { + bubbleData = bubbles[index]; + } + } + return bubbleData; +} + +- (id)cellDataOfEventWithEventId:(NSString *)eventId +{ + id bubbleData; + @synchronized(eventIdToBubbleMap) + { + bubbleData = eventIdToBubbleMap[eventId]; + } + return bubbleData; +} + +- (NSInteger)indexOfCellDataWithEventId:(NSString *)eventId +{ + NSInteger index = NSNotFound; + + id bubbleData; + @synchronized(eventIdToBubbleMap) + { + bubbleData = eventIdToBubbleMap[eventId]; + } + + if (bubbleData) + { + @synchronized(bubbles) + { + index = [bubbles indexOfObject:bubbleData]; + } + } + + return index; +} + +- (CGFloat)cellHeightAtIndex:(NSInteger)index withMaximumWidth:(CGFloat)maxWidth +{ + id bubbleData = [self cellDataAtIndex:index]; + + // Sanity check + if (bubbleData && self.delegate) + { + // Compute here height of bubble cell + Class cellViewClass = [self.delegate cellViewClassForCellData:bubbleData]; + return [cellViewClass heightForCellData:bubbleData withMaximumWidth:maxWidth]; + } + + return 0; +} + +- (void)invalidateBubblesCellDataCache +{ + @synchronized(bubbles) + { + for (id bubble in bubbles) + { + [bubble invalidateTextLayout]; + } + } +} + +#pragma mark - Pagination +- (void)paginate:(NSUInteger)numItems direction:(MXTimelineDirection)direction onlyFromStore:(BOOL)onlyFromStore success:(void (^)(NSUInteger addedCellNumber))success failure:(void (^)(NSError *error))failure +{ + // Check the current data source state, and the actual user membership for this room. + if (state != MXKDataSourceStateReady || ((self.room.summary.membership == MXMembershipUnknown || self.room.summary.membership == MXMembershipInvite) && ![self.roomState.historyVisibility isEqualToString:kMXRoomHistoryVisibilityWorldReadable])) + { + // Back pagination is not available here. + if (failure) + { + failure(nil); + } + return; + } + + if (paginationRequest || secondaryPaginationRequest) + { + MXLogDebug(@"[MXKRoomDataSource][%p] paginate: a pagination is already in progress", self); + if (failure) + { + failure(nil); + } + return; + } + + if (NO == [self canPaginate:direction]) + { + MXLogDebug(@"[MXKRoomDataSource][%p] paginate: No more events to paginate", self); + if (success) + { + success(0); + } + } + + __block NSUInteger addedCellNb = 0; + __block NSMutableArray *operationErrors = [NSMutableArray arrayWithCapacity:2]; + dispatch_group_t dispatchGroup = dispatch_group_create(); + + // Define a new listener for this pagination + paginationListener = [_timeline listenToEventsOfTypes:(_filterMessagesWithURL ? @[kMXEventTypeStringRoomMessage] : [MXKAppSettings standardAppSettings].allEventTypesForMessages) onEvent:^(MXEvent *event, MXTimelineDirection direction2, MXRoomState *roomState) { + + if (direction2 == direction) + { + [self queueEventForProcessing:event withRoomState:roomState direction:direction]; + } + + }]; + + // Keep a local reference to this listener. + id localPaginationListenerRef = paginationListener; + + dispatch_group_enter(dispatchGroup); + // Launch the pagination + + MXWeakify(self); + paginationRequest = [_timeline paginate:numItems direction:direction onlyFromStore:onlyFromStore complete:^{ + + MXStrongifyAndReturnIfNil(self); + + // Everything went well, remove the listener + self->paginationRequest = nil; + [self.timeline removeListener:self->paginationListener]; + self->paginationListener = nil; + + // Once done, process retrieved events + [self processQueuedEvents:^(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb) { + + addedCellNb += (direction == MXTimelineDirectionBackwards) ? addedHistoryCellNb : addedLiveCellNb; + dispatch_group_leave(dispatchGroup); + + }]; + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomDataSource][%p] paginateBackMessages fails", self); + + MXStrongifyAndReturnIfNil(self); + + // Something wrong happened or the request was cancelled. + // Check whether the request is the actual one before removing listener and handling the retrieved events. + if (localPaginationListenerRef == self->paginationListener) + { + self->paginationRequest = nil; + [self.timeline removeListener:self->paginationListener]; + self->paginationListener = nil; + + // Process at least events retrieved from store + [self processQueuedEvents:^(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb) { + + [operationErrors addObject:error]; + if (addedHistoryCellNb) + { + addedCellNb += addedHistoryCellNb; + } + dispatch_group_leave(dispatchGroup); + + }]; + } + + }]; + + if (_secondaryTimeline) + { + // Define a new listener for this pagination + secondaryPaginationListener = [_secondaryTimeline listenToEventsOfTypes:_secondaryRoomEventTypes onEvent:^(MXEvent *event, MXTimelineDirection direction2, MXRoomState *roomState) { + + if (direction2 == direction) + { + [self queueEventForProcessing:event withRoomState:roomState direction:direction]; + } + + }]; + + // Keep a local reference to this listener. + id localPaginationListenerRef = secondaryPaginationListener; + + dispatch_group_enter(dispatchGroup); + // Launch the pagination + MXWeakify(self); + secondaryPaginationRequest = [_secondaryTimeline paginate:numItems direction:direction onlyFromStore:onlyFromStore complete:^{ + + MXStrongifyAndReturnIfNil(self); + + // Everything went well, remove the listener + self->secondaryPaginationRequest = nil; + [self.secondaryTimeline removeListener:self->secondaryPaginationListener]; + self->secondaryPaginationListener = nil; + + // Once done, process retrieved events + [self processQueuedEvents:^(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb) { + + addedCellNb += (direction == MXTimelineDirectionBackwards) ? addedHistoryCellNb : addedLiveCellNb; + dispatch_group_leave(dispatchGroup); + + }]; + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomDataSource][%p] paginateBackMessages fails", self); + + MXStrongifyAndReturnIfNil(self); + + // Something wrong happened or the request was cancelled. + // Check whether the request is the actual one before removing listener and handling the retrieved events. + if (localPaginationListenerRef == self->secondaryPaginationListener) + { + self->secondaryPaginationRequest = nil; + [self.secondaryTimeline removeListener:self->secondaryPaginationListener]; + self->secondaryPaginationListener = nil; + + // Process at least events retrieved from store + [self processQueuedEvents:^(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb) { + + [operationErrors addObject:error]; + if (addedHistoryCellNb) + { + addedCellNb += addedHistoryCellNb; + } + dispatch_group_leave(dispatchGroup); + + }]; + } + + }]; + } + + dispatch_group_notify(dispatchGroup, dispatch_get_main_queue(), ^{ + if (operationErrors.count) + { + if (failure) + { + failure(operationErrors.firstObject); + } + } + else + { + if (success) + { + success(addedCellNb); + } + } + }); +} + +- (void)paginateToFillRect:(CGRect)rect direction:(MXTimelineDirection)direction withMinRequestMessagesCount:(NSUInteger)minRequestMessagesCount success:(void (^)(void))success failure:(void (^)(NSError *error))failure +{ + MXLogDebug(@"[MXKRoomDataSource][%p] paginateToFillRect: %@", self, NSStringFromCGRect(rect)); + + // During the first call of this method, the delegate is supposed defined. + // This delegate may be removed whereas this method is called by itself after a pagination request. + // The delegate is required here to be able to compute cell height (and prevent infinite loop in case of reentrancy). + if (!self.delegate) + { + MXLogDebug(@"[MXKRoomDataSource][%p] paginateToFillRect ignored (delegate is undefined)", self); + if (failure) + { + failure(nil); + } + return; + } + + // Get the total height of cells already loaded in memory + CGFloat minMessageHeight = CGFLOAT_MAX; + CGFloat bubblesTotalHeight = 0; + + @synchronized(bubbles) + { + // Check whether data has been aldready loaded + if (bubbles.count) + { + NSUInteger eventsCount = 0; + for (NSInteger i = bubbles.count - 1; i >= 0; i--) + { + id bubbleData = bubbles[i]; + eventsCount += bubbleData.events.count; + + CGFloat bubbleHeight = [self cellHeightAtIndex:i withMaximumWidth:rect.size.width]; + // Sanity check + if (bubbleHeight) + { + bubblesTotalHeight += bubbleHeight; + + if (bubblesTotalHeight > rect.size.height) + { + // No need to compute more cells heights, there are enough to fill the rect + MXLogDebug(@"[MXKRoomDataSource][%p] -> %tu already loaded bubbles (%tu events) are enough to fill the screen", self, bubbles.count - i, eventsCount); + break; + } + + // Compute the minimal height an event takes + minMessageHeight = MIN(minMessageHeight, bubbleHeight / bubbleData.events.count); + } + } + } + else if (minRequestMessagesCount && [self canPaginate:direction]) + { + MXLogDebug(@"[MXKRoomDataSource][%p] paginateToFillRect: Prefill with data from the store", self); + // Give a chance to load data from the store before doing homeserver requests + // Reuse minRequestMessagesCount because we need to provide a number. + [self paginate:minRequestMessagesCount direction:direction onlyFromStore:YES success:^(NSUInteger addedCellNumber) { + + // Then retry + [self paginateToFillRect:rect direction:direction withMinRequestMessagesCount:minRequestMessagesCount success:success failure:failure]; + + } failure:failure]; + return; + } + } + + // Is there enough cells to cover all the requested height? + if (bubblesTotalHeight < rect.size.height) + { + // No. Paginate to get more messages + if ([self canPaginate:direction]) + { + // Bound the minimal height to 44 + minMessageHeight = MIN(minMessageHeight, 44); + + // Load messages to cover the remaining height + // Use an extra of 50% to manage unsupported/unexpected/redated events + NSUInteger messagesToLoad = ceil((rect.size.height - bubblesTotalHeight) / minMessageHeight * 1.5); + + // It does not worth to make a pagination request for only 1 message. + // So, use minRequestMessagesCount + messagesToLoad = MAX(messagesToLoad, minRequestMessagesCount); + + MXLogDebug(@"[MXKRoomDataSource][%p] paginateToFillRect: need to paginate %tu events to cover %fpx", self, messagesToLoad, rect.size.height - bubblesTotalHeight); + [self paginate:messagesToLoad direction:direction onlyFromStore:NO success:^(NSUInteger addedCellNumber) { + + [self paginateToFillRect:rect direction:direction withMinRequestMessagesCount:minRequestMessagesCount success:success failure:failure]; + + } failure:failure]; + } + else + { + + MXLogDebug(@"[MXKRoomDataSource][%p] paginateToFillRect: No more events to paginate", self); + if (success) + { + success(); + } + } + } + else + { + // Yes. Nothing to do + if (success) + { + success(); + } + } +} + + +#pragma mark - Sending +- (void)sendTextMessage:(NSString *)text success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + __block MXEvent *localEchoEvent = nil; + + BOOL isEmote = [self isMessageAnEmote:text]; + NSString *sanitizedText = [self sanitizedMessageText:text]; + NSString *html = [self htmlMessageFromSanitizedText:sanitizedText]; + + // Make the request to the homeserver + if (isEmote) + { + [_room sendEmote:sanitizedText formattedText:html localEcho:&localEchoEvent success:success failure:failure]; + } + else + { + [_room sendTextMessage:sanitizedText formattedText:html localEcho:&localEchoEvent success:success failure:failure]; + } + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + +- (void)sendReplyToEventWithId:(NSString*)eventIdToReply + withTextMessage:(NSString *)text + success:(void (^)(NSString *))success + failure:(void (^)(NSError *))failure +{ + MXEvent *eventToReply = [self eventWithEventId:eventIdToReply]; + + __block MXEvent *localEchoEvent = nil; + + NSString *sanitizedText = [self sanitizedMessageText:text]; + NSString *html = [self htmlMessageFromSanitizedText:sanitizedText]; + + id stringLocalizer = [MXKSendReplyEventStringLocalizer new]; + + [_room sendReplyToEvent:eventToReply withTextMessage:sanitizedText formattedTextMessage:html stringLocalizer:stringLocalizer localEcho:&localEchoEvent success:success failure:failure]; + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + +- (BOOL)isMessageAnEmote:(NSString*)text +{ + return [text hasPrefix:emoteMessageSlashCommandPrefix]; +} + +- (NSString*)sanitizedMessageText:(NSString*)rawText +{ + NSString *text; + + //Remove NULL bytes from the string, as they are likely to trip up many things later, + //including our own C-based Markdown-to-HTML convertor. + // + //Normally, we don't expect people to be entering NULL bytes in messages, + //but because of a bug in iOS 11, it's easy to have it happen. + // + //iOS 11's Smart Punctuation feature "conveniently" converts double hyphens (`--`) to longer en-dashes (`—`). + //However, when adding any kind of dash/hyphen after such an en-dash, + //iOS would also insert a NULL byte inbetween the dashes (`NULL`). + // + //Even if a future iOS update fixes this, + //we'd better be defensive and always remove occurrences of NULL bytes from text messages. + text = [rawText stringByReplacingOccurrencesOfString:[NSString stringWithFormat:@"%C", 0x00000000] withString:@""]; + + // Check whether the message is an emote + if ([self isMessageAnEmote:text]) + { + // Remove "/me " string + text = [text substringFromIndex:emoteMessageSlashCommandPrefix.length]; + } + + return text; +} + +- (NSString*)htmlMessageFromSanitizedText:(NSString*)sanitizedText +{ + NSString *html; + + // Did user use Markdown text? + NSString *htmlStringFromMarkdown = [_eventFormatter htmlStringFromMarkdownString:sanitizedText]; + + if ([htmlStringFromMarkdown isEqualToString:sanitizedText]) + { + // No formatted string + html = nil; + } + else + { + html = htmlStringFromMarkdown; + } + + return html; +} + +- (void)sendImage:(UIImage *)image success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + // Make sure the uploaded image orientation is up + image = [MXKTools forceImageOrientationUp:image]; + + // Only jpeg image is supported here + NSString *mimetype = @"image/jpeg"; + NSData *imageData = UIImageJPEGRepresentation(image, 0.9); + + // Shall we need to consider a thumbnail? + UIImage *thumbnail = nil; + if (_room.summary.isEncrypted) + { + // Thumbnail is useful only in case of encrypted room + thumbnail = [MXKTools reduceImage:image toFitInSize:CGSizeMake(800, 600)]; + if (thumbnail == image) + { + thumbnail = nil; + } + } + + [self sendImageData:imageData withImageSize:image.size mimeType:mimetype andThumbnail:thumbnail success:success failure:failure]; +} + +- (BOOL)canReplyToEventWithId:(NSString*)eventIdToReply +{ + MXEvent *eventToReply = [self eventWithEventId:eventIdToReply]; + return [self.room canReplyToEvent:eventToReply]; +} + +- (void)sendImage:(NSData *)imageData mimeType:(NSString *)mimetype success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + UIImage *image = [UIImage imageWithData:imageData]; + + // Shall we need to consider a thumbnail? + UIImage *thumbnail = nil; + if (_room.summary.isEncrypted) + { + // Thumbnail is useful only in case of encrypted room + thumbnail = [MXKTools reduceImage:image toFitInSize:CGSizeMake(800, 600)]; + if (thumbnail == image) + { + thumbnail = nil; + } + } + + [self sendImageData:imageData withImageSize:image.size mimeType:mimetype andThumbnail:thumbnail success:success failure:failure]; +} + +- (void)sendImageData:(NSData*)imageData withImageSize:(CGSize)imageSize mimeType:(NSString*)mimetype andThumbnail:(UIImage*)thumbnail success:(void (^)(NSString *eventId))success failure:(void (^)(NSError *error))failure +{ + __block MXEvent *localEchoEvent = nil; + + [_room sendImage:imageData withImageSize:imageSize mimeType:mimetype andThumbnail:thumbnail localEcho:&localEchoEvent success:success failure:failure]; + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + +- (void)sendVideo:(NSURL *)videoLocalURL withThumbnail:(UIImage *)videoThumbnail success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + AVURLAsset *videoAsset = [AVURLAsset assetWithURL:videoLocalURL]; + [self sendVideoAsset:videoAsset withThumbnail:videoThumbnail success:success failure:failure]; +} + +- (void)sendVideoAsset:(AVAsset *)videoAsset withThumbnail:(UIImage *)videoThumbnail success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + __block MXEvent *localEchoEvent = nil; + + [_room sendVideoAsset:videoAsset withThumbnail:videoThumbnail localEcho:&localEchoEvent success:success failure:failure]; + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + +- (void)sendAudioFile:(NSURL *)audioFileLocalURL mimeType:mimeType success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + __block MXEvent *localEchoEvent = nil; + + [_room sendAudioFile:audioFileLocalURL mimeType:mimeType localEcho:&localEchoEvent success:success failure:failure keepActualFilename:YES]; + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + +- (void)sendVoiceMessage:(NSURL *)audioFileLocalURL + mimeType:mimeType + duration:(NSUInteger)duration + samples:(NSArray *)samples + success:(void (^)(NSString *))success + failure:(void (^)(NSError *))failure +{ + __block MXEvent *localEchoEvent = nil; + + [_room sendVoiceMessage:audioFileLocalURL mimeType:mimeType duration:duration samples:samples localEcho:&localEchoEvent success:success failure:failure keepActualFilename:YES]; + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + + +- (void)sendFile:(NSURL *)fileLocalURL mimeType:(NSString*)mimeType success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + __block MXEvent *localEchoEvent = nil; + + [_room sendFile:fileLocalURL mimeType:mimeType localEcho:&localEchoEvent success:success failure:failure]; + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + +- (void)sendMessageWithContent:(NSDictionary *)msgContent success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + __block MXEvent *localEchoEvent = nil; + + // Make the request to the homeserver + [_room sendMessageWithContent:msgContent localEcho:&localEchoEvent success:success failure:failure]; + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + +- (void)sendEventOfType:(MXEventTypeString)eventTypeString content:(NSDictionary*)msgContent success:(void (^)(NSString *eventId))success failure:(void (^)(NSError *error))failure +{ + __block MXEvent *localEchoEvent = nil; + + // Make the request to the homeserver + [_room sendEventOfType:eventTypeString content:msgContent localEcho:&localEchoEvent success:success failure:failure]; + + if (localEchoEvent) + { + // Make the data source digest this fake local echo message + [self queueEventForProcessing:localEchoEvent withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + } +} + +- (void)resendEventWithEventId:(NSString *)eventId success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure +{ + MXEvent *event = [self eventWithEventId:eventId]; + + // Sanity check + if (!event) + { + return; + } + + MXLogInfo(@"[MXKRoomDataSource][%p] resendEventWithEventId. EventId: %@", self, event.eventId); + + // Check first whether the event is encrypted + if ([event.wireType isEqualToString:kMXEventTypeStringRoomEncrypted]) + { + // We try here to resent an encrypted event + // Note: we keep the existing local echo. + [_room sendEventOfType:kMXEventTypeStringRoomEncrypted content:event.wireContent localEcho:&event success:success failure:failure]; + } + else if ([event.type isEqualToString:kMXEventTypeStringRoomMessage]) + { + // And retry the send the message according to its type + NSString *msgType = event.content[@"msgtype"]; + if ([msgType isEqualToString:kMXMessageTypeText] || [msgType isEqualToString:kMXMessageTypeEmote]) + { + // Resend the Matrix event by reusing the existing echo + [_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure]; + } + else if ([msgType isEqualToString:kMXMessageTypeImage]) + { + // Check whether the sending failed while uploading the data. + // If the content url corresponds to a upload id, the upload was not complete. + NSString *contentURL = event.content[@"url"]; + if (contentURL && [contentURL hasPrefix:kMXMediaUploadIdPrefix]) + { + NSString *mimetype = nil; + if (event.content[@"info"]) + { + mimetype = event.content[@"info"][@"mimetype"]; + } + + NSString *localImagePath = [MXMediaManager cachePathForMatrixContentURI:contentURL andType:mimetype inFolder:_roomId]; + UIImage* image = [MXMediaManager loadPictureFromFilePath:localImagePath]; + if (image) + { + // Restart sending the image from the beginning. + + // Remove the local echo. + [self removeEventWithEventId:eventId]; + + if (mimetype) + { + NSData *imageData = [NSData dataWithContentsOfFile:localImagePath]; + [self sendImage:imageData mimeType:mimetype success:success failure:failure]; + } + else + { + [self sendImage:image success:success failure:failure]; + } + } + else + { + failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendGeneric userInfo:nil]); + MXLogWarning(@"[MXKRoomDataSource][%p] resendEventWithEventId: Warning - Unable to resend room message of type: %@", self, msgType); + } + } + else + { + // Resend the Matrix event by reusing the existing echo + [_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure]; + } + } + else if ([msgType isEqualToString:kMXMessageTypeAudio]) + { + // Check whether the sending failed while uploading the data. + // If the content url corresponds to a upload id, the upload was not complete. + NSString *contentURL = event.content[@"url"]; + if (!contentURL || ![contentURL hasPrefix:kMXMediaUploadIdPrefix]) + { + // Resend the Matrix event by reusing the existing echo + [_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure]; + return; + } + + NSString *mimetype = event.content[@"info"][@"mimetype"]; + NSString *localFilePath = [MXMediaManager cachePathForMatrixContentURI:contentURL andType:mimetype inFolder:_roomId]; + NSURL *localFileURL = [NSURL URLWithString:localFilePath]; + + if (![NSFileManager.defaultManager fileExistsAtPath:localFilePath]) { + failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendInvalidLocalFilePath userInfo:nil]); + MXLogWarning(@"[MXKRoomDataSource][%p] resendEventWithEventId: Warning - Unable to resend voice message, invalid file path.", self); + return; + } + + // Remove the local echo. + [self removeEventWithEventId:eventId]; + + if (event.isVoiceMessage) { + NSNumber *duration = event.content[kMXMessageContentKeyExtensibleAudio][kMXMessageContentKeyExtensibleAudioDuration]; + NSArray *samples = event.content[kMXMessageContentKeyExtensibleAudio][kMXMessageContentKeyExtensibleAudioWaveform]; + + [self sendVoiceMessage:localFileURL mimeType:mimetype duration:duration.doubleValue samples:samples success:success failure:failure]; + } else { + [self sendAudioFile:localFileURL mimeType:mimetype success:success failure:failure]; + } + } + else if ([msgType isEqualToString:kMXMessageTypeVideo]) + { + // Check whether the sending failed while uploading the data. + // If the content url corresponds to a upload id, the upload was not complete. + NSString *contentURL = event.content[@"url"]; + if (contentURL && [contentURL hasPrefix:kMXMediaUploadIdPrefix]) + { + // TODO: Support resend on attached video when upload has been failed. + MXLogDebug(@"[MXKRoomDataSource][%p] resendEventWithEventId: Warning - Unable to resend attached video (upload was not complete)", self); + failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendInvalidMessageType userInfo:nil]); + } + else + { + // Resend the Matrix event by reusing the existing echo + [_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure]; + } + } + else if ([msgType isEqualToString:kMXMessageTypeFile]) + { + // Check whether the sending failed while uploading the data. + // If the content url corresponds to a upload id, the upload was not complete. + NSString *contentURL = event.content[@"url"]; + if (contentURL && [contentURL hasPrefix:kMXMediaUploadIdPrefix]) + { + NSString *mimetype = nil; + if (event.content[@"info"]) + { + mimetype = event.content[@"info"][@"mimetype"]; + } + + if (mimetype) + { + // Restart sending the image from the beginning. + + // Remove the local echo + [self removeEventWithEventId:eventId]; + + NSString *localFilePath = [MXMediaManager cachePathForMatrixContentURI:contentURL andType:mimetype inFolder:_roomId]; + + [self sendFile:[NSURL fileURLWithPath:localFilePath isDirectory:NO] mimeType:mimetype success:success failure:failure]; + } + else + { + failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendGeneric userInfo:nil]); + MXLogWarning(@"[MXKRoomDataSource][%p] resendEventWithEventId: Warning - Unable to resend room message of type: %@", self, msgType); + } + } + else + { + // Resend the Matrix event by reusing the existing echo + [_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure]; + } + } + else + { + failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendInvalidMessageType userInfo:nil]); + MXLogWarning(@"[MXKRoomDataSource][%p] resendEventWithEventId: Warning - Unable to resend room message of type: %@", self, msgType); + } + } + else + { + failure([NSError errorWithDomain:MXKRoomDataSourceErrorDomain code:MXKRoomDataSourceErrorResendInvalidMessageType userInfo:nil]); + MXLogWarning(@"[MXKRoomDataSource][%p] MXKRoomDataSource: Warning - Only resend of MXEventTypeRoomMessage is allowed. Event.type: %@", self, event.type); + } +} + + +#pragma mark - Events management +- (MXEvent *)eventWithEventId:(NSString *)eventId +{ + MXEvent *theEvent; + + // First, retrieve the cell data hosting the event + id bubbleData = [self cellDataOfEventWithEventId:eventId]; + if (bubbleData) + { + // Then look into the events in this cell + for (MXEvent *event in bubbleData.events) + { + if ([event.eventId isEqualToString:eventId]) + { + theEvent = event; + break; + } + } + } + return theEvent; +} + +- (void)removeEventWithEventId:(NSString *)eventId +{ + MXLogVerbose(@"[MXKRoomDataSource][%p] removeEventWithEventId: %@", self, eventId); + + // First, retrieve the cell data hosting the event + id bubbleData = [self cellDataOfEventWithEventId:eventId]; + if (bubbleData) + { + NSUInteger remainingEvents; + @synchronized (bubbleData) + { + remainingEvents = [bubbleData removeEvent:eventId]; + } + + // If there is no more events in the bubble, remove it + if (0 == remainingEvents) + { + [self removeCellData:bubbleData]; + } + + // Remove the event from the outgoing messages storage + [_room removeOutgoingMessage:eventId]; + + // Update the delegate + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + } +} + +- (void)didReceiveReceiptEvent:(MXEvent *)receiptEvent roomState:(MXRoomState *)roomState +{ + // Do the processing on the same processing queue + MXWeakify(self); + dispatch_async(MXKRoomDataSource.processingQueue, ^{ + MXStrongifyAndReturnIfNil(self); + + // Remove the previous displayed read receipt for each user who sent a + // new read receipt. + // To implement it, we need to find the sender id of each new read receipt + // among the read receipts array of all events in all bubbles. + NSArray *readReceiptSenders = receiptEvent.readReceiptSenders; + + @synchronized(self->bubbles) + { + for (MXKRoomBubbleCellData *cellData in self->bubbles) + { + NSMutableDictionary *> *updatedCellDataReadReceipts = [NSMutableDictionary dictionary]; + + for (NSString *eventId in cellData.readReceipts) + { + for (MXReceiptData *receiptData in cellData.readReceipts[eventId]) + { + for (NSString *senderId in readReceiptSenders) + { + if ([receiptData.userId isEqualToString:senderId]) + { + if (!updatedCellDataReadReceipts[eventId]) + { + updatedCellDataReadReceipts[eventId] = cellData.readReceipts[eventId]; + } + + NSPredicate *predicate = [NSPredicate predicateWithFormat:@"userId!=%@", receiptData.userId]; + updatedCellDataReadReceipts[eventId] = [updatedCellDataReadReceipts[eventId] filteredArrayUsingPredicate:predicate]; + break; + } + } + + } + } + + // Flush found changed to the cell data + for (NSString *eventId in updatedCellDataReadReceipts) + { + if (updatedCellDataReadReceipts[eventId].count) + { + [self updateCellData:cellData withReadReceipts:updatedCellDataReadReceipts[eventId] forEventId:eventId]; + } + else + { + [self updateCellData:cellData withReadReceipts:nil forEventId:eventId]; + } + } + } + } + + dispatch_group_t dispatchGroup = dispatch_group_create(); + + // Update cell data we have received a read receipt for + NSArray *readEventIds = receiptEvent.readReceiptEventIds; + for (NSString* eventId in readEventIds) + { + MXKRoomBubbleCellData *cellData = [self cellDataOfEventWithEventId:eventId]; + if (cellData) + { + @synchronized(self->bubbles) + { + dispatch_group_enter(dispatchGroup); + [self addReadReceiptsForEvent:eventId inCellDatas:self->bubbles startingAtCellData:cellData completion:^{ + dispatch_group_leave(dispatchGroup); + }]; + } + } + } + + dispatch_group_notify(dispatchGroup, dispatch_get_main_queue(), ^{ + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + }); + }); +} + +- (void)updateCellData:(MXKRoomBubbleCellData*)cellData withReadReceipts:(NSArray*)readReceipts forEventId:(NSString*)eventId +{ + cellData.readReceipts[eventId] = readReceipts; + + // Indicate that the text message layout should be recomputed. + [cellData invalidateTextLayout]; +} + +- (void)handleUnsentMessages +{ + // Add the unsent messages at the end of the conversation + NSArray* outgoingMessages = _room.outgoingMessages; + + [self.mxSession decryptEvents:outgoingMessages inTimeline:nil onComplete:^(NSArray *failedEvents) { + + for (MXEvent *outgoingMessage in outgoingMessages) + { + [self queueEventForProcessing:outgoingMessage withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + } + + MXLogVerbose(@"[MXKRoomDataSource][%p] handleUnsentMessages: queued %tu events", self, outgoingMessages.count); + + [self processQueuedEvents:nil]; + }]; +} + +#pragma mark - Bubble collapsing + +- (void)collapseRoomBubble:(id)bubbleData collapsed:(BOOL)collapsed +{ + if (bubbleData.collapsed != collapsed) + { + id nextBubbleData = bubbleData; + do + { + nextBubbleData.collapsed = collapsed; + } + while ((nextBubbleData = nextBubbleData.nextCollapsableCellData)); + + if (self.delegate) + { + // Reload all the table + [self.delegate dataSource:self didCellChange:nil]; + } + } +} + +#pragma mark - Private methods + +- (void)replaceEvent:(MXEvent*)eventToReplace withEvent:(MXEvent*)event +{ + MXLogVerbose(@"[MXKRoomDataSource][%p] replaceEvent: %@ with: %@", self, eventToReplace.eventId, event.eventId); + + if (eventToReplace.isLocalEvent) + { + // Stop listening to the identifier change for the replaced event. + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeIdentifierNotification object:eventToReplace]; + } + + // Retrieve the cell data hosting the replaced event + id bubbleData = [self cellDataOfEventWithEventId:eventToReplace.eventId]; + if (!bubbleData) + { + return; + } + + NSUInteger remainingEvents; + @synchronized (bubbleData) + { + // Check whether the local echo is replaced or removed + if (event) + { + remainingEvents = [bubbleData updateEvent:eventToReplace.eventId withEvent:event]; + } + else + { + remainingEvents = [bubbleData removeEvent:eventToReplace.eventId]; + } + } + + // Update bubbles mapping + @synchronized (eventIdToBubbleMap) + { + // Remove the broken link from the map + [eventIdToBubbleMap removeObjectForKey:eventToReplace.eventId]; + + if (event && remainingEvents) + { + eventIdToBubbleMap[event.eventId] = bubbleData; + + if (event.isLocalEvent) + { + // Listen to the identifier change for the local events. + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(localEventDidChangeIdentifier:) name:kMXEventDidChangeIdentifierNotification object:event]; + } + } + } + + // If there is no more events in the bubble, remove it + if (0 == remainingEvents) + { + [self removeCellData:bubbleData]; + } + + // Update the delegate + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } +} + +- (NSArray *)removeCellData:(id)cellData +{ + NSMutableArray *deletedRows = [NSMutableArray array]; + + MXLogVerbose(@"[MXKRoomDataSource][%p] removeCellData: %@", self, [cellData.events valueForKey:@"eventId"]); + + // Remove potential occurrences in bubble map + @synchronized (eventIdToBubbleMap) + { + for (MXEvent *event in cellData.events) + { + [eventIdToBubbleMap removeObjectForKey:event.eventId]; + + if (event.isLocalEvent) + { + // Stop listening to the identifier change for this event. + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeIdentifierNotification object:event]; + } + } + } + + // Check whether the adjacent bubbles can merge together + @synchronized(bubbles) + { + NSUInteger index = [bubbles indexOfObject:cellData]; + if (index != NSNotFound) + { + [bubbles removeObjectAtIndex:index]; + [deletedRows addObject:[NSIndexPath indexPathForRow:index inSection:0]]; + + if (bubbles.count) + { + // Update flag in remaining data + if (index == 0) + { + // We removed here the first bubble. + // We have to update the 'isPaginationFirstBubble' and 'shouldHideSenderInformation' flags of the new first bubble. + id firstCellData = bubbles.firstObject; + + firstCellData.isPaginationFirstBubble = ((self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay) && firstCellData.date); + + // Keep visible the sender information by default, + // except if the bubble has no display (composed only by ignored events). + firstCellData.shouldHideSenderInformation = firstCellData.hasNoDisplay; + } + else if (index < bubbles.count) + { + // We removed here a bubble which is not the before last. + id cellData1 = bubbles[index-1]; + id cellData2 = bubbles[index]; + + // Check first whether the neighbor bubbles can merge + Class class = [self cellDataClassForCellIdentifier:kMXKRoomBubbleCellDataIdentifier]; + if ([class instancesRespondToSelector:@selector(mergeWithBubbleCellData:)]) + { + if ([cellData1 mergeWithBubbleCellData:cellData2]) + { + [bubbles removeObjectAtIndex:index]; + [deletedRows addObject:[NSIndexPath indexPathForRow:(index + 1) inSection:0]]; + + cellData2 = nil; + } + } + + if (cellData2) + { + // Update its 'isPaginationFirstBubble' and 'shouldHideSenderInformation' flags + + // Pagination handling + if (self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay && !cellData2.isPaginationFirstBubble) + { + // Check whether a new pagination starts on the second cellData + NSString *cellData1DateString = [self.eventFormatter dateStringFromDate:cellData1.date withTime:NO]; + NSString *cellData2DateString = [self.eventFormatter dateStringFromDate:cellData2.date withTime:NO]; + + if (!cellData1DateString) + { + cellData2.isPaginationFirstBubble = (cellData2DateString && cellData.isPaginationFirstBubble); + } + else + { + cellData2.isPaginationFirstBubble = (cellData2DateString && ![cellData2DateString isEqualToString:cellData1DateString]); + } + } + + // Check whether the sender information is relevant for this bubble. + // Check first if the bubble is not composed only by ignored events. + cellData2.shouldHideSenderInformation = cellData2.hasNoDisplay; + if (!cellData2.shouldHideSenderInformation && cellData2.isPaginationFirstBubble == NO) + { + // Check whether the neighbor bubbles have been sent by the same user. + cellData2.shouldHideSenderInformation = [cellData2 hasSameSenderAsBubbleCellData:cellData1]; + } + } + + } + } + } + } + + return deletedRows; +} + +- (void)didMXRoomInitialSynced:(NSNotification *)notif +{ + // Refresh the room data source when the room has been initialSync'ed + MXRoom *room = notif.object; + if (self.mxSession == room.mxSession && + ([self.roomId isEqualToString:room.roomId] || [self.secondaryRoomId isEqualToString:room.roomId])) + { + MXLogDebug(@"[MXKRoomDataSource][%p] didMXRoomInitialSynced for room: %@", self, room.roomId); + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXRoomInitialSyncNotification object:room]; + + [self reload]; + } +} + +- (void)didMXSessionUpdatePublicisedGroupsForUsers:(NSNotification *)notif +{ + // Retrieved the list of the concerned users + NSArray *userIds = notif.userInfo[kMXSessionNotificationUserIdsArrayKey]; + if (userIds.count) + { + // Check whether at least one listed user is a room member. + for (NSString* userId in userIds) + { + MXRoomMember * roomMember = [self.roomState.members memberWithUserId:userId]; + if (roomMember) + { + // Inform the delegate to refresh the bubble display + // We dispatch here this action in order to let each bubble data update their sender flair. + if (self.delegate) + { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.delegate dataSource:self didCellChange:nil]; + }); + } + break; + } + } + } +} + +- (void)eventDidChangeSentState:(NSNotification *)notif +{ + MXEvent *event = notif.object; + if ([event.roomId isEqualToString:_roomId]) + { + MXLogVerbose(@"[MXKRoomDataSource][%p] eventDidChangeSentState: %@, to: %tu", self, event.eventId, event.sentState); + + // Retrieve the cell data hosting the local echo + id bubbleData = [self cellDataOfEventWithEventId:event.eventId]; + if (!bubbleData) + { + // Initial state for local echos + BOOL isInitial = event.isLocalEvent && + (event.sentState == MXEventSentStateSending || event.sentState == MXEventSentStateEncrypting); + if (!isInitial) + { + MXLogWarning(@"[MXKRoomDataSource][%p] eventDidChangeSentState: Cannot find bubble data for event: %@", self, event.eventId); + } + return; + } + + @synchronized (bubbleData) + { + [bubbleData updateEvent:event.eventId withEvent:event]; + } + + // Inform the delegate + if (self.delegate && (self.secondaryRoom ? bubbles.count > 0 : YES)) + { + [self.delegate dataSource:self didCellChange:nil]; + } + } +} + +- (void)localEventDidChangeIdentifier:(NSNotification *)notif +{ + MXEvent *event = notif.object; + NSString *previousId = notif.userInfo[kMXEventIdentifierKey]; + + MXLogVerbose(@"[MXKRoomDataSource][%p] localEventDidChangeIdentifier from: %@ to: %@", self, previousId, event.eventId); + + if (event && previousId) + { + // Update bubbles mapping + @synchronized (eventIdToBubbleMap) + { + id bubbleData = eventIdToBubbleMap[previousId]; + if (bubbleData && event.eventId) + { + eventIdToBubbleMap[event.eventId] = bubbleData; + [eventIdToBubbleMap removeObjectForKey:previousId]; + + // The bubble data must use the final event id too + [bubbleData updateEvent:previousId withEvent:event]; + } + } + + if (!event.isLocalEvent) + { + // Stop listening to the identifier change when the event becomes an actual event. + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXEventDidChangeIdentifierNotification object:event]; + } + } +} + +- (void)eventDidDecrypt:(NSNotification *)notif +{ + MXEvent *event = notif.object; + if ([event.roomId isEqualToString:_roomId] || + ([event.roomId isEqualToString:_secondaryRoomId] && [_secondaryRoomEventTypes containsObject:event.type])) + { + // Retrieve the cell data hosting the event + id bubbleData = [self cellDataOfEventWithEventId:event.eventId]; + if (!bubbleData) + { + return; + } + + // We need to update the data of the cell that displays the event. + // The trickiest update is when the cell contains several events and the event + // to update turns out to be an attachment. + // In this case, we need to split the cell into several cells so that the attachment + // has its own cell. + if (bubbleData.events.count == 1 || ![_eventFormatter isSupportedAttachment:event]) + { + // If the event is still a text, a simple update is enough + // If the event is an attachment, it has already its own cell. Let the bubble + // data handle the type change. + @synchronized (bubbleData) + { + [bubbleData updateEvent:event.eventId withEvent:event]; + } + } + else + { + @synchronized (bubbleData) + { + BOOL eventIsFirstInBubble = NO; + NSInteger bubbleDataIndex = [bubbles indexOfObject:bubbleData]; + + if (NSNotFound == bubbleDataIndex) + { + // If bubbleData is not in bubbles there is nothing to update for this event, its not displayed. + return; + } + + // We need to create a dedicated cell for the event attachment. + // From the current bubble, remove the updated event and all events after. + NSMutableArray *removedEvents; + NSUInteger remainingEvents = [bubbleData removeEventsFromEvent:event.eventId removedEvents:&removedEvents]; + + // If there is no more events in this bubble, remove it + if (0 == remainingEvents) + { + eventIsFirstInBubble = YES; + @synchronized (eventsToProcessSnapshot) + { + [bubbles removeObjectAtIndex:bubbleDataIndex]; + bubbleDataIndex--; + } + } + + // Create a dedicated bubble for the attachment + if (removedEvents.count) + { + Class class = [self cellDataClassForCellIdentifier:kMXKRoomBubbleCellDataIdentifier]; + + id newBubbleData = [[class alloc] initWithEvent:removedEvents[0] andRoomState:self.roomState andRoomDataSource:self]; + + if (eventIsFirstInBubble) + { + // Apply same config as before + newBubbleData.isPaginationFirstBubble = bubbleData.isPaginationFirstBubble; + newBubbleData.shouldHideSenderInformation = bubbleData.shouldHideSenderInformation; + } + else + { + // This new bubble is not the first. Show nothing + newBubbleData.isPaginationFirstBubble = NO; + newBubbleData.shouldHideSenderInformation = YES; + } + + // Update bubbles mapping + @synchronized (eventIdToBubbleMap) + { + eventIdToBubbleMap[event.eventId] = newBubbleData; + } + + @synchronized (eventsToProcessSnapshot) + { + [bubbles insertObject:newBubbleData atIndex:bubbleDataIndex + 1]; + } + } + + // And put other cutted events in another bubble + if (removedEvents.count > 1) + { + Class class = [self cellDataClassForCellIdentifier:kMXKRoomBubbleCellDataIdentifier]; + + id newBubbleData; + for (NSUInteger i = 1; i < removedEvents.count; i++) + { + MXEvent *removedEvent = removedEvents[i]; + if (i == 1) + { + newBubbleData = [[class alloc] initWithEvent:removedEvent andRoomState:self.roomState andRoomDataSource:self]; + } + else + { + [newBubbleData addEvent:removedEvent andRoomState:self.roomState]; + } + + // Update bubbles mapping + @synchronized (eventIdToBubbleMap) + { + eventIdToBubbleMap[removedEvent.eventId] = newBubbleData; + } + } + + // Do not show the + newBubbleData.isPaginationFirstBubble = NO; + newBubbleData.shouldHideSenderInformation = YES; + + @synchronized (eventsToProcessSnapshot) + { + [bubbles insertObject:newBubbleData atIndex:bubbleDataIndex + 2]; + } + } + } + } + + // Update the delegate + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + } +} + +// Indicates whether an event has base requirements to allow actions (like reply, reactions, edit, etc.) +- (BOOL)canPerformActionOnEvent:(MXEvent*)event +{ + BOOL isSent = event.sentState == MXEventSentStateSent; + BOOL isRoomMessage = event.eventType == MXEventTypeRoomMessage; + + NSString *messageType = event.content[@"msgtype"]; + + return isSent && isRoomMessage && messageType && ![messageType isEqualToString:@"m.bad.encrypted"]; +} + +- (void)setState:(MXKDataSourceState)newState +{ + self->state = newState; + + if (self.delegate && [self.delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [self.delegate dataSource:self didStateChange:self->state]; + } +} + +- (void)setSecondaryRoomId:(NSString *)secondaryRoomId +{ + if (_secondaryRoomId != secondaryRoomId) + { + _secondaryRoomId = secondaryRoomId; + + if (self.state == MXKDataSourceStateReady) + { + [self reload]; + } + } +} + +- (void)setSecondaryRoomEventTypes:(NSArray *)secondaryRoomEventTypes +{ + if (_secondaryRoomEventTypes != secondaryRoomEventTypes) + { + _secondaryRoomEventTypes = secondaryRoomEventTypes; + + if (self.state == MXKDataSourceStateReady) + { + [self reload]; + } + } +} + +#pragma mark - Asynchronous events processing + + (dispatch_queue_t)processingQueue +{ + static dispatch_queue_t processingQueue; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + processingQueue = dispatch_queue_create("MXKRoomDataSource", DISPATCH_QUEUE_SERIAL); + }); + + return processingQueue; +} + +/** + Queue an event in order to process its display later. + + @param event the event to process. + @param roomState the state of the room when the event fired. + @param direction the order of the events in the arrays + */ +- (void)queueEventForProcessing:(MXEvent*)event withRoomState:(MXRoomState*)roomState direction:(MXTimelineDirection)direction +{ + if (event.isLocalEvent) + { + MXLogVerbose(@"[MXKRoomDataSource][%p] queueEventForProcessing: %@", self, event.eventId); + } + + if (self.filterMessagesWithURL) + { + // Check whether the event has a value for the 'url' key in its content. + if (!event.getMediaURLs.count) + { + // Ignore the event + return; + } + } + + // Check for undecryptable messages that were sent while the user was not in the room and hide them + if ([MXKAppSettings standardAppSettings].hidePreJoinedUndecryptableEvents + && direction == MXTimelineDirectionBackwards) + { + [self checkForPreJoinUTDWithEvent:event roomState:roomState]; + + // Hide pre joint UTD events + if (self.shouldStopBackPagination) + { + return; + } + } + + MXKQueuedEvent *queuedEvent = [[MXKQueuedEvent alloc] initWithEvent:event andRoomState:roomState direction:direction]; + + // Count queued events when the server sync is in progress + if (self.mxSession.state == MXSessionStateSyncInProgress) + { + queuedEvent.serverSyncEvent = YES; + _serverSyncEventCount++; + + if (_serverSyncEventCount == 1) + { + // Notify that sync process starts + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKRoomDataSourceSyncStatusChanged object:self userInfo:nil]; + } + } + + @synchronized(eventsToProcess) + { + [eventsToProcess addObject:queuedEvent]; + + if (self.secondaryRoom) + { + // use a stable sorting here, which means it won't change the order of events unless it has to. + [eventsToProcess sortWithOptions:NSSortStable + usingComparator:^NSComparisonResult(MXKQueuedEvent * _Nonnull event1, MXKQueuedEvent * _Nonnull event2) { + return [event2.eventDate compare:event1.eventDate]; + }]; + } + } +} + +- (BOOL)canPaginate:(MXTimelineDirection)direction +{ + if (_secondaryTimeline) + { + if (![_timeline canPaginate:direction] && ![_secondaryTimeline canPaginate:direction]) + { + return NO; + } + } + else + { + if (![_timeline canPaginate:direction]) + { + return NO; + } + } + + if (direction == MXTimelineDirectionBackwards && self.shouldStopBackPagination) + { + return NO; + } + + return YES; +} + +// Check for undecryptable messages that were sent while the user was not in the room. +- (void)checkForPreJoinUTDWithEvent:(MXEvent*)event roomState:(MXRoomState*)roomState +{ + // Only check for encrypted rooms + if (!self.room.summary.isEncrypted) + { + return; + } + + // Back pagination is stopped do not check for other pre join events + if (self.shouldStopBackPagination) + { + return; + } + + // if we reach a UTD and flag is set, hide previous encrypted messages and stop back-paginating + if (event.eventType == MXEventTypeRoomEncrypted + && [event.decryptionError.domain isEqualToString:MXDecryptingErrorDomain] + && self.shouldPreventBackPaginationOnPreviousUTDEvent) + { + self.shouldStopBackPagination = YES; + return; + } + + self.shouldStopBackPagination = NO; + + if (event.eventType != MXEventTypeRoomMember) + { + return; + } + + NSString *userId = event.stateKey; + + // Only check "m.room.member" event for current user + if (![userId isEqualToString:self.mxSession.myUserId]) + { + return; + } + + BOOL shouldPreventBackPaginationOnPreviousUTDEvent = NO; + + MXRoomMember *member = [roomState.members memberWithUserId:userId]; + + if (member) + { + switch (member.membership) { + case MXMembershipJoin: + { + // if we reach a join event for the user: + // - if prev-content is invite, continue back-paginating + // - if prev-content is join (was just an avatar or displayname change), continue back-paginating + // - otherwise, set a flag and continue back-paginating + + NSString *previousMemberhsip = event.prevContent[@"membership"]; + + BOOL isPrevContentAnInvite = [previousMemberhsip isEqualToString:@"invite"]; + BOOL isPrevContentAJoin = [previousMemberhsip isEqualToString:@"join"]; + + if (!(isPrevContentAnInvite || isPrevContentAJoin)) + { + shouldPreventBackPaginationOnPreviousUTDEvent = YES; + } + } + break; + case MXMembershipInvite: + // if we reach an invite event for the user, set flag and continue back-paginating + shouldPreventBackPaginationOnPreviousUTDEvent = YES; + break; + default: + break; + } + } + + self.shouldPreventBackPaginationOnPreviousUTDEvent = shouldPreventBackPaginationOnPreviousUTDEvent; +} + +- (BOOL)checkBing:(MXEvent*)event +{ + BOOL isHighlighted = NO; + + // read receipts have no rule + if (![event.type isEqualToString:kMXEventTypeStringReceipt]) { + // Check if we should bing this event + MXPushRule *rule = [self.mxSession.notificationCenter ruleMatchingEvent:event roomState:self.roomState]; + if (rule) + { + // Check whether is there an highlight tweak on it + for (MXPushRuleAction *ruleAction in rule.actions) + { + if (ruleAction.actionType == MXPushRuleActionTypeSetTweak) + { + if ([ruleAction.parameters[@"set_tweak"] isEqualToString:@"highlight"]) + { + // Check the highlight tweak "value" + // If not present, highlight. Else check its value before highlighting + if (nil == ruleAction.parameters[@"value"] || YES == [ruleAction.parameters[@"value"] boolValue]) + { + isHighlighted = YES; + break; + } + } + } + } + } + } + + event.mxkIsHighlighted = isHighlighted; + return isHighlighted; +} + +/** + Start processing pending events. + + @param onComplete a block called (on the main thread) when the processing has been done. Can be nil. + Note this block returns the number of added cells in first and last positions. + */ +- (void)processQueuedEvents:(void (^)(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb))onComplete +{ + MXWeakify(self); + + // Do the processing on the processing queue + dispatch_async(MXKRoomDataSource.processingQueue, ^{ + + MXStrongifyAndReturnIfNil(self); + + // Note: As this block is always called from the same processing queue, + // only one batch process is done at a time. Thus, an event cannot be + // processed twice + + // Snapshot queued events to avoid too long lock. + @synchronized(self->eventsToProcess) + { + if (self->eventsToProcess.count) + { + self->eventsToProcessSnapshot = self->eventsToProcess; + if (self.secondaryRoom) + { + @synchronized(self->bubbles) + { + [self->bubblesSnapshot removeAllObjects]; + } + } + else + { + self->eventsToProcess = [NSMutableArray array]; + } + } + } + + NSUInteger serverSyncEventCount = 0; + NSUInteger addedHistoryCellCount = 0; + NSUInteger addedLiveCellCount = 0; + + dispatch_group_t dispatchGroup = dispatch_group_create(); + + // Lock on `eventsToProcessSnapshot` to suspend reload or destroy during the process. + @synchronized(self->eventsToProcessSnapshot) + { + // Is there events to process? + // The list can be empty because several calls of processQueuedEvents may be processed + // in one pass in the processingQueue + if (self->eventsToProcessSnapshot.count) + { + // Make a quick copy of changing data to avoid to lock it too long time + @synchronized(self->bubbles) + { + self->bubblesSnapshot = [self->bubbles mutableCopy]; + } + + NSMutableSet> *collapsingCellDataSeriess = [NSMutableSet set]; + + for (MXKQueuedEvent *queuedEvent in self->eventsToProcessSnapshot) + { + @synchronized (self->eventIdToBubbleMap) + { + // Check whether the event processed before + if (self->eventIdToBubbleMap[queuedEvent.event.eventId]) + { + MXLogVerbose(@"[MXKRoomDataSource][%p] processQueuedEvents: Skip event: %@, state: %tu", self, queuedEvent.event.eventId, queuedEvent.event.sentState); + continue; + } + } + + @autoreleasepool + { + // Count events received while the server sync was in progress + if (queuedEvent.serverSyncEvent) + { + serverSyncEventCount ++; + } + + // Check whether the event must be highlighted + [self checkBing:queuedEvent.event]; + + // Retrieve the MXKCellData class to manage the data + Class class = [self cellDataClassForCellIdentifier:kMXKRoomBubbleCellDataIdentifier]; + NSAssert([class conformsToProtocol:@protocol(MXKRoomBubbleCellDataStoring)], @"MXKRoomDataSource only manages MXKCellData that conforms to MXKRoomBubbleCellDataStoring protocol"); + + BOOL eventManaged = NO; + BOOL updatedBubbleDataHadNoDisplay = NO; + id bubbleData; + if ([class instancesRespondToSelector:@selector(addEvent:andRoomState:)] && 0 < self->bubblesSnapshot.count) + { + // Try to concatenate the event to the last or the oldest bubble? + if (queuedEvent.direction == MXTimelineDirectionBackwards) + { + bubbleData = self->bubblesSnapshot.firstObject; + } + else + { + bubbleData = self->bubblesSnapshot.lastObject; + } + + @synchronized (bubbleData) + { + updatedBubbleDataHadNoDisplay = bubbleData.hasNoDisplay; + eventManaged = [bubbleData addEvent:queuedEvent.event andRoomState:queuedEvent.state]; + } + } + + if (NO == eventManaged) + { + // The event has not been concatenated to an existing cell, create a new bubble for this event + bubbleData = [[class alloc] initWithEvent:queuedEvent.event andRoomState:queuedEvent.state andRoomDataSource:self]; + if (!bubbleData) + { + // The event is ignored + continue; + } + + // Check cells collapsing + if (bubbleData.hasAttributedTextMessage) + { + if (bubbleData.collapsable) + { + if (queuedEvent.direction == MXTimelineDirectionBackwards) + { + // Try to collapse it with the series at the start of self.bubbles + if (self->collapsableSeriesAtStart && [self->collapsableSeriesAtStart collapseWith:bubbleData]) + { + // bubbleData becomes the oldest cell data of the current series + self->collapsableSeriesAtStart.prevCollapsableCellData = bubbleData; + bubbleData.nextCollapsableCellData = self->collapsableSeriesAtStart; + + // The new cell must have the collapsed state as the series + bubbleData.collapsed = self->collapsableSeriesAtStart.collapsed; + + // Release data of the previous header + self->collapsableSeriesAtStart.collapseState = nil; + self->collapsableSeriesAtStart.collapsedAttributedTextMessage = nil; + [collapsingCellDataSeriess removeObject:self->collapsableSeriesAtStart]; + + // And keep a ref of data for the new start of the series + self->collapsableSeriesAtStart = bubbleData; + self->collapsableSeriesAtStart.collapseState = queuedEvent.state; + [collapsingCellDataSeriess addObject:self->collapsableSeriesAtStart]; + } + else + { + // This is a ending point for a new collapsable series of cells + self->collapsableSeriesAtStart = bubbleData; + self->collapsableSeriesAtStart.collapseState = queuedEvent.state; + [collapsingCellDataSeriess addObject:self->collapsableSeriesAtStart]; + } + } + else + { + // Try to collapse it with the series at the end of self.bubbles + if (self->collapsableSeriesAtEnd && [self->collapsableSeriesAtEnd collapseWith:bubbleData]) + { + // Put bubbleData at the series tail + // Find the tail + id tailBubbleData = self->collapsableSeriesAtEnd; + while (tailBubbleData.nextCollapsableCellData) + { + tailBubbleData = tailBubbleData.nextCollapsableCellData; + } + + tailBubbleData.nextCollapsableCellData = bubbleData; + bubbleData.prevCollapsableCellData = tailBubbleData; + + // The new cell must have the collapsed state as the series + bubbleData.collapsed = tailBubbleData.collapsed; + + // If the start of the collapsible series stems from an event in a different processing + // batch, we need to track it here so that we can update the summary string later + if (![collapsingCellDataSeriess containsObject:self->collapsableSeriesAtEnd]) { + [collapsingCellDataSeriess addObject:self->collapsableSeriesAtEnd]; + } + } + else + { + // This is a starting point for a new collapsable series of cells + self->collapsableSeriesAtEnd = bubbleData; + self->collapsableSeriesAtEnd.collapseState = queuedEvent.state; + [collapsingCellDataSeriess addObject:self->collapsableSeriesAtEnd]; + } + } + } + else + { + // The new bubble is not collapsable. + // We can close one border of the current series being built (if any) + if (queuedEvent.direction == MXTimelineDirectionBackwards && self->collapsableSeriesAtStart) + { + // This is the begin border of the series + self->collapsableSeriesAtStart = nil; + } + else if (queuedEvent.direction == MXTimelineDirectionForwards && self->collapsableSeriesAtEnd) + { + // This is the end border of the series + self->collapsableSeriesAtEnd = nil; + } + } + } + + if (queuedEvent.direction == MXTimelineDirectionBackwards) + { + // The new bubble data will be inserted at first position. + // We have to update the 'isPaginationFirstBubble' and 'shouldHideSenderInformation' flags of the current first bubble. + + // Pagination handling + if ((self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay) && bubbleData.date) + { + // A new pagination starts with this new bubble data + bubbleData.isPaginationFirstBubble = YES; + + // Check whether the current first displayed pagination title is still relevant. + if (self->bubblesSnapshot.count) + { + NSInteger index = 0; + id previousFirstBubbleDataWithDate; + NSString *firstBubbleDateString; + while (index < self->bubblesSnapshot.count) + { + previousFirstBubbleDataWithDate = self->bubblesSnapshot[index++]; + firstBubbleDateString = [self.eventFormatter dateStringFromDate:previousFirstBubbleDataWithDate.date withTime:NO]; + + if (firstBubbleDateString) + { + break; + } + } + + if (firstBubbleDateString) + { + NSString *bubbleDateString = [self.eventFormatter dateStringFromDate:bubbleData.date withTime:NO]; + previousFirstBubbleDataWithDate.isPaginationFirstBubble = (bubbleDateString && ![firstBubbleDateString isEqualToString:bubbleDateString]); + } + } + } + else + { + bubbleData.isPaginationFirstBubble = NO; + } + + // Sender information are required for this new first bubble data, + // except if the bubble has no display (composed only by ignored events). + bubbleData.shouldHideSenderInformation = bubbleData.hasNoDisplay; + + // Check whether this information is relevant for the current first bubble. + if (!bubbleData.shouldHideSenderInformation && self->bubblesSnapshot.count) + { + id previousFirstBubbleData = self->bubblesSnapshot.firstObject; + + if (previousFirstBubbleData.isPaginationFirstBubble == NO) + { + // Check whether the current first bubble has been sent by the same user. + previousFirstBubbleData.shouldHideSenderInformation |= [previousFirstBubbleData hasSameSenderAsBubbleCellData:bubbleData]; + } + } + + // Insert the new bubble data in first position + [self->bubblesSnapshot insertObject:bubbleData atIndex:0]; + + addedHistoryCellCount++; + } + else + { + // The new bubble data will be added at the last position + // We have to update its 'isPaginationFirstBubble' and 'shouldHideSenderInformation' flags according to the previous last bubble. + + // Pagination handling + if (self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay) + { + // Check whether a new pagination starts at this bubble + NSString *bubbleDateString = [self.eventFormatter dateStringFromDate:bubbleData.date withTime:NO]; + + // Look for the current last bubble with date + NSInteger index = self->bubblesSnapshot.count; + NSString *lastBubbleDateString; + while (index--) + { + id previousLastBubbleData = self->bubblesSnapshot[index]; + lastBubbleDateString = [self.eventFormatter dateStringFromDate:previousLastBubbleData.date withTime:NO]; + + if (lastBubbleDateString) + { + break; + } + } + + if (lastBubbleDateString) + { + bubbleData.isPaginationFirstBubble = (bubbleDateString && ![bubbleDateString isEqualToString:lastBubbleDateString]); + } + else + { + bubbleData.isPaginationFirstBubble = (bubbleDateString != nil); + } + } + else + { + bubbleData.isPaginationFirstBubble = NO; + } + + // Check whether the sender information is relevant for this new bubble. + bubbleData.shouldHideSenderInformation = bubbleData.hasNoDisplay; + if (!bubbleData.shouldHideSenderInformation && self->bubblesSnapshot.count && (bubbleData.isPaginationFirstBubble == NO)) + { + // Check whether the previous bubble has been sent by the same user. + id previousLastBubbleData = self->bubblesSnapshot.lastObject; + bubbleData.shouldHideSenderInformation = [bubbleData hasSameSenderAsBubbleCellData:previousLastBubbleData]; + } + + // Insert the new bubble in last position + [self->bubblesSnapshot addObject:bubbleData]; + + addedLiveCellCount++; + } + } + else if (updatedBubbleDataHadNoDisplay && !bubbleData.hasNoDisplay) + { + // Here the event has been added in an existing bubble data which had no display, + // and the added event provides a display to this bubble data. + if (queuedEvent.direction == MXTimelineDirectionBackwards) + { + // The bubble is the first one. + + // Pagination handling + if ((self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay) && bubbleData.date) + { + // A new pagination starts with this bubble data + bubbleData.isPaginationFirstBubble = YES; + + // Look for the first next bubble with date to check whether its pagination title is still relevant. + if (self->bubblesSnapshot.count) + { + NSInteger index = 1; + id nextBubbleDataWithDate; + NSString *firstNextBubbleDateString; + while (index < self->bubblesSnapshot.count) + { + nextBubbleDataWithDate = self->bubblesSnapshot[index++]; + firstNextBubbleDateString = [self.eventFormatter dateStringFromDate:nextBubbleDataWithDate.date withTime:NO]; + + if (firstNextBubbleDateString) + { + break; + } + } + + if (firstNextBubbleDateString) + { + NSString *bubbleDateString = [self.eventFormatter dateStringFromDate:bubbleData.date withTime:NO]; + nextBubbleDataWithDate.isPaginationFirstBubble = (bubbleDateString && ![firstNextBubbleDateString isEqualToString:bubbleDateString]); + } + } + } + else + { + bubbleData.isPaginationFirstBubble = NO; + } + + // Sender information are required for this new first bubble data + bubbleData.shouldHideSenderInformation = NO; + + // Check whether this information is still relevant for the next bubble. + if (self->bubblesSnapshot.count > 1) + { + id nextBubbleData = self->bubblesSnapshot[1]; + + if (nextBubbleData.isPaginationFirstBubble == NO) + { + // Check whether the current first bubble has been sent by the same user. + nextBubbleData.shouldHideSenderInformation |= [nextBubbleData hasSameSenderAsBubbleCellData:bubbleData]; + } + } + } + else + { + // The bubble data is the last one + + // Pagination handling + if (self.bubblesPagination == MXKRoomDataSourceBubblesPaginationPerDay) + { + // Check whether a new pagination starts at this bubble + NSString *bubbleDateString = [self.eventFormatter dateStringFromDate:bubbleData.date withTime:NO]; + + // Look for the first previous bubble with date + NSInteger index = self->bubblesSnapshot.count - 1; + NSString *firstPreviousBubbleDateString; + while (index--) + { + id previousBubbleData = self->bubblesSnapshot[index]; + firstPreviousBubbleDateString = [self.eventFormatter dateStringFromDate:previousBubbleData.date withTime:NO]; + + if (firstPreviousBubbleDateString) + { + break; + } + } + + if (firstPreviousBubbleDateString) + { + bubbleData.isPaginationFirstBubble = (bubbleDateString && ![bubbleDateString isEqualToString:firstPreviousBubbleDateString]); + } + else + { + bubbleData.isPaginationFirstBubble = (bubbleDateString != nil); + } + } + else + { + bubbleData.isPaginationFirstBubble = NO; + } + + // Check whether the sender information is relevant for this new bubble. + bubbleData.shouldHideSenderInformation = NO; + if (self->bubblesSnapshot.count && (bubbleData.isPaginationFirstBubble == NO)) + { + // Check whether the previous bubble has been sent by the same user. + NSInteger index = self->bubblesSnapshot.count - 1; + if (index--) + { + id previousBubbleData = self->bubblesSnapshot[index]; + bubbleData.shouldHideSenderInformation = [bubbleData hasSameSenderAsBubbleCellData:previousBubbleData]; + } + } + } + } + + [self updateCellDataReactions:bubbleData forEventId:queuedEvent.event.eventId]; + + // Store event-bubble link to the map + @synchronized (self->eventIdToBubbleMap) + { + self->eventIdToBubbleMap[queuedEvent.event.eventId] = bubbleData; + } + + if (queuedEvent.event.isLocalEvent) + { + // Listen to the identifier change for the local events. + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(localEventDidChangeIdentifier:) name:kMXEventDidChangeIdentifierNotification object:queuedEvent.event]; + } + } + } + + for (MXKQueuedEvent *queuedEvent in self->eventsToProcessSnapshot) + { + @autoreleasepool + { + dispatch_group_enter(dispatchGroup); + [self addReadReceiptsForEvent:queuedEvent.event.eventId inCellDatas:self->bubblesSnapshot startingAtCellData:self->eventIdToBubbleMap[queuedEvent.event.eventId] completion:^{ + dispatch_group_leave(dispatchGroup); + }]; + } + } + + // Check if all cells of self.bubbles belongs to a single collapse series. + // In this case, collapsableSeriesAtStart and collapsableSeriesAtEnd must be equal + // in order to handle next forward or backward pagination. + if (self->collapsableSeriesAtStart && self->collapsableSeriesAtStart == self->bubbles.firstObject) + { + // Find the tail + id tailBubbleData = self->collapsableSeriesAtStart; + while (tailBubbleData.nextCollapsableCellData) + { + tailBubbleData = tailBubbleData.nextCollapsableCellData; + } + + if (tailBubbleData == self->bubbles.lastObject) + { + self->collapsableSeriesAtEnd = self->collapsableSeriesAtStart; + } + } + else if (self->collapsableSeriesAtEnd) + { + // Find the start + id startBubbleData = self->collapsableSeriesAtEnd; + while (startBubbleData.prevCollapsableCellData) + { + startBubbleData = startBubbleData.prevCollapsableCellData; + } + + if (startBubbleData == self->bubbles.firstObject) + { + self->collapsableSeriesAtStart = self->collapsableSeriesAtEnd; + } + } + + // Compose (= compute collapsedAttributedTextMessage) of collapsable seriess + for (id bubbleData in collapsingCellDataSeriess) + { + // Get all events of the series + NSMutableArray *events = [NSMutableArray array]; + id nextBubbleData = bubbleData; + do + { + [events addObjectsFromArray:nextBubbleData.events]; + } + while ((nextBubbleData = nextBubbleData.nextCollapsableCellData)); + + // Build the summary string for the series + bubbleData.collapsedAttributedTextMessage = [self.eventFormatter attributedStringFromEvents:events withRoomState:bubbleData.collapseState error:nil]; + + // Release collapseState objects, even the one of collapsableSeriesAtStart. + // We do not need to keep its state because if an collapsable event comes before collapsableSeriesAtStart, + // we will take the room state of this event. + if (bubbleData != self->collapsableSeriesAtEnd) + { + bubbleData.collapseState = nil; + } + } + } + self->eventsToProcessSnapshot = nil; + } + + // Check whether some events have been processed + if (self->bubblesSnapshot) + { + // Updated data can be displayed now + // Block MXKRoomDataSource.processingQueue while the processing is finalised on the main thread + dispatch_group_wait(dispatchGroup, DISPATCH_TIME_FOREVER); + + dispatch_sync(dispatch_get_main_queue(), ^{ + // Check whether self has not been reloaded or destroyed + if (self.state == MXKDataSourceStateReady && self->bubblesSnapshot) + { + if (self.serverSyncEventCount) + { + self->_serverSyncEventCount -= serverSyncEventCount; + if (!self.serverSyncEventCount) + { + // Notify that sync process ends + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKRoomDataSourceSyncStatusChanged object:self userInfo:nil]; + } + } + if (self.secondaryRoom) { + [self->bubblesSnapshot sortWithOptions:NSSortStable + usingComparator:^NSComparisonResult(MXKRoomBubbleCellData * _Nonnull bubbleData1, MXKRoomBubbleCellData * _Nonnull bubbleData2) { + if (bubbleData1.date) + { + if (bubbleData2.date) + { + return [bubbleData1.date compare:bubbleData2.date]; + } + else + { + return NSOrderedDescending; + } + } + else + { + if (bubbleData2.date) + { + return NSOrderedAscending; + } + else + { + return NSOrderedSame; + } + } + }]; + } + self->bubbles = self->bubblesSnapshot; + self->bubblesSnapshot = nil; + + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + else + { + // Check the memory usage of the data source. Reload it if the cache is too huge. + [self limitMemoryUsage:self.maxBackgroundCachedBubblesCount]; + } + } + + // Inform about the end if requested + if (onComplete) + { + onComplete(addedHistoryCellCount, addedLiveCellCount); + } + }); + } + else + { + // No new event has been added, we just inform about the end if requested. + if (onComplete) + { + dispatch_group_notify(dispatchGroup, dispatch_get_main_queue(), ^{ + onComplete(0, 0); + }); + } + } + }); +} + +/** + Add the read receipts of an event into the timeline (which is in array of cell datas) + + If the event is not displayed, read receipts will be added to a previous displayed message. + + @param eventId the id of the event. + @param cellDatas the working array of cell datas. + @param cellData the original cell data the event belongs to. + */ +- (void)addReadReceiptsForEvent:(NSString*)eventId inCellDatas:(NSArray>*)cellDatas startingAtCellData:(id)cellData completion:(void (^)(void))completion +{ + if (self.showBubbleReceipts) + { + if (self.room) + { + [self.room getEventReceipts:eventId sorted:YES completion:^(NSArray * _Nonnull readReceipts) { + if (readReceipts.count) + { + NSInteger cellDataIndex = [cellDatas indexOfObject:cellData]; + if (cellDataIndex != NSNotFound) + { + [self addReadReceipts:readReceipts forEvent:eventId inCellDatas:cellDatas atCellDataIndex:cellDataIndex]; + } + } + + if (completion) + { + completion(); + } + }]; + } + else if (completion) + { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(); + }); + } + } + else if (completion) + { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(); + }); + } +} + +- (void)addReadReceipts:(NSArray *)readReceipts forEvent:(NSString*)eventId inCellDatas:(NSArray>*)cellDatas atCellDataIndex:(NSInteger)cellDataIndex +{ + id cellData = cellDatas[cellDataIndex]; + + if ([cellData isKindOfClass:MXKRoomBubbleCellData.class]) + { + MXKRoomBubbleCellData *roomBubbleCellData = (MXKRoomBubbleCellData*)cellData; + + BOOL areReadReceiptsAssigned = NO; + for (MXKRoomBubbleComponent *component in roomBubbleCellData.bubbleComponents.reverseObjectEnumerator) + { + if (component.attributedTextMessage) + { + if (roomBubbleCellData.readReceipts[component.event.eventId]) + { + NSArray *currentReadReceipts = roomBubbleCellData.readReceipts[component.event.eventId]; + NSMutableArray *newReadReceipts = [NSMutableArray arrayWithArray:currentReadReceipts]; + for (MXReceiptData *readReceipt in readReceipts) + { + BOOL alreadyHere = NO; + for (MXReceiptData *currentReadReceipt in currentReadReceipts) + { + if ([readReceipt.userId isEqualToString:currentReadReceipt.userId]) + { + alreadyHere = YES; + break; + } + } + + if (!alreadyHere) + { + [newReadReceipts addObject:readReceipt]; + } + } + [self updateCellData:roomBubbleCellData withReadReceipts:newReadReceipts forEventId:component.event.eventId]; + } + else + { + [self updateCellData:roomBubbleCellData withReadReceipts:readReceipts forEventId:component.event.eventId]; + } + areReadReceiptsAssigned = YES; + break; + } + + MXLogDebug(@"[MXKRoomDataSource][%p] addReadReceipts: Read receipts for an event(%@) that is not displayed", self, eventId); + } + + if (!areReadReceiptsAssigned) + { + MXLogDebug(@"[MXKRoomDataSource][%p] addReadReceipts: Try to attach read receipts to an older message: %@", self, eventId); + + // Try to assign RRs to a previous cell data + if (cellDataIndex >= 1) + { + [self addReadReceipts:readReceipts forEvent:eventId inCellDatas:cellDatas atCellDataIndex:cellDataIndex - 1]; + } + else + { + MXLogDebug(@"[MXKRoomDataSource][%p] addReadReceipts: Fail to attach read receipts for an event(%@)", self, eventId); + } + } + } +} + + +#pragma mark - UITableViewDataSource +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + // PATCH: Presently no bubble must be displayed until the user joins the room. + // FIXME: Handle room data source in case of room preview + if (self.room.summary.membership == MXMembershipInvite) + { + return 0; + } + + NSInteger count; + @synchronized(bubbles) + { + count = bubbles.count; + } + return count; +} + +- (void)scanBubbleDataIfNeeded:(id)bubbleData +{ + MXScanManager *scanManager = self.mxSession.scanManager; + + if (!scanManager && ![bubbleData isKindOfClass:MXKRoomBubbleCellData.class]) + { + return; + } + + MXKRoomBubbleCellData *roomBubbleCellData = (MXKRoomBubbleCellData*)bubbleData; + + NSString *contentURL = roomBubbleCellData.attachment.contentURL; + + // If the content url corresponds to an upload id, the upload is in progress or not complete. + // Create a fake event scan with in progress status when uploading media. + // Since there is no event scan in database it will be overriden by MXScanManager on media upload complete. + if (contentURL && [contentURL hasPrefix:kMXMediaUploadIdPrefix]) + { + MXKRoomBubbleComponent *firstBubbleComponent = roomBubbleCellData.bubbleComponents.firstObject; + MXEvent *firstBubbleComponentEvent = firstBubbleComponent.event; + + if (firstBubbleComponent && firstBubbleComponent.eventScan.antivirusScanStatus != MXAntivirusScanStatusInProgress && firstBubbleComponentEvent) + { + MXEventScan *uploadEventScan = [MXEventScan new]; + uploadEventScan.eventId = firstBubbleComponentEvent.eventId; + uploadEventScan.antivirusScanStatus = MXAntivirusScanStatusInProgress; + uploadEventScan.antivirusScanDate = nil; + uploadEventScan.mediaScans = @[]; + + firstBubbleComponent.eventScan = uploadEventScan; + } + } + else + { + for (MXKRoomBubbleComponent *bubbleComponent in roomBubbleCellData.bubbleComponents) + { + MXEvent *event = bubbleComponent.event; + + if ([event isContentScannable]) + { + [scanManager scanEventIfNeeded:event]; + // NOTE: - [MXScanManager scanEventIfNeeded:] perform modification in background, so - [MXScanManager eventScanWithId:] do not retrieve the last state of event scan. + // It is noticeable when eventScan should be created for the first time. It would be better to return an eventScan with an in progress scan status instead of nil. + MXEventScan *eventScan = [scanManager eventScanWithId:event.eventId]; + bubbleComponent.eventScan = eventScan; + } + } + } +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell *cell; + + id bubbleData = [self cellDataAtIndex:indexPath.row]; + + // Launch an antivirus scan on events contained in bubble data if needed + [self scanBubbleDataIfNeeded:bubbleData]; + + if (bubbleData && self.delegate) + { + // Retrieve the cell identifier according to cell data. + NSString *identifier = [self.delegate cellReuseIdentifierForCellData:bubbleData]; + if (identifier) + { + cell = [tableView dequeueReusableCellWithIdentifier:identifier forIndexPath:indexPath]; + + // Make sure we listen to user actions on the cell + cell.delegate = self; + + // Update typing flag before rendering + bubbleData.isTyping = _showTypingNotifications && currentTypingUsers && ([currentTypingUsers indexOfObject:bubbleData.senderId] != NSNotFound); + // Report the current timestamp display option + bubbleData.showBubbleDateTime = self.showBubblesDateTime; + // display the read receipts + bubbleData.showBubbleReceipts = self.showBubbleReceipts; + // let the caller application manages the time label? + bubbleData.useCustomDateTimeLabel = self.useCustomDateTimeLabel; + // let the caller application manages the receipt? + bubbleData.useCustomReceipts = self.useCustomReceipts; + // let the caller application manages the unsent button? + bubbleData.useCustomUnsentButton = self.useCustomUnsentButton; + + // Make the bubble display the data + [cell render:bubbleData]; + } + } + + // Sanity check: this method may be called during a layout refresh while room data have been modified. + if (!cell) + { + // Return an empty cell + return [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"fakeCell"]; + } + + return cell; +} + +#pragma mark - Groups + +- (MXGroup *)groupWithGroupId:(NSString*)groupId +{ + MXGroup *group = [self.mxSession groupWithGroupId:groupId]; + if (!group) + { + // Check whether an instance has been already created. + group = [externalRelatedGroups objectForKey:groupId]; + } + + if (!group) + { + // Create a new group instance. + group = [[MXGroup alloc] initWithGroupId:groupId]; + [externalRelatedGroups setObject:group forKey:groupId]; + + // Retrieve at least the group profile + [self.mxSession updateGroupProfile:group success:nil failure:^(NSError *error) { + + MXLogDebug(@"[MXKRoomDataSource][%p] groupWithGroupId: group profile update failed %@", self, groupId); + + }]; + } + + return group; +} + +#pragma mark - MXScanManager notifications + +- (void)registerScanManagerNotifications +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:MXScanManagerEventScanDidChangeNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(eventScansDidChange:) name:MXScanManagerEventScanDidChangeNotification object:nil]; +} + +- (void)unregisterScanManagerNotifications +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:MXScanManagerEventScanDidChangeNotification object:nil]; +} + +- (void)eventScansDidChange:(NSNotification*)notification +{ + // TODO: Avoid to call the delegate to often. Set a minimum time interval to avoid table view flickering. + [self.delegate dataSource:self didCellChange:nil]; +} + + +#pragma mark - Reactions + +- (void)registerReactionsChangeListener +{ + if (!self.showReactions || reactionsChangeListener) + { + return; + } + + MXWeakify(self); + reactionsChangeListener = [self.mxSession.aggregations listenToReactionCountUpdateInRoom:self.roomId block:^(NSDictionary * _Nonnull changes) { + MXStrongifyAndReturnIfNil(self); + + BOOL updated = NO; + for (NSString *eventId in changes) + { + id bubbleData = [self cellDataOfEventWithEventId:eventId]; + if (bubbleData) + { + // TODO: Be smarted and use changes[eventId] + [self updateCellDataReactions:bubbleData forEventId:eventId]; + updated = YES; + } + } + + if (updated) + { + [self.delegate dataSource:self didCellChange:nil]; + } + }]; +} + +- (void)unregisterReactionsChangeListener +{ + if (reactionsChangeListener) + { + [self.mxSession.aggregations removeListener:reactionsChangeListener]; + reactionsChangeListener = nil; + } +} + +- (void)updateCellDataReactions:(id)cellData forEventId:(NSString*)eventId +{ + if (!self.showReactions || ![cellData isKindOfClass:MXKRoomBubbleCellData.class]) + { + return; + } + + MXKRoomBubbleCellData *roomBubbleCellData = (MXKRoomBubbleCellData*)cellData; + + MXAggregatedReactions *aggregatedReactions = [self.mxSession.aggregations aggregatedReactionsOnEvent:eventId inRoom:self.roomId].aggregatedReactionsWithNonZeroCount; + + if (self.showOnlySingleEmojiReactions) + { + aggregatedReactions = aggregatedReactions.aggregatedReactionsWithSingleEmoji; + } + + if (aggregatedReactions) + { + if (!roomBubbleCellData.reactions) + { + roomBubbleCellData.reactions = [NSMutableDictionary dictionary]; + } + + roomBubbleCellData.reactions[eventId] = aggregatedReactions; + } + else + { + // unreaction + roomBubbleCellData.reactions[eventId] = nil; + } + + // Indicate that the text message layout should be recomputed. + [roomBubbleCellData invalidateTextLayout]; +} + +- (BOOL)canReactToEventWithId:(NSString*)eventId +{ + BOOL canReact = NO; + + MXEvent *event = [self eventWithEventId:eventId]; + + if ([self canPerformActionOnEvent:event]) + { + NSString *messageType = event.content[@"msgtype"]; + + if ([messageType isEqualToString:kMXMessageTypeKeyVerificationRequest]) + { + canReact = NO; + } + else + { + canReact = YES; + } + } + + return canReact; +} + +- (void)addReaction:(NSString *)reaction forEventId:(NSString *)eventId success:(void (^)(void))success failure:(void (^)(NSError *))failure +{ + [self.mxSession.aggregations addReaction:reaction forEvent:eventId inRoom:self.roomId success:success failure:^(NSError * _Nonnull error) { + MXLogDebug(@"[MXKRoomDataSource][%p] Fail to send reaction on eventId: %@", self, eventId); + if (failure) + { + failure(error); + } + }]; +} + +- (void)removeReaction:(NSString *)reaction forEventId:(NSString *)eventId success:(void (^)(void))success failure:(void (^)(NSError *))failure +{ + [self.mxSession.aggregations removeReaction:reaction forEvent:eventId inRoom:self.roomId success:success failure:^(NSError * _Nonnull error) { + MXLogDebug(@"[MXKRoomDataSource][%p] Fail to unreact on eventId: %@", self, eventId); + if (failure) + { + failure(error); + } + }]; +} + +#pragma mark - Editions + +- (BOOL)canEditEventWithId:(NSString*)eventId +{ + MXEvent *event = [self eventWithEventId:eventId]; + BOOL isRoomMessage = event.eventType == MXEventTypeRoomMessage; + NSString *messageType = event.content[@"msgtype"]; + + return isRoomMessage + && ([messageType isEqualToString:kMXMessageTypeText] || [messageType isEqualToString:kMXMessageTypeEmote]) + && [event.sender isEqualToString:self.mxSession.myUserId] + && [event.roomId isEqualToString:self.roomId]; +} + +- (NSString*)editableTextMessageForEvent:(MXEvent*)event +{ + NSString *editableTextMessage; + + if (event.isReplyEvent) + { + MXReplyEventParser *replyEventParser = [MXReplyEventParser new]; + MXReplyEventParts *replyEventParts = [replyEventParser parse:event]; + + editableTextMessage = replyEventParts.bodyParts.replyText; + } + else + { + editableTextMessage = event.content[@"body"]; + } + + return editableTextMessage; +} + +- (void)registerEventEditsListener +{ + if (eventEditsListener) + { + return; + } + + MXWeakify(self); + eventEditsListener = [self.mxSession.aggregations listenToEditsUpdateInRoom:self.roomId block:^(MXEvent * _Nonnull replaceEvent) { + MXStrongifyAndReturnIfNil(self); + + [self updateEventWithReplaceEvent:replaceEvent]; + }]; +} + +- (void)updateEventWithReplaceEvent:(MXEvent*)replaceEvent +{ + NSString *editedEventId = replaceEvent.relatesTo.eventId; + + dispatch_async(MXKRoomDataSource.processingQueue, ^{ + + // Check whether a message contains the edited event + id bubbleData = [self cellDataOfEventWithEventId:editedEventId]; + if (bubbleData) + { + BOOL hasChanged = [self updateCellData:bubbleData forEditionWithReplaceEvent:replaceEvent andEventId:editedEventId]; + + if (hasChanged) + { + // Update the delegate on main thread + dispatch_async(dispatch_get_main_queue(), ^{ + + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + + }); + } + } + }); +} + +- (void)unregisterEventEditsListener +{ + if (eventEditsListener) + { + [self.mxSession.aggregations removeListener:eventEditsListener]; + eventEditsListener = nil; + } +} + +- (BOOL)updateCellData:(id)bubbleCellData forEditionWithReplaceEvent:(MXEvent*)replaceEvent andEventId:(NSString*)eventId +{ + BOOL hasChanged = NO; + + @synchronized (bubbleCellData) + { + // Retrieve the original event to edit it + NSArray *events = bubbleCellData.events; + MXEvent *editedEvent = nil; + + // If not already done, update edited event content in-place + // This is required for: + // - local echo + // - non live timeline in memory store (permalink) + for (MXEvent *event in events) + { + if ([event.eventId isEqualToString:eventId]) + { + // Check whether the event was not already edited + if (![event.unsignedData.relations.replace.eventId isEqualToString:replaceEvent.eventId]) + { + editedEvent = [event editedEventFromReplacementEvent:replaceEvent]; + } + break; + } + } + + if (editedEvent) + { + if (editedEvent.sentState != replaceEvent.sentState) + { + // Relay the replace event state to the edited event so that the display + // of the edited will rerun the classic sending color flow. + // Note: this must be done on the main thread (this operation triggers + // the call of [self eventDidChangeSentState]) + dispatch_async(dispatch_get_main_queue(), ^{ + editedEvent.sentState = replaceEvent.sentState; + }); + } + + [bubbleCellData updateEvent:eventId withEvent:editedEvent]; + [bubbleCellData invalidateTextLayout]; + hasChanged = YES; + } + } + + return hasChanged; +} + +- (void)replaceTextMessageForEventWithId:(NSString*)eventId + withTextMessage:(NSString *)text + success:(void (^)(NSString *))success + failure:(void (^)(NSError *))failure +{ + MXEvent *event = [self eventWithEventId:eventId]; + + NSString *sanitizedText = [self sanitizedMessageText:text]; + NSString *formattedText = [self htmlMessageFromSanitizedText:sanitizedText]; + + NSString *eventBody = event.content[@"body"]; + NSString *eventFormattedBody = event.content[@"formatted_body"]; + + if (![sanitizedText isEqualToString:eventBody] && (!eventFormattedBody || ![formattedText isEqualToString:eventFormattedBody])) + { + [self.mxSession.aggregations replaceTextMessageEvent:event withTextMessage:sanitizedText formattedText:formattedText localEchoBlock:^(MXEvent * _Nonnull replaceEventLocalEcho) { + + // Apply the local echo to the timeline + [self updateEventWithReplaceEvent:replaceEventLocalEcho]; + + // Integrate the replace local event into the timeline like when sending a message + // This also allows to manage read receipt on this replace event + [self queueEventForProcessing:replaceEventLocalEcho withRoomState:self.roomState direction:MXTimelineDirectionForwards]; + [self processQueuedEvents:nil]; + + } success:success failure:failure]; + } + else + { + failure(nil); + } +} + +#pragma mark - Virtual Rooms + +- (void)virtualRoomsDidChange:(NSNotification *)notification +{ + // update secondary room id + self.secondaryRoomId = [self.mxSession virtualRoomOf:self.roomId]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSourceManager.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSourceManager.h new file mode 100644 index 000000000..46f6709da --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSourceManager.h @@ -0,0 +1,124 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKRoomDataSource.h" + +/** + `MXKRoomDataSourceManagerReleasePolicy` defines how a `MXKRoomDataSource` instance must be released + when [MXKRoomDataSourceManager closeRoomDataSourceWithRoomId:] is called. + + Once released, the in-memory data (messages that are outgoing, failed sending, ...) of room data source + is lost. + */ +typedef enum : NSUInteger { + + /** + Created `MXKRoomDataSource` instances are never released when they are closed. + */ + MXKRoomDataSourceManagerReleasePolicyNeverRelease, + + /** + Created `MXKRoomDataSource` instances are released when they are closed. + */ + MXKRoomDataSourceManagerReleasePolicyReleaseOnClose, + +} MXKRoomDataSourceManagerReleasePolicy; + + +/** + `MXKRoomDataSourceManager` manages a pool of `MXKRoomDataSource` instances for a given Matrix session. + + It makes the `MXKRoomDataSource` instances reusable so that their data (messages that are outgoing, failed sending, ...) + is not lost when the view controller that displays them is gone. + */ +@interface MXKRoomDataSourceManager : NSObject + +/** + Retrieve the MXKRoomDataSources manager for a particular Matrix session. + + @param mxSession the Matrix session, + @return the MXKRoomDataSources manager to use for this session. + */ ++ (MXKRoomDataSourceManager*)sharedManagerForMatrixSession:(MXSession*)mxSession; + +/** + Remove the MXKRoomDataSources manager for a particular Matrix session. + + @param mxSession the Matrix session. + */ ++ (void)removeSharedManagerForMatrixSession:(MXSession*)mxSession; + +/** + Register the MXKRoomDataSource-inherited class that will be used to instantiate all room data source. + By default MXKRoomDataSource class is considered. + + CAUTION: All existing room data source instances are reset in case of class change. + + @param roomDataSourceClass a MXKRoomDataSource-inherited class. + */ ++ (void)registerRoomDataSourceClass:(Class)roomDataSourceClass; + +/** + Force close all the current room data source instances. + */ +- (void)reset; + +/** + Get a room data source corresponding to a room id. + + If a room data source already exists for this room, its reference will be returned. Else, + if requested, the method will instantiate it. + + @param roomId the room id of the room. + @param create if YES, the MXKRoomDataSourceManager will create the room data source if it does not exist yet. + @param onComplete blocked with the room data source (instance of MXKRoomDataSource-inherited class). + */ +- (void)roomDataSourceForRoom:(NSString*)roomId create:(BOOL)create onComplete:(void (^)(MXKRoomDataSource *roomDataSource))onComplete; + +/** + Make a room data source be managed by the manager. + + Use this method to add a MXKRoomDataSource-inherited instance that cannot be automatically created by + [MXKRoomDataSourceManager roomDataSourceForRoom: create:]. + + @param roomDataSource the MXKRoomDataSource-inherited object to the manager scope. + */ +- (void)addRoomDataSource:(MXKRoomDataSource*)roomDataSource; + +/** + Close the roomDataSource. + + The roomDataSource instance will be actually destroyed according to the current release policy. + + @param roomId the room if of the data source to release. + @param forceRelease if yes the room data source instance will be destroyed whatever the policy is. + */ +- (void)closeRoomDataSourceWithRoomId:(NSString*)roomId forceClose:(BOOL)forceRelease; + +/** + The release policy to apply when `MXKRoomDataSource` instances are closed. + Default is MXKRoomDataSourceManagerReleasePolicyNeverRelease. + */ +@property (nonatomic) MXKRoomDataSourceManagerReleasePolicy releasePolicy; + +/** + Tells whether a server sync is in progress in the matrix session. + */ +@property (nonatomic, readonly) BOOL isServerSyncInProgress; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSourceManager.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSourceManager.m new file mode 100644 index 000000000..fd4e54c8a --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSourceManager.m @@ -0,0 +1,271 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomDataSourceManager.h" + +@interface MXKRoomDataSourceManager() +{ + MXSession *mxSession; + + /** + The list of running roomDataSources. + Each key is a room ID. Each value, the MXKRoomDataSource instance. + */ + NSMutableDictionary *roomDataSources; + + /** + Observe UIApplicationDidReceiveMemoryWarningNotification to dispose of any resources that can be recreated. + */ + id UIApplicationDidReceiveMemoryWarningNotificationObserver; +} + +@end + +static NSMutableDictionary *_roomDataSourceManagers = nil; +static Class _roomDataSourceClass; + +@implementation MXKRoomDataSourceManager + ++ (MXKRoomDataSourceManager *)sharedManagerForMatrixSession:(MXSession *)mxSession +{ + // Manage a pool of managers: one per Matrix session + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + _roomDataSourceManagers = [NSMutableDictionary dictionary]; + }); + + MXKRoomDataSourceManager *roomDataSourceManager; + + // Compute an id for this mxSession object: its pointer address as a string + NSString *mxSessionId = [NSString stringWithFormat:@"%p", mxSession]; + + @synchronized(_roomDataSourceManagers) + { + if (_roomDataSourceClass == nil) + { + // Set default class + _roomDataSourceClass = MXKRoomDataSource.class; + } + // If not available yet, create the `MXKRoomDataSourceManager` for this Matrix session + roomDataSourceManager = _roomDataSourceManagers[mxSessionId]; + if (!roomDataSourceManager) + { + roomDataSourceManager = [[MXKRoomDataSourceManager alloc]initWithMatrixSession:mxSession]; + _roomDataSourceManagers[mxSessionId] = roomDataSourceManager; + } + } + + return roomDataSourceManager; +} + ++ (void)removeSharedManagerForMatrixSession:(MXSession*)mxSession +{ + // Compute the id for this mxSession object: its pointer address as a string + NSString *mxSessionId = [NSString stringWithFormat:@"%p", mxSession]; + + @synchronized(_roomDataSourceManagers) + { + MXKRoomDataSourceManager *roomDataSourceManager = [_roomDataSourceManagers objectForKey:mxSessionId]; + if (roomDataSourceManager) + { + [roomDataSourceManager destroy]; + [_roomDataSourceManagers removeObjectForKey:mxSessionId]; + } + } +} + ++ (void)registerRoomDataSourceClass:(Class)roomDataSourceClass +{ + // Sanity check: accept only MXKRoomDataSource classes or sub-classes + NSParameterAssert([roomDataSourceClass isSubclassOfClass:MXKRoomDataSource.class]); + + @synchronized(_roomDataSourceManagers) + { + if (roomDataSourceClass !=_roomDataSourceClass) + { + _roomDataSourceClass = roomDataSourceClass; + + NSArray *mxSessionIds = _roomDataSourceManagers.allKeys; + for (NSString *mxSessionId in mxSessionIds) + { + MXKRoomDataSourceManager *roomDataSourceManager = [_roomDataSourceManagers objectForKey:mxSessionId]; + if (roomDataSourceManager) + { + [roomDataSourceManager destroy]; + [_roomDataSourceManagers removeObjectForKey:mxSessionId]; + } + } + } + } +} + +- (instancetype)initWithMatrixSession:(MXSession *)matrixSession +{ + self = [super init]; + if (self) + { + mxSession = matrixSession; + roomDataSources = [NSMutableDictionary dictionary]; + _releasePolicy = MXKRoomDataSourceManagerReleasePolicyNeverRelease; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXSessionDidLeaveRoom:) name:kMXSessionDidLeaveRoomNotification object:nil]; + + // Observe UIApplicationDidReceiveMemoryWarningNotification + UIApplicationDidReceiveMemoryWarningNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidReceiveMemoryWarningNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXLogDebug(@"[MXKRoomDataSourceManager] %@: Received memory warning.", self); + + // Reload all data sources (except the current used ones) to reduce memory usage. + for (MXKRoomDataSource *roomDataSource in self->roomDataSources.allValues) + { + if (!roomDataSource.delegate) + { + [roomDataSource reload]; + } + } + + }]; + } + return self; +} + +- (void)dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidLeaveRoomNotification object:nil]; +} + +- (void)destroy +{ + [self reset]; + + if (UIApplicationDidReceiveMemoryWarningNotificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:UIApplicationDidReceiveMemoryWarningNotificationObserver]; + UIApplicationDidReceiveMemoryWarningNotificationObserver = nil; + } +} + +#pragma mark + +- (BOOL)isServerSyncInProgress +{ + // Check first the matrix session state + if (mxSession.state == MXSessionStateSyncInProgress) + { + return YES; + } + + // Check all data sources (events process is asynchronous, server sync may not be complete in data source). + for (MXKRoomDataSource *roomDataSource in roomDataSources.allValues) + { + if (roomDataSource.serverSyncEventCount) + { + return YES; + } + } + + return NO; +} + +#pragma mark + +- (void)reset +{ + NSArray *roomIds = roomDataSources.allKeys; + for (NSString *roomId in roomIds) + { + [self closeRoomDataSourceWithRoomId:roomId forceClose:YES]; + } +} + +- (void)roomDataSourceForRoom:(NSString *)roomId create:(BOOL)create onComplete:(void (^)(MXKRoomDataSource *roomDataSource))onComplete +{ + NSParameterAssert(roomId); + + // If not available yet, create the room data source + MXKRoomDataSource *roomDataSource = roomDataSources[roomId]; + + if (!roomDataSource && create && roomId) + { + [_roomDataSourceClass loadRoomDataSourceWithRoomId:roomId andMatrixSession:mxSession onComplete:^(id roomDataSource) { + [self addRoomDataSource:roomDataSource]; + onComplete(roomDataSource); + }]; + } + else + { + onComplete(roomDataSource); + } +} + +- (void)addRoomDataSource:(MXKRoomDataSource *)roomDataSource +{ + roomDataSources[roomDataSource.roomId] = roomDataSource; +} + +- (void)closeRoomDataSourceWithRoomId:(NSString*)roomId forceClose:(BOOL)forceRelease; +{ + // Check first whether this roomDataSource is well handled by this manager + if (!roomId || !roomDataSources[roomId]) + { + MXLogDebug(@"[MXKRoomDataSourceManager] Failed to close an unknown room id: %@", roomId); + return; + } + + MXKRoomDataSource *roomDataSource = roomDataSources[roomId]; + + // According to the policy, it is interesting to keep the room data source in life: it can keep managing echo messages + // in background for instance + MXKRoomDataSourceManagerReleasePolicy releasePolicy = _releasePolicy; + if (forceRelease) + { + // Act as ReleaseOnClose policy + releasePolicy = MXKRoomDataSourceManagerReleasePolicyReleaseOnClose; + } + + switch (releasePolicy) + { + case MXKRoomDataSourceManagerReleasePolicyReleaseOnClose: + + // Destroy and forget the instance + [roomDataSource destroy]; + [roomDataSources removeObjectForKey:roomDataSource.roomId]; + break; + + case MXKRoomDataSourceManagerReleasePolicyNeverRelease: + + // The close here consists in no more sending actions to the current view controller, the room data source delegate + roomDataSource.delegate = nil; + + // Keep the instance for life (reduce memory usage by flushing room data if the number of bubbles is over 30). + [roomDataSource limitMemoryUsage:roomDataSource.maxBackgroundCachedBubblesCount]; + break; + + default: + break; + } +} + +- (void)didMXSessionDidLeaveRoom:(NSNotification *)notif +{ + if (mxSession == notif.object) + { + // The room is no more available, remove it from the manager + [self closeRoomDataSourceWithRoomId:notif.userInfo[kMXSessionNotificationRoomIdKey] forceClose:YES]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.h b/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.h new file mode 100644 index 000000000..6d0d4b842 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.h @@ -0,0 +1,25 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +/** + A `MXKSendReplyEventStringLocalizer` instance represents string localizations used when send reply event to a message in a room. + */ +@interface MXKSendReplyEventStringLocalizer : NSObject + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.m b/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.m new file mode 100644 index 000000000..f116c466b --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.m @@ -0,0 +1,53 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + + +#import "MXKSendReplyEventStringLocalizer.h" +#import "MXKSwiftHeader.h" + +@implementation MXKSendReplyEventStringLocalizer + +- (NSString *)senderSentAnImage +{ + return [MatrixKitL10n messageReplyToSenderSentAnImage]; +} + +- (NSString *)senderSentAVideo +{ + return [MatrixKitL10n messageReplyToSenderSentAVideo]; +} + +- (NSString *)senderSentAnAudioFile +{ + return [MatrixKitL10n messageReplyToSenderSentAnAudioFile]; +} + +- (NSString *)senderSentAVoiceMessage +{ + return [MatrixKitL10n messageReplyToSenderSentAVoiceMessage]; +} + +- (NSString *)senderSentAFile +{ + return [MatrixKitL10n messageReplyToSenderSentAFile]; +} + +- (NSString *)messageToReplyToPrefix +{ + return [MatrixKitL10n messageReplyToMessageToReplyToPrefix]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h new file mode 100644 index 000000000..d2791b9cf --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.h @@ -0,0 +1,33 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +@import Foundation; + +/** + Slash commands used to perform actions from a room. + */ + +FOUNDATION_EXPORT NSString *const kMXKSlashCmdChangeDisplayName; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdEmote; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdJoinRoom; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdPartRoom; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdInviteUser; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdKickUser; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdBanUser; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdUnbanUser; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdSetUserPowerLevel; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdResetUserPowerLevel; +FOUNDATION_EXPORT NSString *const kMXKSlashCmdChangeRoomTopic; diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m new file mode 100644 index 000000000..b83e42f3e --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKSlashCommands.m @@ -0,0 +1,29 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSlashCommands.h" + +NSString *const kMXKSlashCmdChangeDisplayName = @"/nick"; +NSString *const kMXKSlashCmdEmote = @"/me"; +NSString *const kMXKSlashCmdJoinRoom = @"/join"; +NSString *const kMXKSlashCmdPartRoom = @"/part"; +NSString *const kMXKSlashCmdInviteUser = @"/invite"; +NSString *const kMXKSlashCmdKickUser = @"/kick"; +NSString *const kMXKSlashCmdBanUser = @"/ban"; +NSString *const kMXKSlashCmdUnbanUser = @"/unban"; +NSString *const kMXKSlashCmdSetUserPowerLevel = @"/op"; +NSString *const kMXKSlashCmdResetUserPowerLevel = @"/deop"; +NSString *const kMXKSlashCmdChangeRoomTopic = @"/topic"; diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKURLPreviewDataProtocol.h b/Riot/Modules/MatrixKit/Models/Room/MXKURLPreviewDataProtocol.h new file mode 100644 index 000000000..3518d81f8 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKURLPreviewDataProtocol.h @@ -0,0 +1,40 @@ +// +// Copyright 2020 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +@protocol MXKURLPreviewDataProtocol + +/// The URL that's represented by the preview data. +@property (readonly, nonnull) NSURL *url; + +/// The ID of the event that created this preview. +@property (readonly, nonnull) NSString *eventID; + +/// The ID of the room that this preview is from. +@property (readonly, nonnull) NSString *roomID; + +/// The OpenGraph site name for the URL. +@property (readonly, nullable) NSString *siteName; + +/// The OpenGraph title for the URL. +@property (readonly, nullable) NSString *title; + +/// The OpenGraph description for the URL. +@property (readonly, nullable) NSString *text; + +/// The OpenGraph image for the URL. +@property (readwrite, nullable) UIImage *image; + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKInterleavedRecentsDataSource.h b/Riot/Modules/MatrixKit/Models/RoomList/MXKInterleavedRecentsDataSource.h new file mode 100644 index 000000000..646dfc18a --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKInterleavedRecentsDataSource.h @@ -0,0 +1,26 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRecentsDataSource.h" + +/** + 'MXKInterleavedRecentsDataSource' class inherits from 'MXKRecentsDataSource'. + + It interleaves the recents in case of multiple sessions to display first the most recent room. + */ +@interface MXKInterleavedRecentsDataSource : MXKRecentsDataSource + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKInterleavedRecentsDataSource.m b/Riot/Modules/MatrixKit/Models/RoomList/MXKInterleavedRecentsDataSource.m new file mode 100644 index 000000000..28bb4c6ae --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKInterleavedRecentsDataSource.m @@ -0,0 +1,439 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKInterleavedRecentsDataSource.h" + +#import "MXKInterleavedRecentTableViewCell.h" + +#import "MXKAccountManager.h" + +#import "NSBundle+MatrixKit.h" + +@interface MXKInterleavedRecentsDataSource () +{ + /** + The interleaved recents: cell data served by `MXKInterleavedRecentsDataSource`. + */ + NSMutableArray *interleavedCellDataArray; +} + +@end + +@implementation MXKInterleavedRecentsDataSource + +- (instancetype)init +{ + self = [super init]; + if (self) + { + interleavedCellDataArray = [NSMutableArray array]; + } + return self; +} + +#pragma mark - Override MXKDataSource + +- (void)destroy +{ + interleavedCellDataArray = nil; + + [super destroy]; +} + +#pragma mark - Override MXKRecentsDataSource + +- (UIView *)viewForHeaderInSection:(NSInteger)section withFrame:(CGRect)frame +{ + UIView *sectionHeader = nil; + + if (displayedRecentsDataSourceArray.count > 1 && section == 0) + { + sectionHeader = [[UIView alloc] initWithFrame:frame]; + sectionHeader.backgroundColor = [UIColor colorWithRed:0.9 green:0.9 blue:0.9 alpha:1.0]; + CGFloat btnWidth = frame.size.width / displayedRecentsDataSourceArray.count; + UIButton *previousShrinkButton; + + for (NSInteger index = 0; index < displayedRecentsDataSourceArray.count; index++) + { + MXKSessionRecentsDataSource *recentsDataSource = [displayedRecentsDataSourceArray objectAtIndex:index]; + NSString* btnTitle = recentsDataSource.mxSession.myUser.userId; + + // Add shrink button + UIButton *shrinkButton = [UIButton buttonWithType:UIButtonTypeCustom]; + CGRect btnFrame = CGRectMake(index * btnWidth, 0, btnWidth, sectionHeader.frame.size.height); + shrinkButton.frame = btnFrame; + shrinkButton.backgroundColor = [UIColor clearColor]; + + [shrinkButton addTarget:self action:@selector(onButtonPressed:) forControlEvents:UIControlEventTouchUpInside]; + shrinkButton.tag = index; + [sectionHeader addSubview:shrinkButton]; + sectionHeader.userInteractionEnabled = YES; + + // Set shrink button constraints + NSLayoutConstraint *leftConstraint; + NSLayoutConstraint *widthConstraint; + shrinkButton.translatesAutoresizingMaskIntoConstraints = NO; + if (!previousShrinkButton) + { + leftConstraint = [NSLayoutConstraint constraintWithItem:shrinkButton + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:sectionHeader + attribute:NSLayoutAttributeLeading + multiplier:1 + constant:0]; + widthConstraint = [NSLayoutConstraint constraintWithItem:shrinkButton + attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationEqual + toItem:sectionHeader + attribute:NSLayoutAttributeWidth + multiplier:(1.0 /displayedRecentsDataSourceArray.count) + constant:0]; + } + else + { + leftConstraint = [NSLayoutConstraint constraintWithItem:shrinkButton + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:previousShrinkButton + attribute:NSLayoutAttributeTrailing + multiplier:1 + constant:0]; + widthConstraint = [NSLayoutConstraint constraintWithItem:shrinkButton + attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationEqual + toItem:previousShrinkButton + attribute:NSLayoutAttributeWidth + multiplier:1 + constant:0]; + } + [NSLayoutConstraint activateConstraints:@[leftConstraint, widthConstraint]]; + previousShrinkButton = shrinkButton; + + // Add shrink icon + UIImage *chevron; + if ([shrinkedRecentsDataSourceArray indexOfObject:recentsDataSource] != NSNotFound) + { + chevron = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"disclosure"]; + } + else + { + chevron = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"shrink"]; + } + UIImageView *chevronView = [[UIImageView alloc] initWithImage:chevron]; + if ([shrinkedRecentsDataSourceArray indexOfObject:recentsDataSource] == NSNotFound) + { + // Display the tint color of the user + MXKAccount *account = [[MXKAccountManager sharedManager] accountForUserId:recentsDataSource.mxSession.myUser.userId]; + if (account) + { + chevronView.backgroundColor = account.userTintColor; + } + else + { + chevronView.backgroundColor = [UIColor clearColor]; + } + } + else + { + chevronView.backgroundColor = [UIColor lightGrayColor]; + } + chevronView.contentMode = UIViewContentModeCenter; + frame = chevronView.frame; + frame.size.width = frame.size.height = shrinkButton.frame.size.height - 10; + frame.origin.x = shrinkButton.frame.size.width - frame.size.width - 8; + frame.origin.y = (shrinkButton.frame.size.height - frame.size.height) / 2; + chevronView.frame = frame; + [shrinkButton addSubview:chevronView]; + chevronView.autoresizingMask |= (UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin); + + // Add label + frame = shrinkButton.frame; + frame.origin.x = 5; + frame.origin.y = 5; + frame.size.width = chevronView.frame.origin.x - 10; + frame.size.height -= 10; + UILabel *headerLabel = [[UILabel alloc] initWithFrame:frame]; + headerLabel.font = [UIFont boldSystemFontOfSize:16]; + headerLabel.backgroundColor = [UIColor clearColor]; + headerLabel.text = btnTitle; + [shrinkButton addSubview:headerLabel]; + headerLabel.autoresizingMask |= (UIViewAutoresizingFlexibleWidth); + } + } + + return sectionHeader; +} + +- (id)cellDataAtIndexPath:(NSIndexPath *)indexPath +{ + id cellData = nil; + + // Only one section is handled by this data source + if (indexPath.section == 0) + { + // Consider first the case where there is only one data source (no interleaving). + if (displayedRecentsDataSourceArray.count == 1) + { + MXKSessionRecentsDataSource *recentsDataSource = displayedRecentsDataSourceArray.firstObject; + cellData = [recentsDataSource cellDataAtIndex:indexPath.row]; + } + // Else all the cells have been interleaved. + else if (indexPath.row < interleavedCellDataArray.count) + { + cellData = interleavedCellDataArray[indexPath.row]; + } + } + + return cellData; +} + +- (CGFloat)cellHeightAtIndexPath:(NSIndexPath *)indexPath +{ + CGFloat height = 0; + + // Only one section is handled by this data source + if (indexPath.section == 0) + { + // Consider first the case where there is only one data source (no interleaving). + if (displayedRecentsDataSourceArray.count == 1) + { + MXKSessionRecentsDataSource *recentsDataSource = displayedRecentsDataSourceArray.firstObject; + height = [recentsDataSource cellHeightAtIndex:indexPath.row]; + } + // Else all the cells have been interleaved. + else if (indexPath.row < interleavedCellDataArray.count) + { + id recentCellData = interleavedCellDataArray[indexPath.row]; + + // Select the related recent data source + MXKDataSource *dataSource = recentCellData.dataSource; + if ([dataSource isKindOfClass:[MXKSessionRecentsDataSource class]]) + { + MXKSessionRecentsDataSource *recentsDataSource = (MXKSessionRecentsDataSource*)dataSource; + // Count the index of this cell data in original data source array + NSInteger rank = 0; + for (NSInteger index = 0; index < indexPath.row; index++) + { + id cellData = interleavedCellDataArray[index]; + if (cellData.roomSummary == recentCellData.roomSummary) + { + rank++; + } + } + + height = [recentsDataSource cellHeightAtIndex:rank]; + } + } + } + + return height; +} + +- (NSIndexPath*)cellIndexPathWithRoomId:(NSString*)roomId andMatrixSession:(MXSession*)matrixSession +{ + NSIndexPath *indexPath = nil; + + // Consider first the case where there is only one data source (no interleaving). + if (displayedRecentsDataSourceArray.count == 1) + { + MXKSessionRecentsDataSource *recentsDataSource = displayedRecentsDataSourceArray.firstObject; + if (recentsDataSource.mxSession == matrixSession) + { + // Look for the cell + for (NSInteger index = 0; index < recentsDataSource.numberOfCells; index ++) + { + id recentCellData = [recentsDataSource cellDataAtIndex:index]; + if ([roomId isEqualToString:recentCellData.roomIdentifier]) + { + // Got it + indexPath = [NSIndexPath indexPathForRow:index inSection:0]; + break; + } + } + } + } + else + { + // Look for the right data source + for (MXKSessionRecentsDataSource *recentsDataSource in displayedRecentsDataSourceArray) + { + if (recentsDataSource.mxSession == matrixSession) + { + // Check whether the source is not shrinked + if ([shrinkedRecentsDataSourceArray indexOfObject:recentsDataSource] == NSNotFound) + { + // Look for the cell + for (NSInteger index = 0; index < interleavedCellDataArray.count; index ++) + { + id recentCellData = interleavedCellDataArray[index]; + if ([roomId isEqualToString:recentCellData.roomIdentifier]) + { + // Got it + indexPath = [NSIndexPath indexPathForRow:index inSection:0]; + break; + } + } + } + break; + } + } + } + + return indexPath; +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + // Check whether all data sources are ready before rendering recents + if (self.state == MXKDataSourceStateReady) + { + // Only one section is handled by this data source. + return (displayedRecentsDataSourceArray.count ? 1 : 0); + } + return 0; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + // Consider first the case where there is only one data source (no interleaving). + if (displayedRecentsDataSourceArray.count == 1) + { + MXKSessionRecentsDataSource *recentsDataSource = displayedRecentsDataSourceArray.firstObject; + return recentsDataSource.numberOfCells; + } + + return interleavedCellDataArray.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + id roomData = [self cellDataAtIndexPath:indexPath]; + if (roomData && self.delegate) + { + NSString *cellIdentifier = [self.delegate cellReuseIdentifierForCellData:roomData]; + if (cellIdentifier) + { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIdentifier forIndexPath:indexPath]; + + // Make sure we listen to user actions on the cell + cell.delegate = self; + + // Make the bubble display the data + [cell render:roomData]; + + // Clear the user flag, if only one recents list is available + if (displayedRecentsDataSourceArray.count == 1 && [cell isKindOfClass:[MXKInterleavedRecentTableViewCell class]]) + { + ((MXKInterleavedRecentTableViewCell*)cell).userFlag.backgroundColor = [UIColor clearColor]; + } + + return cell; + } + } + + // Return a fake cell to prevent app from crashing. + return [[UITableViewCell alloc] init]; +} + +#pragma mark - MXKDataSourceDelegate + +- (void)dataSource:(MXKDataSource*)dataSource didCellChange:(id)changes +{ + // Consider first the case where there is only one data source (no interleaving). + if (displayedRecentsDataSourceArray.count == 1) + { + // Flush interleaved cells array, we will refer directly to the cell data of the unique data source. + [interleavedCellDataArray removeAllObjects]; + } + else + { + // Handle here the specific case where a second source is just added. + // The empty interleaved cells array has to be prefilled with the cell data of the other source (except if this other source is shrinked). + if (!interleavedCellDataArray.count && displayedRecentsDataSourceArray.count == 2) + { + // This is the first interleaving, look for the other source + MXKSessionRecentsDataSource *recentsDataSource = displayedRecentsDataSourceArray.firstObject; + if (recentsDataSource == dataSource) + { + recentsDataSource = displayedRecentsDataSourceArray.lastObject; + } + + if ([shrinkedRecentsDataSourceArray indexOfObject:recentsDataSource] == NSNotFound) + { + // Report all cell data + for (NSInteger index = 0; index < recentsDataSource.numberOfCells; index ++) + { + [interleavedCellDataArray addObject:[recentsDataSource cellDataAtIndex:index]]; + } + } + } + + // Update now interleaved cells array, TODO take into account 'changes' parameter + MXKSessionRecentsDataSource *updateRecentsDataSource = (MXKSessionRecentsDataSource*)dataSource; + NSInteger numberOfUpdatedCells = 0; + // Check whether this dataSource is used + if ([displayedRecentsDataSourceArray indexOfObject:dataSource] != NSNotFound && [shrinkedRecentsDataSourceArray indexOfObject:dataSource] == NSNotFound) + { + numberOfUpdatedCells = updateRecentsDataSource.numberOfCells; + } + + NSInteger currentCellIndex = 0; + NSInteger updatedCellIndex = 0; + id updatedCellData = nil; + + if (numberOfUpdatedCells) + { + updatedCellData = [updateRecentsDataSource cellDataAtIndex:updatedCellIndex++]; + } + + // Review all cell data items of the current list + while (currentCellIndex < interleavedCellDataArray.count) + { + id currentCellData = interleavedCellDataArray[currentCellIndex]; + + // Remove existing cell data of the updated data source + if (currentCellData.dataSource == dataSource) + { + [interleavedCellDataArray removeObjectAtIndex:currentCellIndex]; + } + else + { + while (updatedCellData && (updatedCellData.roomSummary.lastMessage.originServerTs > currentCellData.roomSummary.lastMessage.originServerTs)) + { + [interleavedCellDataArray insertObject:updatedCellData atIndex:currentCellIndex++]; + updatedCellData = [updateRecentsDataSource cellDataAtIndex:updatedCellIndex++]; + } + + currentCellIndex++; + } + } + + while (updatedCellData) + { + [interleavedCellDataArray addObject:updatedCellData]; + updatedCellData = [updateRecentsDataSource cellDataAtIndex:updatedCellIndex++]; + } + } + + // Call super to keep update readyRecentsDataSourceArray. + [super dataSource:dataSource didCellChange:changes]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellData.h b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellData.h new file mode 100644 index 000000000..b02001881 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellData.h @@ -0,0 +1,26 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKRecentCellDataStoring.h" + +/** + `MXKRecentCellData` modelised the data for a `MXKRecentTableViewCell` cell. + */ +@interface MXKRecentCellData : MXKCellData + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellData.m b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellData.m new file mode 100644 index 000000000..b2bfc1885 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellData.m @@ -0,0 +1,133 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRecentCellData.h" + +@import MatrixSDK; + +#import "MXKDataSource.h" +#import "MXEvent+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKRecentCellData +@synthesize roomSummary, dataSource, lastEventDate; + +- (instancetype)initWithRoomSummary:(id)theRoomSummary + dataSource:(MXKDataSource*)theDataSource; +{ + self = [self init]; + if (self) + { + roomSummary = theRoomSummary; + dataSource = theDataSource; + } + return self; +} + +- (void)dealloc +{ + roomSummary = nil; +} + +- (MXSession *)mxSession +{ + return dataSource.mxSession; +} + +- (NSString*)lastEventDate +{ + return (NSString*)roomSummary.lastMessage.others[@"lastEventDate"]; +} + +- (BOOL)hasUnread +{ + return (roomSummary.localUnreadEventCount != 0); +} + +- (NSString *)roomIdentifier +{ + if (self.isSuggestedRoom) + { + return self.roomSummary.spaceChildInfo.childRoomId; + } + return roomSummary.roomId; +} + +- (NSString *)roomDisplayname +{ + if (self.isSuggestedRoom) + { + return self.roomSummary.spaceChildInfo.displayName; + } + return roomSummary.displayname; +} + +- (NSString *)avatarUrl +{ + if (self.isSuggestedRoom) + { + return self.roomSummary.spaceChildInfo.avatarUrl; + } + return roomSummary.avatar; +} + +- (NSString *)lastEventTextMessage +{ + if (self.isSuggestedRoom) + { + return roomSummary.spaceChildInfo.topic; + } + return roomSummary.lastMessage.text; +} + +- (NSAttributedString *)lastEventAttributedTextMessage +{ + if (self.isSuggestedRoom) + { + return nil; + } + return roomSummary.lastMessage.attributedText; +} + +- (NSUInteger)notificationCount +{ + return roomSummary.notificationCount; +} + +- (NSUInteger)highlightCount +{ + return roomSummary.highlightCount; +} + +- (NSString*)notificationCountStringValue +{ + return [NSString stringWithFormat:@"%tu", self.notificationCount]; +} + +- (NSString*)description +{ + return [NSString stringWithFormat:@"%@ %@: %@ - %@", super.description, self.roomSummary.roomId, self.roomDisplayname, self.lastEventTextMessage]; +} + +- (BOOL)isSuggestedRoom +{ + // As off now, we only store MXSpaceChildInfo in case of suggested rooms + return self.roomSummary.spaceChildInfo != nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellDataStoring.h b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellDataStoring.h new file mode 100644 index 000000000..8cf046696 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentCellDataStoring.h @@ -0,0 +1,75 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKCellData.h" + +@class MXKDataSource; +@class MXSpaceChildInfo; + +/** + `MXKRecentCellDataStoring` defines a protocol a class must conform in order to store recent cell data + managed by `MXKSessionRecentsDataSource`. + */ +@protocol MXKRecentCellDataStoring + +#pragma mark - Data displayed by a room recent cell + +/** + The original data source of the recent displayed by the cell. + */ +@property (nonatomic, weak, readonly) MXKDataSource *dataSource; + +/** + The `MXRoomSummaryProtocol` instance of the room for the recent displayed by the cell. + */ +@property (nonatomic, readonly) id roomSummary; + +@property (nonatomic, readonly) NSString *roomIdentifier; +@property (nonatomic, readonly) NSString *roomDisplayname; +@property (nonatomic, readonly) NSString *avatarUrl; +@property (nonatomic, readonly) NSString *lastEventTextMessage; +@property (nonatomic, readonly) NSString *lastEventDate; + +@property (nonatomic, readonly) BOOL hasUnread; +@property (nonatomic, readonly) NSUInteger notificationCount; +@property (nonatomic, readonly) NSUInteger highlightCount; +@property (nonatomic, readonly) NSString *notificationCountStringValue; +@property (nonatomic, readonly) BOOL isSuggestedRoom; + +@property (nonatomic, readonly) MXSession *mxSession; + +#pragma mark - Public methods +/** + Create a new `MXKCellData` object for a new recent cell. + + @param roomSummary the `id` object that has data about the room. + @param dataSource the `MXKDataSource` object that will use this instance. + @return the newly created instance. + */ +- (instancetype)initWithRoomSummary:(id)roomSummary + dataSource:(MXKDataSource*)dataSource; + +@optional +/** + The `lastEventTextMessage` with sets of attributes. + */ +@property (nonatomic, readonly) NSAttributedString *lastEventAttributedTextMessage; + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentsDataSource.h b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentsDataSource.h new file mode 100644 index 000000000..b9de69b4a --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentsDataSource.h @@ -0,0 +1,140 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSessionRecentsDataSource.h" + +/** + 'MXKRecentsDataSource' is a base class to handle recents from one or multiple matrix sessions. + A 'MXKRecentsDataSource' instance provides the recents data source for `MXKRecentListViewController`. + + By default, the recents list of different sessions are handled into separate sections. + */ +@interface MXKRecentsDataSource : MXKDataSource +{ +@protected + /** + Array of `MXKSessionRecentsDataSource` instances. Only ready and non empty data source are listed here. + (Note: a data source may be considered as empty during searching) + */ + NSMutableArray *displayedRecentsDataSourceArray; + + /** + Array of shrinked sources. Sub-list of displayedRecentsDataSourceArray. + */ + NSMutableArray *shrinkedRecentsDataSourceArray; +} + +/** + List of associated matrix sessions. + */ +@property (nonatomic, readonly) NSArray* mxSessions; + +/** + The number of available recents data sources (This count may be different than mxSession.count because empty data sources are ignored). + */ +@property (nonatomic, readonly) NSUInteger displayedRecentsDataSourcesCount; + +/** + Tell whether there are some unread messages. + */ +@property (nonatomic, readonly) BOOL hasUnread; + +/** + The current search patterns list. + */ +@property (nonatomic, readonly) NSArray* searchPatternsList; + +@property (nonatomic, strong) MXSpace *currentSpace; + +#pragma mark - Configuration + +/** + Add recents data from a matrix session. + + @param mxSession the Matrix session to retrieve contextual data. + @return the new 'MXKSessionRecentsDataSource' instance created for this Matrix session. + */ +- (MXKSessionRecentsDataSource *)addMatrixSession:(MXSession*)mxSession; + +/** + Remove recents data related to a matrix session. + + @param mxSession the session to remove. + */ +- (void)removeMatrixSession:(MXSession*)mxSession; + +/** + Filter the current recents list according to the provided patterns. + + @param patternsList the list of patterns (`NSString` instances) to match with. Set nil to cancel search. + */ +- (void)searchWithPatterns:(NSArray*)patternsList; + +/** + Get the section header view. + + @param section the section index + @param frame the drawing area for the header of the specified section. + @return the section header. + */ +- (UIView *)viewForHeaderInSection:(NSInteger)section withFrame:(CGRect)frame; + +/** + Get the data for the cell at the given index path. + + @param indexPath the index of the cell + @return the cell data + */ +- (id)cellDataAtIndexPath:(NSIndexPath*)indexPath; + +/** + Get the height of the cell at the given index path. + + @param indexPath the index of the cell + @return the cell height + */ +- (CGFloat)cellHeightAtIndexPath:(NSIndexPath*)indexPath; + +/** + Get the index path of the cell related to the provided roomId and session. + + @param roomId the room identifier. + @param mxSession the matrix session in which the room should be available. + @return indexPath the index of the cell (nil if not found or if the related section is shrinked). + */ +- (NSIndexPath*)cellIndexPathWithRoomId:(NSString*)roomId andMatrixSession:(MXSession*)mxSession; + +/** + Returns the room at the index path + + @param indexPath the index of the cell + @return the MXRoom if it exists + */ +- (MXRoom*)getRoomAtIndexPath:(NSIndexPath *)indexPath; + +/** + Leave the room at the index path + + @param indexPath the index of the cell + */ +- (void)leaveRoomAtIndexPath:(NSIndexPath *)indexPath; + +/** + Action registered on buttons used to shrink/disclose recents sources. + */ +- (IBAction)onButtonPressed:(id)sender; + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentsDataSource.m b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentsDataSource.m new file mode 100644 index 000000000..0da982a1d --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKRecentsDataSource.m @@ -0,0 +1,657 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRecentsDataSource.h" + +@import MatrixSDK.MXMediaManager; + +#import "NSBundle+MatrixKit.h" + +#import "MXKConstants.h" + +@interface MXKRecentsDataSource () +{ + /** + Array of `MXSession` instances. + */ + NSMutableArray *mxSessionArray; + + /** + Array of `MXKSessionRecentsDataSource` instances (one by matrix session). + */ + NSMutableArray *recentsDataSourceArray; +} + +@end + +@implementation MXKRecentsDataSource + +- (instancetype)init +{ + self = [super init]; + if (self) + { + mxSessionArray = [NSMutableArray array]; + recentsDataSourceArray = [NSMutableArray array]; + + displayedRecentsDataSourceArray = [NSMutableArray array]; + shrinkedRecentsDataSourceArray = [NSMutableArray array]; + + // Set default data and view classes + [self registerCellDataClass:MXKRecentCellData.class forCellIdentifier:kMXKRecentCellIdentifier]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXSessionInviteRoomUpdate:) name:kMXSessionInvitedRoomsDidChangeNotification object:nil]; + } + return self; +} + +- (instancetype)initWithMatrixSession:(MXSession *)matrixSession +{ + self = [self init]; + if (self) + { + [self addMatrixSession:matrixSession]; + } + return self; +} + +- (MXKSessionRecentsDataSource *)addMatrixSession:(MXSession *)matrixSession +{ + MXKSessionRecentsDataSource *recentsDataSource = [[MXKSessionRecentsDataSource alloc] initWithMatrixSession:matrixSession]; + + if (recentsDataSource) + { + // Set the actual data and view classes + [recentsDataSource registerCellDataClass:[self cellDataClassForCellIdentifier:kMXKRecentCellIdentifier] forCellIdentifier:kMXKRecentCellIdentifier]; + + [mxSessionArray addObject:matrixSession]; + + recentsDataSource.delegate = self; + [recentsDataSourceArray addObject:recentsDataSource]; + + [recentsDataSource finalizeInitialization]; + + if (self.delegate && [self.delegate respondsToSelector:@selector(dataSource:didAddMatrixSession:)]) + { + [self.delegate dataSource:self didAddMatrixSession:matrixSession]; + } + + // Check the current state of the data source + [self dataSource:recentsDataSource didStateChange:recentsDataSource.state]; + } + + return recentsDataSource; +} + +- (void)removeMatrixSession:(MXSession*)matrixSession +{ + for (NSUInteger index = 0; index < mxSessionArray.count; index++) + { + MXSession *mxSession = [mxSessionArray objectAtIndex:index]; + if (mxSession == matrixSession) + { + MXKSessionRecentsDataSource *recentsDataSource = [recentsDataSourceArray objectAtIndex:index]; + [recentsDataSource destroy]; + + [displayedRecentsDataSourceArray removeObject:recentsDataSource]; + + [recentsDataSourceArray removeObjectAtIndex:index]; + [mxSessionArray removeObjectAtIndex:index]; + + // Loop on 'didCellChange' method to let inherited 'MXKRecentsDataSource' class handle this removed data source. + [self dataSource:recentsDataSource didCellChange:nil]; + + if (self.delegate && [self.delegate respondsToSelector:@selector(dataSource:didRemoveMatrixSession:)]) + { + [self.delegate dataSource:self didRemoveMatrixSession:matrixSession]; + } + + break; + } + } +} + +- (void)setCurrentSpace:(MXSpace *)currentSpace +{ + _currentSpace = currentSpace; + + for (MXKSessionRecentsDataSource *recentsDataSource in recentsDataSourceArray) { + recentsDataSource.currentSpace = currentSpace; + } +} + +#pragma mark - MXKDataSource overridden + +- (MXSession*)mxSession +{ + if (mxSessionArray.count > 1) + { + MXLogDebug(@"[MXKRecentsDataSource] CAUTION: mxSession property is not relevant in case of multi-sessions (%tu)", mxSessionArray.count); + } + + // TODO: This property is not well adapted in case of multi-sessions + // We consider by default the first added session as the main one... + if (mxSessionArray.count) + { + return [mxSessionArray firstObject]; + } + return nil; +} + +- (MXKDataSourceState)state +{ + // Manage a global state based on the state of each internal data source. + + MXKDataSourceState currentState = MXKDataSourceStateUnknown; + MXKSessionRecentsDataSource *dataSource; + + if (recentsDataSourceArray.count) + { + dataSource = [recentsDataSourceArray firstObject]; + currentState = dataSource.state; + + // Deduce the current state according to the internal data sources + for (NSUInteger index = 1; index < recentsDataSourceArray.count; index++) + { + dataSource = [recentsDataSourceArray objectAtIndex:index]; + + switch (dataSource.state) + { + case MXKDataSourceStateUnknown: + break; + case MXKDataSourceStatePreparing: + currentState = MXKDataSourceStatePreparing; + break; + case MXKDataSourceStateFailed: + if (currentState == MXKDataSourceStateUnknown) + { + currentState = MXKDataSourceStateFailed; + } + break; + case MXKDataSourceStateReady: + if (currentState == MXKDataSourceStateUnknown || currentState == MXKDataSourceStateFailed) + { + currentState = MXKDataSourceStateReady; + } + break; + + default: + break; + } + } + } + + return currentState; +} + +- (void)destroy +{ + for (MXKSessionRecentsDataSource *recentsDataSource in recentsDataSourceArray) + { + [recentsDataSource destroy]; + } + displayedRecentsDataSourceArray = nil; + recentsDataSourceArray = nil; + shrinkedRecentsDataSourceArray = nil; + mxSessionArray = nil; + + _searchPatternsList = nil; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionInvitedRoomsDidChangeNotification object:nil]; + + [super destroy]; +} + +#pragma mark - + +- (NSArray*)mxSessions +{ + return [NSArray arrayWithArray:mxSessionArray]; +} + +- (NSUInteger)displayedRecentsDataSourcesCount +{ + return displayedRecentsDataSourceArray.count; +} + +- (BOOL)hasUnread +{ + // Check hasUnread flag in all ready data sources + for (MXKSessionRecentsDataSource *recentsDataSource in displayedRecentsDataSourceArray) + { + if (recentsDataSource.hasUnread) + { + return YES; + } + } + return NO; +} + +- (void)searchWithPatterns:(NSArray*)patternsList +{ + _searchPatternsList = patternsList; + + // CAUTION: Apply here the search pattern to all ready data sources (not only displayed ones). + // Some data sources may have been removed from 'displayedRecentsDataSourceArray' during a previous search if no recent was matching. + for (MXKSessionRecentsDataSource *recentsDataSource in recentsDataSourceArray) + { + if (recentsDataSource.state == MXKDataSourceStateReady) + { + [recentsDataSource searchWithPatterns:patternsList]; + } + } +} + +- (UIView *)viewForHeaderInSection:(NSInteger)section withFrame:(CGRect)frame +{ + UIView *sectionHeader = nil; + + if (displayedRecentsDataSourceArray.count > 1 && section < displayedRecentsDataSourceArray.count) + { + MXKSessionRecentsDataSource *recentsDataSource = [displayedRecentsDataSourceArray objectAtIndex:section]; + + NSString* sectionTitle = recentsDataSource.mxSession.myUser.userId; + + sectionHeader = [[UIView alloc] initWithFrame:frame]; + sectionHeader.backgroundColor = [UIColor colorWithRed:0.9 green:0.9 blue:0.9 alpha:1.0]; + + // Add shrink button + UIButton *shrinkButton = [UIButton buttonWithType:UIButtonTypeCustom]; + frame.origin.x = frame.origin.y = 0; + shrinkButton.frame = frame; + shrinkButton.backgroundColor = [UIColor clearColor]; + [shrinkButton addTarget:self action:@selector(onButtonPressed:) forControlEvents:UIControlEventTouchUpInside]; + shrinkButton.tag = section; + [sectionHeader addSubview:shrinkButton]; + sectionHeader.userInteractionEnabled = YES; + + // Add shrink icon + UIImage *chevron; + if ([shrinkedRecentsDataSourceArray indexOfObject:recentsDataSource] != NSNotFound) + { + chevron = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"disclosure"]; + } + else + { + chevron = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"shrink"]; + } + UIImageView *chevronView = [[UIImageView alloc] initWithImage:chevron]; + chevronView.contentMode = UIViewContentModeCenter; + frame = chevronView.frame; + frame.origin.x = sectionHeader.frame.size.width - frame.size.width - 8; + frame.origin.y = (sectionHeader.frame.size.height - frame.size.height) / 2; + chevronView.frame = frame; + [sectionHeader addSubview:chevronView]; + chevronView.autoresizingMask = (UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin); + + // Add label + frame = sectionHeader.frame; + frame.origin.x = 5; + frame.origin.y = 5; + frame.size.width = chevronView.frame.origin.x - 10; + frame.size.height -= 10; + UILabel *headerLabel = [[UILabel alloc] initWithFrame:frame]; + headerLabel.font = [UIFont boldSystemFontOfSize:16]; + headerLabel.backgroundColor = [UIColor clearColor]; + headerLabel.text = sectionTitle; + [sectionHeader addSubview:headerLabel]; + } + + return sectionHeader; +} + +- (id)cellDataAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath.section < displayedRecentsDataSourceArray.count) + { + MXKSessionRecentsDataSource *recentsDataSource = [displayedRecentsDataSourceArray objectAtIndex:indexPath.section]; + + return [recentsDataSource cellDataAtIndex:indexPath.row]; + } + return nil; +} + +- (CGFloat)cellHeightAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath.section < displayedRecentsDataSourceArray.count) + { + MXKSessionRecentsDataSource *recentsDataSource = [displayedRecentsDataSourceArray objectAtIndex:indexPath.section]; + + return [recentsDataSource cellHeightAtIndex:indexPath.row]; + } + return 0; +} + +- (NSIndexPath*)cellIndexPathWithRoomId:(NSString*)roomId andMatrixSession:(MXSession*)matrixSession +{ + NSIndexPath *indexPath = nil; + + // Look for the right data source + for (NSInteger section = 0; section < displayedRecentsDataSourceArray.count; section++) + { + MXKSessionRecentsDataSource *recentsDataSource = displayedRecentsDataSourceArray[section]; + if (recentsDataSource.mxSession == matrixSession) + { + // Check whether the source is not shrinked + if ([shrinkedRecentsDataSourceArray indexOfObject:recentsDataSource] == NSNotFound) + { + // Look for the cell + for (NSInteger index = 0; index < recentsDataSource.numberOfCells; index ++) + { + id recentCellData = [recentsDataSource cellDataAtIndex:index]; + if ([roomId isEqualToString:recentCellData.roomIdentifier]) + { + // Got it + indexPath = [NSIndexPath indexPathForRow:index inSection:section]; + break; + } + } + } + break; + } + } + + return indexPath; +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView +{ + // Check whether all data sources are ready before rendering recents + if (self.state == MXKDataSourceStateReady) + { + return displayedRecentsDataSourceArray.count; + } + return 0; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + if (section < displayedRecentsDataSourceArray.count) + { + MXKSessionRecentsDataSource *recentsDataSource = [displayedRecentsDataSourceArray objectAtIndex:section]; + + // Check whether the source is shrinked + if ([shrinkedRecentsDataSourceArray indexOfObject:recentsDataSource] == NSNotFound) + { + return recentsDataSource.numberOfCells; + } + } + + return 0; +} + +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section +{ + NSString* sectionTitle = nil; + + if (displayedRecentsDataSourceArray.count > 1 && section < displayedRecentsDataSourceArray.count) + { + MXKSessionRecentsDataSource *recentsDataSource = [displayedRecentsDataSourceArray objectAtIndex:section]; + + sectionTitle = recentsDataSource.mxSession.myUser.userId; + } + + return sectionTitle; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath.section < displayedRecentsDataSourceArray.count && self.delegate) + { + MXKSessionRecentsDataSource *recentsDataSource = [displayedRecentsDataSourceArray objectAtIndex:indexPath.section]; + + id roomData = [recentsDataSource cellDataAtIndex:indexPath.row]; + + NSString *cellIdentifier = [self.delegate cellReuseIdentifierForCellData:roomData]; + if (cellIdentifier) + { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIdentifier forIndexPath:indexPath]; + + // Make sure we listen to user actions on the cell + cell.delegate = self; + + // Make the bubble display the data + [cell render:roomData]; + + return cell; + } + } + + // Return a fake cell to prevent app from crashing. + return [[UITableViewCell alloc] init]; +} + +- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath +{ + // Return NO if you do not want the specified item to be editable. + return YES; +} + +- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (editingStyle == UITableViewCellEditingStyleDelete) + { + [self leaveRoomAtIndexPath:indexPath]; + } +} + +#pragma mark - MXKDataSourceDelegate + +- (Class)cellViewClassForCellData:(MXKCellData*)cellData +{ + // Retrieve the class from the delegate here + if (self.delegate) + { + return [self.delegate cellViewClassForCellData:cellData]; + } + + return nil; +} + +- (NSString *)cellReuseIdentifierForCellData:(MXKCellData*)cellData +{ + // Retrieve the identifier from the delegate here + if (self.delegate) + { + return [self.delegate cellReuseIdentifierForCellData:cellData]; + } + + return nil; +} + +- (void)dataSource:(MXKDataSource*)dataSource didCellChange:(id)changes +{ + // Keep update readyRecentsDataSourceArray by checking number of cells + if (dataSource.state == MXKDataSourceStateReady) + { + MXKSessionRecentsDataSource *recentsDataSource = (MXKSessionRecentsDataSource*)dataSource; + + if (recentsDataSource.numberOfCells) + { + // Check whether the data source must be added + if ([displayedRecentsDataSourceArray indexOfObject:recentsDataSource] == NSNotFound) + { + // Add this data source first + [self dataSource:dataSource didStateChange:dataSource.state]; + return; + } + } + else + { + // Check whether this data source must be removed + if ([displayedRecentsDataSourceArray indexOfObject:recentsDataSource] != NSNotFound) + { + [displayedRecentsDataSourceArray removeObject:recentsDataSource]; + + // Loop on 'didCellChange' method to let inherited 'MXKRecentsDataSource' class handle this removed data source. + [self dataSource:recentsDataSource didCellChange:nil]; + return; + } + } + } + + // Notify delegate + [self.delegate dataSource:self didCellChange:changes]; +} + +- (void)dataSource:(MXKDataSource*)dataSource didStateChange:(MXKDataSourceState)state +{ + // Update list of ready data sources + MXKSessionRecentsDataSource *recentsDataSource = (MXKSessionRecentsDataSource*)dataSource; + if (dataSource.state == MXKDataSourceStateReady && recentsDataSource.numberOfCells) + { + if ([displayedRecentsDataSourceArray indexOfObject:recentsDataSource] == NSNotFound) + { + // Add this new recents data source. + if (!displayedRecentsDataSourceArray.count) + { + [displayedRecentsDataSourceArray addObject:recentsDataSource]; + } + else + { + // To display multiple accounts in a consistent order, we sort the recents data source by considering the account user id (alphabetic order). + NSUInteger index; + for (index = 0; index < displayedRecentsDataSourceArray.count; index++) + { + MXKSessionRecentsDataSource *currentRecentsDataSource = displayedRecentsDataSourceArray[index]; + if ([currentRecentsDataSource.mxSession.myUser.userId compare:recentsDataSource.mxSession.myUser.userId] == NSOrderedDescending) + { + break; + } + } + + // Insert this data source + [displayedRecentsDataSourceArray insertObject:recentsDataSource atIndex:index]; + } + + // Check whether a search session is in progress + if (_searchPatternsList) + { + [recentsDataSource searchWithPatterns:_searchPatternsList]; + } + else + { + // Loop on 'didCellChange' method to let inherited 'MXKRecentsDataSource' class handle this new added data source. + [self dataSource:recentsDataSource didCellChange:nil]; + } + } + } + else if ([displayedRecentsDataSourceArray indexOfObject:recentsDataSource] != NSNotFound) + { + [displayedRecentsDataSourceArray removeObject:recentsDataSource]; + + // Loop on 'didCellChange' method to let inherited 'MXKRecentsDataSource' class handle this removed data source. + [self dataSource:recentsDataSource didCellChange:nil]; + } + + if (self.delegate && [self.delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [self.delegate dataSource:self didStateChange:self.state]; + } +} + +#pragma mark - Action + +- (IBAction)onButtonPressed:(id)sender +{ + if ([sender isKindOfClass:[UIButton class]]) + { + UIButton *shrinkButton = (UIButton*)sender; + + if (shrinkButton.tag < displayedRecentsDataSourceArray.count) + { + MXKSessionRecentsDataSource *recentsDataSource = [displayedRecentsDataSourceArray objectAtIndex:shrinkButton.tag]; + + NSUInteger index = [shrinkedRecentsDataSourceArray indexOfObject:recentsDataSource]; + if (index != NSNotFound) + { + // Disclose the + [shrinkedRecentsDataSourceArray removeObjectAtIndex:index]; + } + else + { + // Shrink the recents from this session + [shrinkedRecentsDataSourceArray addObject:recentsDataSource]; + } + + // Loop on 'didCellChange' method to let inherited 'MXKRecentsDataSource' class handle change on this data source. + [self dataSource:recentsDataSource didCellChange:nil]; + } + } +} + +#pragma mark - room actions +- (MXRoom*)getRoomAtIndexPath:(NSIndexPath *)indexPath +{ + // Leave the selected room + id recentCellData = [self cellDataAtIndexPath:indexPath]; + + if (recentCellData) + { + return [self.mxSession roomWithRoomId:recentCellData.roomIdentifier]; + } + + return nil; +} + +- (void)leaveRoomAtIndexPath:(NSIndexPath *)indexPath +{ + MXRoom* room = [self getRoomAtIndexPath:indexPath]; + + if (room) + { + // cancel pending uploads/downloads + // they are useless by now + [MXMediaManager cancelDownloadsInCacheFolder:room.roomId]; + + // TODO GFO cancel pending uploads related to this room + + [room leave:^{ + + // Trigger recents table refresh + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKRecentsDataSource] Failed to leave room (%@) failed", room.roomId); + + // Notify MatrixKit user + NSString *myUserId = room.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } +} + +- (void)didMXSessionInviteRoomUpdate:(NSNotification *)notif +{ + MXSession *mxSession = notif.object; + if ([self.mxSessions indexOfObject:mxSession] != NSNotFound) + { + // do nothing by default + // the inherited classes might require to perform a full or a particial refresh. + //[self.delegate dataSource:self didCellChange:nil]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKSessionRecentsDataSource.h b/Riot/Modules/MatrixKit/Models/RoomList/MXKSessionRecentsDataSource.h new file mode 100644 index 000000000..0748f1e62 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKSessionRecentsDataSource.h @@ -0,0 +1,90 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKConstants.h" +#import "MXKDataSource.h" +#import "MXKRecentCellData.h" + +@class MXSpace; + +/** + Identifier to use for cells that display a room in the recents list. + */ +extern NSString *const kMXKRecentCellIdentifier; + +/** + The recents data source based on a unique matrix session. + */ +MXK_DEPRECATED_ATTRIBUTE_WITH_MSG("See MXSession.roomListDataManager") +@interface MXKSessionRecentsDataSource : MXKDataSource { + +@protected + + /** + The data for the cells served by `MXKSessionRecentsDataSource`. + */ + NSMutableArray *cellDataArray; + + /** + The filtered recents: sub-list of `cellDataArray` defined by `searchWithPatterns:` call. + */ + NSMutableArray *filteredCellDataArray; +} + +/** + The current number of cells. + */ +@property (nonatomic, readonly) NSInteger numberOfCells; + +/** + Tell whether there are some unread messages. + */ +@property (nonatomic, readonly) BOOL hasUnread; + +@property (nonatomic, strong, nullable) MXSpace *currentSpace; + + +#pragma mark - Life cycle + +/** + Filter the current recents list according to the provided patterns. + When patterns are not empty, the search result is stored in `filteredCellDataArray`, + this array provides then data for the cells served by `MXKRecentsDataSource`. + + @param patternsList the list of patterns (`NSString` instances) to match with. Set nil to cancel search. + */ +- (void)searchWithPatterns:(NSArray*)patternsList; + +/** + Get the data for the cell at the given index. + + @param index the index of the cell in the array + @return the cell data + */ +- (id)cellDataAtIndex:(NSInteger)index; + +/** + Get height of the cell at the given index. + + @param index the index of the cell in the array + @return the cell height + */ +- (CGFloat)cellHeightAtIndex:(NSInteger)index; + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomList/MXKSessionRecentsDataSource.m b/Riot/Modules/MatrixKit/Models/RoomList/MXKSessionRecentsDataSource.m new file mode 100644 index 000000000..bf4c600aa --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomList/MXKSessionRecentsDataSource.m @@ -0,0 +1,552 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSessionRecentsDataSource.h" + +@import MatrixSDK; + +#import "MXKRoomDataSourceManager.h" + +#import "MXKSwiftHeader.h" + +#pragma mark - Constant definitions +NSString *const kMXKRecentCellIdentifier = @"kMXKRecentCellIdentifier"; +static NSTimeInterval const roomSummaryChangeThrottlerDelay = .5; + + +@interface MXKSessionRecentsDataSource () +{ + MXKRoomDataSourceManager *roomDataSourceManager; + + /** + Internal array used to regulate change notifications. + Cell data changes are stored instantly in this array. + These changes are reported to the delegate only if no server sync is in progress. + */ + NSMutableArray *internalCellDataArray; + + /** + Store the current search patterns list. + */ + NSArray* searchPatternsList; + + /** + Do not react on every summary change + */ + MXThrottler *roomSummaryChangeThrottler; + + /** + Last received suggested rooms per space ID + */ + NSMutableDictionary *> *lastSuggestedRooms; + + /** + Event listener of the current space used to update the UI if an event occurs. + */ + id spaceEventsListener; + + /** + Observer used to reload data when the space service is initialised + */ + id spaceServiceDidInitialiseObserver; +} + +/** + Additional suggestedRooms related to the current selected Space + */ +@property (nonatomic, strong) NSArray *suggestedRooms; + +@end + +@implementation MXKSessionRecentsDataSource + +- (instancetype)initWithMatrixSession:(MXSession *)matrixSession +{ + self = [super initWithMatrixSession:matrixSession]; + if (self) + { + roomDataSourceManager = [MXKRoomDataSourceManager sharedManagerForMatrixSession:self.mxSession]; + + internalCellDataArray = [NSMutableArray array]; + filteredCellDataArray = nil; + + lastSuggestedRooms = [NSMutableDictionary new]; + + // Set default data and view classes + [self registerCellDataClass:MXKRecentCellData.class forCellIdentifier:kMXKRecentCellIdentifier]; + + roomSummaryChangeThrottler = [[MXThrottler alloc] initWithMinimumDelay:roomSummaryChangeThrottlerDelay]; + + [[MXKAppSettings standardAppSettings] addObserver:self forKeyPath:@"showAllRoomsInHomeSpace" options:0 context:nil]; + } + return self; +} + +- (void)destroy +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXRoomSummaryDidChangeNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXKRoomDataSourceSyncStatusChanged object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionNewRoomNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidLeaveRoomNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDirectRoomsDidChangeNotification object:nil]; + + if (spaceServiceDidInitialiseObserver) { + [[NSNotificationCenter defaultCenter] removeObserver:spaceServiceDidInitialiseObserver]; + } + + [roomSummaryChangeThrottler cancelAll]; + roomSummaryChangeThrottler = nil; + + cellDataArray = nil; + internalCellDataArray = nil; + filteredCellDataArray = nil; + lastSuggestedRooms = nil; + + searchPatternsList = nil; + + [[MXKAppSettings standardAppSettings] removeObserver:self forKeyPath:@"showAllRoomsInHomeSpace" context:nil]; + + [super destroy]; +} + +- (void)didMXSessionStateChange +{ + if (MXSessionStateStoreDataReady <= self.mxSession.state) + { + // Check whether some data have been already load + if (0 == internalCellDataArray.count) + { + [self loadData]; + } + else if (!roomDataSourceManager.isServerSyncInProgress) + { + // Sort cell data and notify the delegate + [self sortCellDataAndNotifyChanges]; + } + } +} + +- (void)setCurrentSpace:(MXSpace *)currentSpace +{ + if (_currentSpace == currentSpace) + { + return; + } + + if (_currentSpace && spaceEventsListener) + { + [_currentSpace.room removeListener:spaceEventsListener]; + } + + _currentSpace = currentSpace; + + self.suggestedRooms = _currentSpace ? lastSuggestedRooms[_currentSpace.spaceId] : nil; + [self updateSuggestedRooms]; + + MXWeakify(self); + spaceEventsListener = [self.currentSpace.room listenToEvents:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + MXStrongifyAndReturnIfNil(self); + [self updateSuggestedRooms]; + }]; +} + +-(void)setSuggestedRooms:(NSArray *)suggestedRooms +{ + _suggestedRooms = suggestedRooms; + [self loadData]; +} + +-(void)updateSuggestedRooms +{ + if (self.currentSpace) + { + NSString *currentSpaceId = self.currentSpace.spaceId; + MXWeakify(self); + [self.mxSession.spaceService getSpaceChildrenForSpaceWithId:currentSpaceId suggestedOnly:YES limit:5 maxDepth:1 paginationToken:nil success:^(MXSpaceChildrenSummary * _Nonnull childrenSummary) { + MXLogDebug(@"[MXKSessionRecentsDataSource] getSpaceChildrenForSpaceWithId %@: %ld found", self.currentSpace.spaceId, childrenSummary.childInfos.count); + MXStrongifyAndReturnIfNil(self); + self->lastSuggestedRooms[currentSpaceId] = childrenSummary.childInfos; + if ([self.currentSpace.spaceId isEqual:currentSpaceId]) { + self.suggestedRooms = childrenSummary.childInfos; + } + } failure:^(NSError * _Nonnull error) { + MXLogError(@"[MXKSessionRecentsDataSource] getSpaceChildrenForSpaceWithId failed with error: %@", error); + }]; + } +} + +#pragma mark - + +- (NSInteger)numberOfCells +{ + if (filteredCellDataArray) + { + return filteredCellDataArray.count; + } + return cellDataArray.count; +} + +- (BOOL)hasUnread +{ + // Check all current cells + // Use numberOfRowsInSection methods so that we take benefit of the filtering + for (NSUInteger i = 0; i < self.numberOfCells; i++) + { + id cellData = [self cellDataAtIndex:i]; + if (cellData.hasUnread) + { + return YES; + } + } + return NO; +} + +- (void)searchWithPatterns:(NSArray*)patternsList +{ + if (patternsList.count) + { + searchPatternsList = patternsList; + + if (filteredCellDataArray) + { + [filteredCellDataArray removeAllObjects]; + } + else + { + filteredCellDataArray = [NSMutableArray arrayWithCapacity:cellDataArray.count]; + } + + for (id cellData in cellDataArray) + { + for (NSString* pattern in patternsList) + { + if (cellData.roomDisplayname && [cellData.roomDisplayname rangeOfString:pattern options:NSCaseInsensitiveSearch].location != NSNotFound) + { + [filteredCellDataArray addObject:cellData]; + break; + } + } + } + } + else + { + filteredCellDataArray = nil; + searchPatternsList = nil; + } + + [self.delegate dataSource:self didCellChange:nil]; +} + +- (id)cellDataAtIndex:(NSInteger)index +{ + if (filteredCellDataArray) + { + if (index < filteredCellDataArray.count) + { + return filteredCellDataArray[index]; + } + } + else if (index < cellDataArray.count) + { + return cellDataArray[index]; + } + + return nil; +} + +- (CGFloat)cellHeightAtIndex:(NSInteger)index +{ + if (self.delegate) + { + id cellData = [self cellDataAtIndex:index]; + + Class class = [self.delegate cellViewClassForCellData:cellData]; + return [class heightForCellData:cellData withMaximumWidth:0]; + } + + return 0; +} + +#pragma mark - Events processing + +/** + Filtering in this method won't have any effect anymore. This class is not maintained. + */ +- (void)loadData +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXRoomSummaryDidChangeNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXKRoomDataSourceSyncStatusChanged object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionNewRoomNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDidLeaveRoomNotification object:nil]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXSessionDirectRoomsDidChangeNotification object:nil]; + + if (!self.mxSession.spaceService.isInitialised && !spaceServiceDidInitialiseObserver) { + MXWeakify(self); + spaceServiceDidInitialiseObserver = [[NSNotificationCenter defaultCenter] addObserverForName:MXSpaceService.didInitialise object:self.mxSession.spaceService queue:nil usingBlock:^(NSNotification * _Nonnull note) { + MXStrongifyAndReturnIfNil(self); + [self loadData]; + }]; + } + + // Reset the table + [internalCellDataArray removeAllObjects]; + + // Retrieve the MXKCellData class to manage the data + Class class = [self cellDataClassForCellIdentifier:kMXKRecentCellIdentifier]; + NSAssert([class conformsToProtocol:@protocol(MXKRecentCellDataStoring)], @"MXKSessionRecentsDataSource only manages MXKCellData that conforms to MXKRecentCellDataStoring protocol"); + + NSDate *startDate = [NSDate date]; + + for (MXRoomSummary *roomSummary in self.mxSession.roomsSummaries) + { + // Filter out private rooms with conference users + if (!roomSummary.isConferenceUserRoom // @TODO Abstract this condition with roomSummary.hiddenFromUser + && !roomSummary.hiddenFromUser) + { + id cellData = [[class alloc] initWithRoomSummary:roomSummary dataSource:self]; + if (cellData) + { + [internalCellDataArray addObject:cellData]; + } + } + } + + for (MXSpaceChildInfo *childInfo in _suggestedRooms) + { + id summary = [[MXRoomSummary alloc] initWithSpaceChildInfo:childInfo]; + id cellData = [[class alloc] initWithRoomSummary:summary + dataSource:self]; + if (cellData) + { + [internalCellDataArray addObject:cellData]; + } + } + + MXLogDebug(@"[MXKSessionRecentsDataSource] Loaded %tu recents in %.3fms", self.mxSession.rooms.count, [[NSDate date] timeIntervalSinceDate:startDate] * 1000); + + // Make sure all rooms have a last message + [self.mxSession fixRoomsSummariesLastMessage]; + + // Report loaded array except if sync is in progress + if (!roomDataSourceManager.isServerSyncInProgress) + { + [self sortCellDataAndNotifyChanges]; + } + + // Listen to MXSession rooms count changes + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXSessionHaveNewRoom:) name:kMXSessionNewRoomNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXSessionDidLeaveRoom:) name:kMXSessionDidLeaveRoomNotification object:nil]; + + // Listen to the direct rooms list + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didDirectRoomsChange:) name:kMXSessionDirectRoomsDidChangeNotification object:nil]; + + // Listen to MXRoomSummary + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didRoomSummaryChanged:) name:kMXRoomSummaryDidChangeNotification object:nil]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didMXSessionStateChange) name:kMXKRoomDataSourceSyncStatusChanged object:nil]; +} + +- (void)didDirectRoomsChange:(NSNotification *)notif +{ + // Inform the delegate about the update + [self.delegate dataSource:self didCellChange:nil]; +} + +- (void)didRoomSummaryChanged:(NSNotification *)notif +{ + [roomSummaryChangeThrottler throttle:^{ + [self didRoomSummaryChanged2:notif]; + }]; +} + +- (void)didRoomSummaryChanged2:(NSNotification *)notif +{ + MXRoomSummary *roomSummary = notif.object; + if (roomSummary.mxSession == self.mxSession && internalCellDataArray.count) + { + // Find the index of the related cell data + NSInteger index = NSNotFound; + for (index = 0; index < internalCellDataArray.count; index++) + { + id theRoomData = [internalCellDataArray objectAtIndex:index]; + if (theRoomData.roomSummary == roomSummary) + { + break; + } + } + + if (index < internalCellDataArray.count) + { + if (roomSummary.hiddenFromUser) + { + [internalCellDataArray removeObjectAtIndex:index]; + } + else + { + // Create a new instance to not modify the content of 'cellDataArray' (the copy is not a deep copy). + Class class = [self cellDataClassForCellIdentifier:kMXKRecentCellIdentifier]; + id cellData = [[class alloc] initWithRoomSummary:roomSummary dataSource:self]; + if (cellData) + { + [internalCellDataArray replaceObjectAtIndex:index withObject:cellData]; + } + } + + // Report change except if sync is in progress + if (!roomDataSourceManager.isServerSyncInProgress) + { + [self sortCellDataAndNotifyChanges]; + } + } + else + { + MXLogDebug(@"[MXKSessionRecentsDataSource] didRoomLastMessageChanged: Cannot find the changed room summary for %@ (%@). It is probably not managed by this recents data source", roomSummary.roomId, roomSummary); + } + } + else + { + // Inform the delegate that all the room summaries have been updated. + [self.delegate dataSource:self didCellChange:nil]; + } +} + +- (void)didMXSessionHaveNewRoom:(NSNotification *)notif +{ + MXSession *mxSession = notif.object; + if (mxSession == self.mxSession) + { + NSString *roomId = notif.userInfo[kMXSessionNotificationRoomIdKey]; + + // Add the room if there is not yet a cell for it + id roomData = [self cellDataWithRoomId:roomId]; + if (nil == roomData) + { + MXLogDebug(@"MXKSessionRecentsDataSource] Add newly joined room: %@", roomId); + + // Retrieve the MXKCellData class to manage the data + Class class = [self cellDataClassForCellIdentifier:kMXKRecentCellIdentifier]; + + MXRoomSummary *roomSummary = [mxSession roomSummaryWithRoomId:roomId]; + id cellData = [[class alloc] initWithRoomSummary:roomSummary dataSource:self]; + if (cellData) + { + [internalCellDataArray addObject:cellData]; + + // Report change except if sync is in progress + if (!roomDataSourceManager.isServerSyncInProgress) + { + [self sortCellDataAndNotifyChanges]; + } + } + } + } +} + +- (void)didMXSessionDidLeaveRoom:(NSNotification *)notif +{ + MXSession *mxSession = notif.object; + if (mxSession == self.mxSession) + { + NSString *roomId = notif.userInfo[kMXSessionNotificationRoomIdKey]; + id roomData = [self cellDataWithRoomId:roomId]; + + if (roomData) + { + MXLogDebug(@"MXKSessionRecentsDataSource] Remove left room: %@", roomId); + + [internalCellDataArray removeObject:roomData]; + + // Report change except if sync is in progress + if (!roomDataSourceManager.isServerSyncInProgress) + { + [self sortCellDataAndNotifyChanges]; + } + } + } +} + +// Order cells +- (void)sortCellDataAndNotifyChanges +{ + // Order them by origin_server_ts + [internalCellDataArray sortUsingComparator:^NSComparisonResult(id cellData1, id cellData2) + { + return [cellData1.roomSummary.lastMessage compareOriginServerTs:cellData2.roomSummary.lastMessage]; + }]; + + // Snapshot the cell data array + cellDataArray = [internalCellDataArray copy]; + + // Update search result if any + if (searchPatternsList) + { + [self searchWithPatterns:searchPatternsList]; + } + + // Update here data source state + if (state != MXKDataSourceStateReady) + { + state = MXKDataSourceStateReady; + if (self.delegate && [self.delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [self.delegate dataSource:self didStateChange:state]; + } + } + + // And inform the delegate about the update + [self.delegate dataSource:self didCellChange:nil]; +} + +// Find the cell data that stores information about the given room id +- (id)cellDataWithRoomId:(NSString*)roomId +{ + id theRoomData; + + NSMutableArray *dataArray = internalCellDataArray; + if (!roomDataSourceManager.isServerSyncInProgress) + { + dataArray = cellDataArray; + } + + for (id roomData in dataArray) + { + if ([roomData.roomSummary.roomId isEqualToString:roomId]) + { + theRoomData = roomData; + break; + } + } + return theRoomData; +} + +#pragma mark - KVO + +- (void)observeValueForKeyPath:(NSString *)keyPath + ofObject:(id)object + change:(NSDictionary *)change + context:(void *)context +{ + if (object == [MXKAppSettings standardAppSettings] && [keyPath isEqualToString:@"showAllRoomsInHomeSpace"]) + { + if (self.currentSpace == nil) + { + [self loadData]; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellData.h b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellData.h new file mode 100644 index 000000000..edd954fd2 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellData.h @@ -0,0 +1,33 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKRoomMemberCellDataStoring.h" + +/** + `MXKRoomMemberCellData` modelised the data for a `MXKRoomMemberTableViewCell` cell. + */ +@interface MXKRoomMemberCellData : MXKCellData + +/** + The matrix session + */ +@property (nonatomic, readonly) MXSession *mxSession; + + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellData.m b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellData.m new file mode 100644 index 000000000..c10c5137f --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellData.m @@ -0,0 +1,66 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomMemberCellData.h" + +#import "MXKRoomMemberListDataSource.h" + +@interface MXKRoomMemberCellData () +{ + MXKRoomMemberListDataSource *roomMemberListDataSource; +} + +@end + +@implementation MXKRoomMemberCellData +@synthesize roomMember; +@synthesize memberDisplayName, powerLevel, isTyping; + +- (instancetype)initWithRoomMember:(MXRoomMember*)member roomState:(MXRoomState*)roomState andRoomMemberListDataSource:(MXKRoomMemberListDataSource*)memberListDataSource +{ + self = [self init]; + if (self) + { + roomMember = member; + roomMemberListDataSource = memberListDataSource; + + // Report member info from the current room state + memberDisplayName = [roomState.members memberName:roomMember.userId]; + powerLevel = [roomState memberNormalizedPowerLevel:roomMember.userId]; + isTyping = NO; + } + + return self; +} + +- (void)updateWithRoomState:(MXRoomState*)roomState +{ + memberDisplayName = [roomState.members memberName:roomMember.userId]; + powerLevel = [roomState memberNormalizedPowerLevel:roomMember.userId]; +} + +- (void)dealloc +{ + roomMember = nil; + roomMemberListDataSource = nil; +} + +- (MXSession*)mxSession +{ + return roomMemberListDataSource.mxSession; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellDataStoring.h b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellDataStoring.h new file mode 100644 index 000000000..053e9a60c --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberCellDataStoring.h @@ -0,0 +1,67 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKCellData.h" + +@class MXKRoomMemberListDataSource; + +/** + `MXKRoomMemberCellDataStoring` defines a protocol a class must conform in order to store room member cell data + managed by `MXKRoomMemberListDataSource`. + */ +@protocol MXKRoomMemberCellDataStoring + + +#pragma mark - Data displayed by a room member cell + +/** + The member displayed by the cell. + */ +@property (nonatomic, readonly) MXRoomMember *roomMember; + +/** + The member display name + */ +@property (nonatomic, readonly) NSString *memberDisplayName; + +/** + The member power level + */ +@property (nonatomic, readonly) CGFloat powerLevel; + +/** + YES when member is typing in the room + */ +@property (nonatomic) BOOL isTyping; + +#pragma mark - Public methods +/** + Create a new `MXKCellData` object for a new member cell. + + @param memberListDataSource the `MXKRoomMemberListDataSource` object that will use this instance. + @return the newly created instance. + */ +- (instancetype)initWithRoomMember:(MXRoomMember*)member roomState:(MXRoomState*)roomState andRoomMemberListDataSource:(MXKRoomMemberListDataSource*)memberListDataSource; + +/** + Update the member data with the provided roon state. + */ +- (void)updateWithRoomState:(MXRoomState*)roomState; + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberListDataSource.h b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberListDataSource.h new file mode 100644 index 000000000..54ffca11c --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberListDataSource.h @@ -0,0 +1,97 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKDataSource.h" +#import "MXKRoomMemberCellData.h" + +#import "MXKAppSettings.h" + +/** + Identifier to use for cells that display a room member. + */ +extern NSString *const kMXKRoomMemberCellIdentifier; + +/** + The data source for `MXKRoomMemberListViewController`. + */ +@interface MXKRoomMemberListDataSource : MXKDataSource { + +@protected + + /** + The data for the cells served by `MXKRoomMembersDataSource`. + */ + NSMutableArray *cellDataArray; + + /** + The filtered members: sub-list of `cellDataArray` defined by `searchWithPatterns:`. + */ + NSMutableArray *filteredCellDataArray; +} + +/** + The id of the room managed by the data source. + */ +@property (nonatomic, readonly) NSString *roomId; + +/** + The settings used to sort/display room members. + + By default the shared application settings are considered. + */ +@property (nonatomic) MXKAppSettings *settings; + + +#pragma mark - Life cycle + +/** + Initialise the data source to serve members corresponding to the passed room. + + @param roomId the id of the room to get members from. + @param mxSession the Matrix session to get data from. + @return the newly created instance. + */ +- (instancetype)initWithRoomId:(NSString*)roomId andMatrixSession:(MXSession*)mxSession; + +/** + Filter the current members list according to the provided patterns. + When patterns are not empty, the search result is stored in `filteredCellDataArray`, + this array provides then data for the cells served by `MXKRoomMembersDataSource`. + + @param patternsList the list of patterns (`NSString` instances) to match with. Set nil to cancel search. + */ +- (void)searchWithPatterns:(NSArray*)patternsList; + +/** + Get the data for the cell at the given index. + + @param index the index of the cell in the array + @return the cell data + */ +- (id)cellDataAtIndex:(NSInteger)index; + +/** + Get height of the celle at the given index. + + @param index the index of the cell in the array + @return the cell height + */ +- (CGFloat)cellHeightAtIndex:(NSInteger)index; + +@end diff --git a/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberListDataSource.m b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberListDataSource.m new file mode 100644 index 000000000..ab8b879ae --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/RoomMemberList/MXKRoomMemberListDataSource.m @@ -0,0 +1,464 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomMemberListDataSource.h" + +@import MatrixSDK.MXCallManager; + +#import "MXKRoomMemberCellData.h" + + +#pragma mark - Constant definitions +NSString *const kMXKRoomMemberCellIdentifier = @"kMXKRoomMemberCellIdentifier"; + + +@interface MXKRoomMemberListDataSource () +{ + /** + The room in which members are listed. + */ + MXRoom *mxRoom; + + /** + Cache for loaded room state. + */ + MXRoomState *mxRoomState; + + /** + The members events listener. + */ + id membersListener; + + /** + The typing notification listener in the room. + */ + id typingNotifListener; +} + +@end + +@implementation MXKRoomMemberListDataSource + +- (instancetype)initWithRoomId:(NSString*)roomId andMatrixSession:(MXSession*)mxSession +{ + self = [super initWithMatrixSession:mxSession]; + if (self) + { + _roomId = roomId; + + cellDataArray = [NSMutableArray array]; + filteredCellDataArray = nil; + + // Consider the shared app settings by default + _settings = [MXKAppSettings standardAppSettings]; + + // Set default data class + [self registerCellDataClass:MXKRoomMemberCellData.class forCellIdentifier:kMXKRoomMemberCellIdentifier]; + } + return self; +} + +- (void)destroy +{ + cellDataArray = nil; + filteredCellDataArray = nil; + + if (membersListener) + { + [self.mxSession removeListener:membersListener]; + membersListener = nil; + } + + if (typingNotifListener) + { + MXWeakify(self); + [mxRoom liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + [liveTimeline removeListener:self->typingNotifListener]; + self->typingNotifListener = nil; + }]; + } + + [super destroy]; +} + +- (void)didMXSessionStateChange +{ + if (MXSessionStateStoreDataReady <= self.mxSession.state) + { + // Check whether the room is not already set + if (!mxRoom) + { + mxRoom = [self.mxSession roomWithRoomId:_roomId]; + if (mxRoom) + { + MXWeakify(self); + [mxRoom state:^(MXRoomState *roomState) { + MXStrongifyAndReturnIfNil(self); + + self->mxRoomState = roomState; + + [self loadData]; + + // Register on typing notif + [self listenTypingNotifications]; + + // Register on members events + [self listenMembersEvents]; + + // Update here data source state + self->state = MXKDataSourceStateReady; + + // Notify delegate + if (self.delegate) + { + if ([self.delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [self.delegate dataSource:self didStateChange:self->state]; + } + [self.delegate dataSource:self didCellChange:nil]; + } + }]; + } + else + { + MXLogDebug(@"[MXKRoomMemberDataSource] The user does not know the room %@", _roomId); + + // Update here data source state + state = MXKDataSourceStateFailed; + + // Notify delegate + if (self.delegate && [self.delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [self.delegate dataSource:self didStateChange:state]; + } + } + } + } +} + +- (void)searchWithPatterns:(NSArray*)patternsList +{ + if (patternsList.count) + { + if (filteredCellDataArray) + { + [filteredCellDataArray removeAllObjects]; + } + else + { + filteredCellDataArray = [NSMutableArray arrayWithCapacity:cellDataArray.count]; + } + + for (id cellData in cellDataArray) + { + for (NSString* pattern in patternsList) + { + if ([[cellData memberDisplayName] rangeOfString:pattern options:NSCaseInsensitiveSearch].location != NSNotFound) + { + [filteredCellDataArray addObject:cellData]; + break; + } + } + } + } + else + { + filteredCellDataArray = nil; + } + + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } +} + +- (id)cellDataAtIndex:(NSInteger)index +{ + if (filteredCellDataArray) + { + return filteredCellDataArray[index]; + } + return cellDataArray[index]; +} + +- (CGFloat)cellHeightAtIndex:(NSInteger)index +{ + if (self.delegate) + { + id cellData = [self cellDataAtIndex:index]; + + Class class = [self.delegate cellViewClassForCellData:cellData]; + return [class heightForCellData:cellData withMaximumWidth:0]; + } + return 0; +} + +#pragma mark - Members processing + +- (void)loadData +{ + NSArray* membersList = [mxRoomState.members membersWithoutConferenceUser]; + + if (!_settings.showLeftMembersInRoomMemberList) + { + NSMutableArray* filteredMembers = [[NSMutableArray alloc] init]; + + for (MXRoomMember* member in membersList) + { + // Filter out left users + if (member.membership != MXMembershipLeave) + { + [filteredMembers addObject:member]; + } + } + + membersList = filteredMembers; + } + + [cellDataArray removeAllObjects]; + + // Retrieve the MXKCellData class to manage the data + Class class = [self cellDataClassForCellIdentifier:kMXKRoomMemberCellIdentifier]; + NSAssert([class conformsToProtocol:@protocol(MXKRoomMemberCellDataStoring)], @"MXKRoomMemberListDataSource only manages MXKCellData that conforms to MXKRoomMemberCellDataStoring protocol"); + + for (MXRoomMember *member in membersList) + { + + id cellData = [[class alloc] initWithRoomMember:member roomState:mxRoomState andRoomMemberListDataSource:self]; + if (cellData) + { + [cellDataArray addObject:cellData]; + } + } + + [self sortMembers]; +} + +- (void)sortMembers +{ + NSArray *sortedMembers = [cellDataArray sortedArrayUsingComparator:^NSComparisonResult(id member1, id member2) + { + + // Move banned and left members at the end of the list + if (member1.roomMember.membership == MXMembershipLeave || member1.roomMember.membership == MXMembershipBan) + { + if (member2.roomMember.membership != MXMembershipLeave && member2.roomMember.membership != MXMembershipBan) + { + return NSOrderedDescending; + } + } + else if (member2.roomMember.membership == MXMembershipLeave || member2.roomMember.membership == MXMembershipBan) + { + return NSOrderedAscending; + } + + // Move invited members just before left and banned members + if (member1.roomMember.membership == MXMembershipInvite) + { + if (member2.roomMember.membership != MXMembershipInvite) + { + return NSOrderedDescending; + } + } + else if (member2.roomMember.membership == MXMembershipInvite) + { + return NSOrderedAscending; + } + + if (self->_settings.sortRoomMembersUsingLastSeenTime) + { + // Get the users that correspond to these members + MXUser *user1 = [self.mxSession userWithUserId:member1.roomMember.userId]; + MXUser *user2 = [self.mxSession userWithUserId:member2.roomMember.userId]; + + // Move users who are not online or unavailable at the end (before invited users) + if ((user1.presence == MXPresenceOnline) || (user1.presence == MXPresenceUnavailable)) + { + if ((user2.presence != MXPresenceOnline) && (user2.presence != MXPresenceUnavailable)) + { + return NSOrderedAscending; + } + } + else if ((user2.presence == MXPresenceOnline) || (user2.presence == MXPresenceUnavailable)) + { + return NSOrderedDescending; + } + else + { + // Here both users are neither online nor unavailable (the lastActive ago is useless) + // We will sort them according to their display, by keeping in front the offline users + if (user1.presence == MXPresenceOffline) + { + if (user2.presence != MXPresenceOffline) + { + return NSOrderedAscending; + } + } + else if (user2.presence == MXPresenceOffline) + { + return NSOrderedDescending; + } + return [[self->mxRoomState.members memberSortedName:member1.roomMember.userId] compare:[self->mxRoomState.members memberSortedName:member2.roomMember.userId] options:NSCaseInsensitiveSearch]; + } + + // Consider user's lastActive ago value + if (user1.lastActiveAgo < user2.lastActiveAgo) + { + return NSOrderedAscending; + } + else if (user1.lastActiveAgo == user2.lastActiveAgo) + { + return [[self->mxRoomState.members memberSortedName:member1.roomMember.userId] compare:[self->mxRoomState.members memberSortedName:member2.roomMember.userId] options:NSCaseInsensitiveSearch]; + } + return NSOrderedDescending; + } + else + { + // Move user without display name at the end (before invited users) + if (member1.roomMember.displayname.length) + { + if (!member2.roomMember.displayname.length) + { + return NSOrderedAscending; + } + } + else if (member2.roomMember.displayname.length) + { + return NSOrderedDescending; + } + + return [[self->mxRoomState.members memberSortedName:member1.roomMember.userId] compare:[self->mxRoomState.members memberSortedName:member2.roomMember.userId] options:NSCaseInsensitiveSearch]; + } + }]; + + cellDataArray = [NSMutableArray arrayWithArray:sortedMembers]; +} + +- (void)listenMembersEvents +{ + // Remove the previous live listener + if (membersListener) + { + [self.mxSession removeListener:membersListener]; + } + + // Register a listener for events that concern room members + NSArray *mxMembersEvents = @[ + kMXEventTypeStringRoomMember, + kMXEventTypeStringRoomPowerLevels, + kMXEventTypeStringPresence + ]; + membersListener = [self.mxSession listenToEventsOfTypes:mxMembersEvents onEvent:^(MXEvent *event, MXTimelineDirection direction, id customObject) + { + // consider only live event + if (direction == MXTimelineDirectionForwards) + { + // Check the room Id (if any) + if (event.roomId && [event.roomId isEqualToString:self->mxRoom.roomId] == NO) + { + // This event does not concern the current room members + return; + } + + // refresh the whole members list. TODO GFO refresh only the updated members. + [self loadData]; + + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + } + }]; +} + +- (void)listenTypingNotifications +{ + // Remove the previous live listener + if (self->typingNotifListener) + { + [mxRoom removeListener:self->typingNotifListener]; + } + + // Add typing notification listener + self->typingNotifListener = [mxRoom listenToEventsOfTypes:@[kMXEventTypeStringTypingNotification] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + // Handle only live events + if (direction == MXTimelineDirectionForwards) + { + // Retrieve typing users list + NSMutableArray *typingUsers = [NSMutableArray arrayWithArray:self->mxRoom.typingUsers]; + // Remove typing info for the current user + NSUInteger index = [typingUsers indexOfObject:self.mxSession.myUser.userId]; + if (index != NSNotFound) + { + [typingUsers removeObjectAtIndex:index]; + } + + for (id cellData in self->cellDataArray) + { + if ([typingUsers indexOfObject:cellData.roomMember.userId] == NSNotFound) + { + cellData.isTyping = NO; + } + else + { + cellData.isTyping = YES; + } + } + + if (self.delegate) + { + [self.delegate dataSource:self didCellChange:nil]; + } + } + }]; +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + if (filteredCellDataArray) + { + return filteredCellDataArray.count; + } + return cellDataArray.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + id roomData = [self cellDataAtIndex:indexPath.row]; + + if (roomData && self.delegate) + { + NSString *identifier = [self.delegate cellReuseIdentifierForCellData:roomData]; + if (identifier) + { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier forIndexPath:indexPath]; + + // Make the bubble display the data + [cell render:roomData]; + + return cell; + } + } + + // Return a fake cell to prevent app from crashing. + return [[UITableViewCell alloc] init]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellData.h b/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellData.h new file mode 100644 index 000000000..d92c3b073 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellData.h @@ -0,0 +1,25 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKCellData.h" +#import "MXKSearchCellDataStoring.h" + +/** + `MXKSearchCellData` modelised the data for a `MXKSearchCell` cell. + */ +@interface MXKSearchCellData : MXKCellData + +@end diff --git a/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellData.m b/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellData.m new file mode 100644 index 000000000..246dbd87d --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellData.m @@ -0,0 +1,69 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSearchCellData.h" + +#import "MXKSearchDataSource.h" + + +@implementation MXKSearchCellData +@synthesize roomId, senderDisplayName; +@synthesize searchResult, title, message, date, shouldShowRoomDisplayName, roomDisplayName, attachment, isAttachmentWithThumbnail, attachmentIcon; + +- (instancetype)initWithSearchResult:(MXSearchResult *)searchResult2 andSearchDataSource:(MXKSearchDataSource *)searchDataSource +{ + self = [super init]; + if (self) + { + searchResult = searchResult2; + + if (searchDataSource.roomEventFilter.rooms.count == 1) + { + // We are displaying a search within a room + // As title, display the user id + title = searchResult.result.sender; + + roomId = searchDataSource.roomEventFilter.rooms[0]; + } + else + { + // We are displaying a search over all user's rooms + // As title, display the room name of this search result + MXRoom *room = [searchDataSource.mxSession roomWithRoomId:searchResult.result.roomId]; + if (room) + { + title = room.summary.displayname; + } + else + { + title = searchResult.result.roomId; + } + } + + date = [searchDataSource.eventFormatter dateStringFromEvent:searchResult.result withTime:YES]; + + // Code from [MXEventFormatter stringFromEvent] for the particular case of a text message + message = [searchResult.result.content[@"body"] isKindOfClass:[NSString class]] ? searchResult.result.content[@"body"] : nil; + } + return self; +} + ++ (void)cellDataWithSearchResult:(MXSearchResult *)searchResult andSearchDataSource:(MXKSearchDataSource *)searchDataSource onComplete:(void (^)(id))onComplete +{ + onComplete([[self alloc] initWithSearchResult:searchResult andSearchDataSource:searchDataSource]); +} + +@end diff --git a/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellDataStoring.h b/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellDataStoring.h new file mode 100644 index 000000000..b5ab3536b --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Search/MXKSearchCellDataStoring.h @@ -0,0 +1,83 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKAttachment.h" + +@class MXKSearchDataSource; + +/** + `MXKSearchCellDataStoring` defines a protocol a class must conform in order to store + a search result in a cell data managed by `MXKSearchDataSource`. + */ +@protocol MXKSearchCellDataStoring + +/** + The room id + */ +@property (nonatomic) NSString *roomId; + +@property (nonatomic, readonly) NSString *title; +@property (nonatomic, readonly) NSString *message; +@property (nonatomic, readonly) NSString *date; + +// Bulk result returned by MatrixSDK +@property (nonatomic, readonly) MXSearchResult *searchResult; + +/** + Tell whether the room display name should be displayed in the cell. NO by default. + */ +@property (nonatomic) BOOL shouldShowRoomDisplayName; + +/** + The room display name. + */ +@property (nonatomic) NSString *roomDisplayName; + +/** + The sender display name. + */ +@property (nonatomic) NSString *senderDisplayName; + +/** + The bubble attachment (if any). + */ +@property (nonatomic) MXKAttachment *attachment; + +/** + YES when the bubble correspond to an attachment displayed with a thumbnail (see image, video). + */ +@property (nonatomic, readonly) BOOL isAttachmentWithThumbnail; + +/** + The default icon relative to the attachment (if any). + */ +@property (nonatomic, readonly) UIImage* attachmentIcon; + + +#pragma mark - Public methods +/** + Create a new `MXKCellData` object for a new search result cell. + + @param searchResult Bulk result returned by MatrixSDK. + @param searchDataSource the `MXKSearchDataSource` object that will use this instance. + @param onComplete a block providing the newly created instance. + */ ++ (void)cellDataWithSearchResult:(MXSearchResult*)searchResult andSearchDataSource:(MXKSearchDataSource*)searchDataSource onComplete:(void (^)(id cellData))onComplete; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Search/MXKSearchDataSource.h b/Riot/Modules/MatrixKit/Models/Search/MXKSearchDataSource.h new file mode 100644 index 000000000..5a89c09d1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Search/MXKSearchDataSource.h @@ -0,0 +1,108 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKDataSource.h" +#import "MXKSearchCellDataStoring.h" + +#import "MXKEventFormatter.h" + +/** + String identifying the object used to store and prepare the cell data of a result during a message search. + */ +extern NSString *const kMXKSearchCellDataIdentifier; + +/** + The data source for `MXKSearchViewController` in case of message search. + + Use the `initWithMatrixSession:` constructor to search in all user's rooms. + Use the `initWithRoomId:andMatrixSession: constructor to search in a specific room. + */ +@interface MXKSearchDataSource : MXKDataSource +{ + @protected + /** + List of results retrieved from the server. + The` MXKSearchDataSource` class stores MXKSearchCellDataStoring objects in it. + */ + NSMutableArray *cellDataArray; +} + +/** + The current search. + */ +@property (nonatomic, readonly) NSString *searchText; + +/** + The room events filter which is applied during the messages search. + */ +@property (nonatomic) MXRoomEventFilter *roomEventFilter; + +/** + Total number of results available on the server. + */ +@property (nonatomic, readonly) NSUInteger serverCount; + +/** + The events to display texts formatter. + `MXKCellData` instances can use it to format text. + */ +@property (nonatomic) MXKEventFormatter *eventFormatter; + +/** + Flag indicating if there are still results (in the past) to get with paginateBack. + */ +@property (nonatomic, readonly) BOOL canPaginate; + +/** + Tell whether the room display name should be displayed in each result cell. NO by default. + */ +@property (nonatomic) BOOL shouldShowRoomDisplayName; + + +/** + Launch a message search homeserver side. + + @discussion The result depends on the 'roomEventFilter' propertie. + + @param textPattern the text to search in messages data. + @param force tell whether the search must be launched even if the text pattern is unchanged. + */ +- (void)searchMessages:(NSString*)textPattern force:(BOOL)force; + +/** + Load more results from the past. + */ +- (void)paginateBack; + +/** + Get the data for the cell at the given index. + + @param index the index of the cell in the array + @return the cell data + */ +- (MXKCellData*)cellDataAtIndex:(NSInteger)index; + +/** + Convert the results of a homeserver search requests into cells. + + This methods is in charge of filling `cellDataArray`. + + @param roomEventResults the homeserver response as provided by MatrixSDK. + @param onComplete the block called once complete. + */ +- (void)convertHomeserverResultsIntoCells:(MXSearchRoomEventResults*)roomEventResults onComplete:(dispatch_block_t)onComplete; + +@end diff --git a/Riot/Modules/MatrixKit/Models/Search/MXKSearchDataSource.m b/Riot/Modules/MatrixKit/Models/Search/MXKSearchDataSource.m new file mode 100644 index 000000000..c79dcf217 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Search/MXKSearchDataSource.m @@ -0,0 +1,275 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSearchDataSource.h" + +#import "MXKSearchCellData.h" + +#pragma mark - Constant definitions +NSString *const kMXKSearchCellDataIdentifier = @"kMXKSearchCellDataIdentifier"; + + +@interface MXKSearchDataSource () +{ + /** + The current search request. + */ + MXHTTPOperation *searchRequest; + + /** + Token that can be used to get the next batch of results in the group, if exists. + */ + NSString *nextBatch; +} + +@end + +@implementation MXKSearchDataSource + +- (instancetype)initWithMatrixSession:(MXSession *)mxSession +{ + self = [super initWithMatrixSession:mxSession]; + if (self) + { + // Set default data and view classes + // Cell data + [self registerCellDataClass:MXKSearchCellData.class forCellIdentifier:kMXKSearchCellDataIdentifier]; + + // Set default MXEvent -> NSString formatter + _eventFormatter = [[MXKEventFormatter alloc] initWithMatrixSession:mxSession]; + + _roomEventFilter = [[MXRoomEventFilter alloc] init]; + + cellDataArray = [NSMutableArray array]; + } + return self; +} + +- (void)destroy +{ + cellDataArray = nil; + _eventFormatter = nil; + + _roomEventFilter = nil; + + [super destroy]; +} + +- (void)searchMessages:(NSString*)textPattern force:(BOOL)force +{ + if (force || ![_searchText isEqualToString:textPattern]) + { + // Reset data before making the new search + if (searchRequest) + { + [searchRequest cancel]; + searchRequest = nil; + } + + _searchText = textPattern; + _serverCount = 0; + _canPaginate = NO; + nextBatch = nil; + + self.state = MXKDataSourceStatePreparing; + [cellDataArray removeAllObjects]; + + if (textPattern.length) + { + MXLogDebug(@"[MXKSearchDataSource] searchMessages: %@", textPattern); + [self doSearch]; + } + else + { + // Refresh table display. + self.state = MXKDataSourceStateReady; + [self.delegate dataSource:self didCellChange:nil]; + } + } +} + +- (void)paginateBack +{ + MXLogDebug(@"[MXKSearchDataSource] paginateBack"); + + self.state = MXKDataSourceStatePreparing; + [self doSearch]; +} + +- (MXKCellData*)cellDataAtIndex:(NSInteger)index +{ + MXKCellData *cellData; + if (index < cellDataArray.count) + { + cellData = cellDataArray[index]; + } + + return cellData; +} + +- (void)convertHomeserverResultsIntoCells:(MXSearchRoomEventResults*)roomEventResults onComplete:(dispatch_block_t)onComplete +{ + // Retrieve the MXKCellData class to manage the data + // Note: MXKSearchDataSource only manages MXKCellData that conforms to MXKSearchCellDataStoring protocol + // see `[registerCellDataClass:forCellIdentifier:]` + Class class = [self cellDataClassForCellIdentifier:kMXKSearchCellDataIdentifier]; + + dispatch_group_t group = dispatch_group_create(); + + for (MXSearchResult *result in roomEventResults.results) + { + dispatch_group_enter(group); + [class cellDataWithSearchResult:result andSearchDataSource:self onComplete:^(__autoreleasing id cellData) { + dispatch_group_leave(group); + + if (cellData) + { + ((id)cellData).shouldShowRoomDisplayName = self.shouldShowRoomDisplayName; + + // Use profile information as data to display + MXSearchUserProfile *userProfile = result.context.profileInfo[result.result.sender]; + cellData.senderDisplayName = userProfile.displayName; + + [self->cellDataArray insertObject:cellData atIndex:0]; + } + }]; + } + + dispatch_group_notify(group, dispatch_get_main_queue(), ^{ + onComplete(); + }); +} + +#pragma mark - Private methods + +// Update the MXKDataSource and notify the delegate +- (void)setState:(MXKDataSourceState)newState +{ + state = newState; + + if (self.delegate) + { + if ([self.delegate respondsToSelector:@selector(dataSource:didStateChange:)]) + { + [self.delegate dataSource:self didStateChange:state]; + } + } +} + +- (void)doSearch +{ + // Handle one request at a time + if (searchRequest) + { + return; + } + + NSDate *startDate = [NSDate date]; + + MXWeakify(self); + searchRequest = [self.mxSession.matrixRestClient searchMessagesWithText:_searchText roomEventFilter:_roomEventFilter beforeLimit:0 afterLimit:0 nextBatch:nextBatch success:^(MXSearchRoomEventResults *roomEventResults) { + MXStrongifyAndReturnIfNil(self); + + MXLogDebug(@"[MXKSearchDataSource] searchMessages: %@ (%d). Done in %.3fms - Got %tu / %tu messages", self.searchText, self.roomEventFilter.containsURL, [[NSDate date] timeIntervalSinceDate:startDate] * 1000, roomEventResults.results.count, roomEventResults.count); + + self->searchRequest = nil; + self->_serverCount = roomEventResults.count; + self->nextBatch = roomEventResults.nextBatch; + self->_canPaginate = (nil != self->nextBatch); + + // Process HS response to cells data + MXWeakify(self); + [self convertHomeserverResultsIntoCells:roomEventResults onComplete:^{ + MXStrongifyAndReturnIfNil(self); + + self.state = MXKDataSourceStateReady; + + // Provide changes information to the delegate + NSIndexSet *insertedIndexes; + if (roomEventResults.results.count) + { + insertedIndexes = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, roomEventResults.results.count)]; + } + + [self.delegate dataSource:self didCellChange:insertedIndexes]; + }]; + + } failure:^(NSError *error) { + MXStrongifyAndReturnIfNil(self); + + self->searchRequest = nil; + self.state = MXKDataSourceStateFailed; + }]; +} + +#pragma mark - Override MXKDataSource + +- (void)registerCellDataClass:(Class)cellDataClass forCellIdentifier:(NSString *)identifier +{ + if ([identifier isEqualToString:kMXKSearchCellDataIdentifier]) + { + // Sanity check + NSAssert([cellDataClass conformsToProtocol:@protocol(MXKSearchCellDataStoring)], @"MXKSearchDataSource only manages MXKCellData that conforms to MXKSearchCellDataStoring protocol"); + } + + [super registerCellDataClass:cellDataClass forCellIdentifier:identifier]; +} + +- (void)cancelAllRequests +{ + if (searchRequest) + { + [searchRequest cancel]; + searchRequest = nil; + } + + [super cancelAllRequests]; +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + return cellDataArray.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + MXKCellData* cellData = [self cellDataAtIndex:indexPath.row]; + + NSString *cellIdentifier = [self.delegate cellReuseIdentifierForCellData:cellData]; + if (cellIdentifier) + { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIdentifier forIndexPath:indexPath]; + + // Make the bubble display the data + [cell render:cellData]; + + // Disable any interactions defined in the cell + // because we want [tableView didSelectRowAtIndexPath:] to be called + cell.contentView.userInteractionEnabled = NO; + + // Force background color change on selection + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + + return cell; + } + + // Return a fake cell to prevent app from crashing. + return [[UITableViewCell alloc] init]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorAlertPresentation.h b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorAlertPresentation.h new file mode 100644 index 000000000..b5fed5637 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorAlertPresentation.h @@ -0,0 +1,26 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +@import Foundation; + +#import "MXKErrorPresentation.h" + +/** + `MXKErrorAlertPresentation` is a concrete implementation of `MXKErrorPresentation` using UIAlertViewController. Display error alert from a view controller. + */ +@interface MXKErrorAlertPresentation : NSObject + +@end diff --git a/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorAlertPresentation.m b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorAlertPresentation.m new file mode 100644 index 000000000..c8ff3ad96 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorAlertPresentation.m @@ -0,0 +1,108 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKErrorAlertPresentation.h" + +#import "MXKErrorPresentableBuilder.h" +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@interface MXKErrorAlertPresentation() + +@property (nonatomic, strong) MXKErrorPresentableBuilder *errorPresentableBuidler; + +@end + +#pragma mark - Implementation + +@implementation MXKErrorAlertPresentation + +#pragma mark - Setup & Teardown + +- (instancetype)init +{ + self = [super init]; + if (self) { + _errorPresentableBuidler = [[MXKErrorPresentableBuilder alloc] init]; + } + return self; +} + +#pragma mark - MXKErrorPresentation + +- (void)presentErrorFromViewController:(UIViewController*)viewController + title:(NSString*)title + message:(NSString*)message + animated:(BOOL)animated + handler:(void (^)(void))handler +{ + + UIAlertController *alert = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert]; + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * _Nonnull action) { + if (handler) + { + handler(); + } + }]]; + + [viewController presentViewController:alert animated:animated completion:nil]; +} + +- (void)presentErrorFromViewController:(UIViewController*)viewController + forError:(NSError*)error + animated:(BOOL)animated + handler:(void (^)(void))handler +{ + id errorPresentable = [self.errorPresentableBuidler errorPresentableFromError:error]; + + if (errorPresentable) + { + [self presentErrorFromViewController:viewController + forErrorPresentable:errorPresentable + animated:animated + handler:handler]; + } +} + +- (void)presentGenericErrorFromViewController:(UIViewController*)viewController + animated:(BOOL)animated + handler:(void (^)(void))handler +{ + id errorPresentable = [self.errorPresentableBuidler commonErrorPresentable]; + + [self presentErrorFromViewController:viewController + forErrorPresentable:errorPresentable + animated:animated + handler:handler]; +} + +- (void)presentErrorFromViewController:(UIViewController*)viewController + forErrorPresentable:(id)errorPresentable + animated:(BOOL)animated + handler:(void (^)(void))handler +{ + [self presentErrorFromViewController:viewController + title:errorPresentable.title + message:errorPresentable.message + animated:animated + handler:handler]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentable.h b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentable.h new file mode 100644 index 000000000..d8d69498c --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentable.h @@ -0,0 +1,29 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +/** + `MXKErrorPresentable` describe an error to display on screen. + */ +@protocol MXKErrorPresentable + +@required + +@property (strong, nonatomic, readonly) NSString *title; +@property (strong, nonatomic, readonly) NSString *message; + +- (id)initWithTitle:(NSString*)title message:(NSString*)message; + +@end diff --git a/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentableBuilder.h b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentableBuilder.h new file mode 100644 index 000000000..8760f7fb3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentableBuilder.h @@ -0,0 +1,41 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +@import Foundation; + +#import "MXKErrorPresentable.h" + +/** + `MXKErrorPresentableBuilder` enable to create error to present on screen. + */ +@interface MXKErrorPresentableBuilder : NSObject + +/** + Build a displayable error from a NSError. + + @param error an NSError. + @return Return nil in case of network request cancellation error otherwise return a presentable error from NSError informations. + */ +- (id )errorPresentableFromError:(NSError*)error; + +/** + Build a common displayable error. Generic error message to present as fallback when error explanation can't be user friendly. + + @return Common default error. + */ +- (id )commonErrorPresentable; + +@end diff --git a/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentableBuilder.m b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentableBuilder.m new file mode 100644 index 000000000..10c1aca39 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentableBuilder.m @@ -0,0 +1,56 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKErrorPresentableBuilder.h" + +#import "NSBundle+MatrixKit.h" +#import "MXKErrorViewModel.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKErrorPresentableBuilder + +- (id )errorPresentableFromError:(NSError*)error +{ + // Ignore nil error or connection cancellation error + if (!error || ([error.domain isEqualToString:NSURLErrorDomain] && error.code == NSURLErrorCancelled)) + { + return nil; + } + + NSString *title = [error.userInfo valueForKey:NSLocalizedFailureReasonErrorKey]; + NSString *message = [error.userInfo valueForKey:NSLocalizedDescriptionKey]; + + if (!title) + { + title = [MatrixKitL10n error]; + } + + if (!message) + { + message = [MatrixKitL10n errorCommonMessage]; + } + + return [[MXKErrorViewModel alloc] initWithTitle:title message:message]; +} + +- (id )commonErrorPresentable +{ + return [[MXKErrorViewModel alloc] initWithTitle:[MatrixKitL10n error] + message:[MatrixKitL10n errorCommonMessage]]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentation.h b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentation.h new file mode 100644 index 000000000..03ea60974 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorPresentation.h @@ -0,0 +1,49 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +@import Foundation; +@import UIKit; + +#import "MXKErrorPresentable.h" + +/** + `MXKErrorPresentation` describe an error display handler for presenting error from a view controller. + */ +@protocol MXKErrorPresentation + +- (void)presentErrorFromViewController:(UIViewController*)viewController + title:(NSString*)title + message:(NSString*)message + animated:(BOOL)animated + handler:(void (^)(void))handler; + +- (void)presentErrorFromViewController:(UIViewController*)viewController + forError:(NSError*)error + animated:(BOOL)animated + handler:(void (^)(void))handler; + +- (void)presentGenericErrorFromViewController:(UIViewController*)viewController + animated:(BOOL)animated + handler:(void (^)(void))handler; + +@required + +- (void)presentErrorFromViewController:(UIViewController*)viewController + forErrorPresentable:(id)errorPresentable + animated:(BOOL)animated + handler:(void (^)(void))handler; + +@end diff --git a/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorViewModel.h b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorViewModel.h new file mode 100644 index 000000000..15aac9350 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorViewModel.h @@ -0,0 +1,26 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +@import Foundation; + +#import "MXKErrorPresentable.h" + +/** + `MXKErrorViewModel` is a concrete implementation of `MXKErrorPresentable` + */ +@interface MXKErrorViewModel : NSObject + +@end diff --git a/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorViewModel.m b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorViewModel.m new file mode 100644 index 000000000..18e54464e --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/ErrorPresentation/MXKErrorViewModel.m @@ -0,0 +1,41 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKErrorViewModel.h" + +@interface MXKErrorViewModel() + +@property (strong, nonatomic) NSString *title; +@property (strong, nonatomic) NSString *message; + +@end + +@implementation MXKErrorViewModel + +- (id)initWithTitle:(NSString*)title message:(NSString*)message +{ + self = [super init]; + + if (self) + { + _title = title; + _message = message; + } + + return self; +} + +@end diff --git a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.h b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.h new file mode 100644 index 000000000..0df5c3a75 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.h @@ -0,0 +1,409 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import + +#import "MXKAppSettings.h" + +@protocol MarkdownToHTMLRendererProtocol; +/** + Formatting result codes. + */ +typedef enum : NSUInteger { + + /** + The formatting was successful. + */ + MXKEventFormatterErrorNone = 0, + + /** + The formatter knows the event type but it encountered data that it does not support. + */ + MXKEventFormatterErrorUnsupported, + + /** + The formatter encountered unexpected data in the event. + */ + MXKEventFormatterErrorUnexpected, + + /** + The formatter does not support the type of the passed event. + */ + MXKEventFormatterErrorUnknownEventType + +} MXKEventFormatterError; + +/** + `MXKEventFormatter` is an utility class for formating Matrix events into strings which + will be displayed to the end user. + */ +@interface MXKEventFormatter : NSObject +{ +@protected + /** + The matrix session. Used to get contextual data. + */ + MXSession *mxSession; + + /** + The date formatter used to build date string without time information. + */ + NSDateFormatter *dateFormatter; + + /** + The time formatter used to build time string without date information. + */ + NSDateFormatter *timeFormatter; + + /** + The default room summary updater from the MXSession. + */ + MXRoomSummaryUpdater *defaultRoomSummaryUpdater; +} + +/** + The settings used to handle room events. + + By default the shared application settings are considered. + */ +@property (nonatomic) MXKAppSettings *settings; + +/** + Flag indicating if the formatter must build strings that will be displayed as subtitle. + Default is NO. + */ +@property (nonatomic) BOOL isForSubtitle; + +/** + Flags indicating if the formatter must create clickable links for Matrix user ids, + room ids, room aliases or event ids. + Default is NO. + */ +@property (nonatomic) BOOL treatMatrixUserIdAsLink; +@property (nonatomic) BOOL treatMatrixRoomIdAsLink; +@property (nonatomic) BOOL treatMatrixRoomAliasAsLink; +@property (nonatomic) BOOL treatMatrixEventIdAsLink; +@property (nonatomic) BOOL treatMatrixGroupIdAsLink; + +/** + Initialise the event formatter. + + @param mxSession the Matrix to retrieve contextual data. + @return the newly created instance. + */ +- (instancetype)initWithMatrixSession:(MXSession*)mxSession; + +/** + Initialise the date and time formatters. + This formatter could require to be updated after updating the device settings. + e.g the time format switches from 24H format to AM/PM. + */ +- (void)initDateTimeFormatters; + +/** + The types of events allowed to be displayed in the room history. + No string will be returned by the formatter for the events whose the type doesn't belong to this array. + + Default is nil. All messages types are displayed. + */ +@property (nonatomic) NSArray *eventTypesFilterForMessages; + +@property (nonatomic, strong) id markdownToHTMLRenderer; + +/** + Checks whether the event is related to an attachment and if it is supported. + + @param event an event. + @return YES if the provided event is related to a supported attachment type. + */ +- (BOOL)isSupportedAttachment:(MXEvent*)event; + +#pragma mark - Events to strings conversion methods +/** + Compose the event sender display name according to the current room state. + + @param event the event to format. + @param roomState the room state right before the event. + @return the sender display name + */ +- (NSString*)senderDisplayNameForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState; + +/** + Compose the event target display name according to the current room state. + + @discussion "target" refers to the room member who is the target of this event (if any), e.g. + the invitee, the person being banned, etc. + + @param event the event to format. + @param roomState the room state right before the event. + @return the target display name (if any) + */ +- (NSString*)targetDisplayNameForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState; + +/** + Retrieve the avatar url of the event sender from the current room state. + + @param event the event to format. + @param roomState the room state right before the event. + @return the sender avatar url + */ +- (NSString*)senderAvatarUrlForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState; + +/** + Retrieve the avatar url of the event target from the current room state. + + @discussion "target" refers to the room member who is the target of this event (if any), e.g. + the invitee, the person being banned, etc. + + @param event the event to format. + @param roomState the room state right before the event. + @return the target avatar url (if any) + */ +- (NSString*)targetAvatarUrlForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState; + +/** + Generate a displayable string representating the event. + + @param event the event to format. + @param roomState the room state right before the event. + @param error the error code. In case of formatting error, the formatter may return non nil string as a proposal. + @return the display text for the event. + */ +- (NSString*)stringFromEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState error:(MXKEventFormatterError*)error; + +/** + Generate a displayable attributed string representating the event. + + @param event the event to format. + @param roomState the room state right before the event. + @param error the error code. In case of formatting error, the formatter may return non nil string as a proposal. + @return the attributed string for the event. + */ +- (NSAttributedString*)attributedStringFromEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState error:(MXKEventFormatterError*)error; + +/** + Generate a displayable attributed string representating a summary for the provided events. + + @param events the series of events to format. + @param roomState the room state right before the first event in the series. + @param error the error code. In case of formatting error, the formatter may return non nil string as a proposal. + @return the attributed string. + */ +- (NSAttributedString*)attributedStringFromEvents:(NSArray*)events withRoomState:(MXRoomState*)roomState error:(MXKEventFormatterError*)error; + +/** + Render a random string into an attributed string with the font and the text color + that correspond to the passed event. + + @param string the string to render. + @param event the event associated to the string. + @return an attributed string. + */ +- (NSAttributedString*)renderString:(NSString*)string forEvent:(MXEvent*)event; + +/** + Render a random html string into an attributed string with the font and the text color + that correspond to the passed event. + + @param htmlString the HTLM string to render. + @param event the event associated to the string. + @param roomState the room state right before the event. + @return an attributed string. + */ +- (NSAttributedString*)renderHTMLString:(NSString*)htmlString forEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState; + +/** + Same as [self renderString:forEvent:] but add a prefix. + The prefix will be rendered with 'prefixTextFont' and 'prefixTextColor'. + + @param string the string to render. + @param prefix the prefix to add. + @param event the event associated to the string. + @return an attributed string. + */ +- (NSAttributedString*)renderString:(NSString*)string withPrefix:(NSString*)prefix forEvent:(MXEvent*)event; + +#pragma mark - Conversion tools + +/** + Convert a Markdown string to HTML. + + @param markdownString the string to convert. + @return an HTML formatted string. + */ +- (NSString*)htmlStringFromMarkdownString:(NSString*)markdownString; + +#pragma mark - Timestamp formatting + +/** + Generate the date in string format corresponding to the date. + + @param date The date. + @param time The flag used to know if the returned string must include time information or not. + @return the string representation of the date. + */ +- (NSString*)dateStringFromDate:(NSDate *)date withTime:(BOOL)time; + +/** + Generate the date in string format corresponding to the timestamp. + The returned string is localised according to the current device settings. + + @param timestamp The timestamp in milliseconds since Epoch. + @param time The flag used to know if the returned string must include time information or not. + @return the string representation of the date. + */ +- (NSString*)dateStringFromTimestamp:(uint64_t)timestamp withTime:(BOOL)time; + +/** + Generate the date in string format corresponding to the event. + The returned string is localised according to the current device settings. + + @param event The event to format. + @param time The flag used to know if the returned string must include time information or not. + @return the string representation of the event date. + */ +- (NSString*)dateStringFromEvent:(MXEvent*)event withTime:(BOOL)time; + +/** + Generate the time string of the provided date by considered the current system time formatting. + + @param date The date. + @return the string representation of the time component of the date. + */ +- (NSString*)timeStringFromDate:(NSDate *)date; + + +# pragma mark - Customisation +/** + The list of allowed HTML tags in rendered attributed strings. + */ +@property (nonatomic) NSArray *allowedHTMLTags; + +/** + A block to run on HTML `img` tags when calling `renderHTMLString:forEvent:withRoomState:`. + + This block provides the original URL for the image and can be used to download the image locally + and return a local file URL for the image to attach to the rendered attributed string. + */ +@property (nonatomic, copy) NSURL* (^htmlImageHandler)(NSString *sourceURL, CGFloat width, CGFloat height); + +/** + The style sheet used by the 'renderHTMLString' method. +*/ +@property (nonatomic) NSString *defaultCSS; + +/** + Default color used to display text content of event. + Default is [UIColor blackColor]. + */ +@property (nonatomic) UIColor *defaultTextColor; + +/** + Default color used to display text content of event when it is displayed as subtitle (related to 'isForSubtitle' property). + Default is [UIColor blackColor]. + */ +@property (nonatomic) UIColor *subTitleTextColor; + +/** + Color applied on the event description prefix used to display for example the message sender name. + Default is [UIColor blackColor]. + */ +@property (nonatomic) UIColor *prefixTextColor; + +/** + Color used when the event must be bing to the end user. This happens when the event + matches the user's push rules. + Default is [UIColor blueColor]. + */ +@property (nonatomic) UIColor *bingTextColor; + +/** + Color used to display text content of an event being encrypted. + Default is [UIColor lightGrayColor]. + */ +@property (nonatomic) UIColor *encryptingTextColor; + +/** + Color used to display text content of an event being sent. + Default is [UIColor lightGrayColor]. + */ +@property (nonatomic) UIColor *sendingTextColor; + +/** + Color used to display error text. + Default is red. + */ +@property (nonatomic) UIColor *errorTextColor; + +/** + Color used to display the side border of HTML blockquotes. + Default is a grey. + */ +@property (nonatomic) UIColor *htmlBlockquoteBorderColor; + +/** + Default text font used to display text content of event. + Default is SFUIText-Regular 14. + */ +@property (nonatomic) UIFont *defaultTextFont; + +/** + Font applied on the event description prefix used to display for example the message sender name. + Default is SFUIText-Regular 14. + */ +@property (nonatomic) UIFont *prefixTextFont; + +/** + Text font used when the event must be bing to the end user. This happens when the event + matches the user's push rules. + Default is SFUIText-Regular 14. + */ +@property (nonatomic) UIFont *bingTextFont; + +/** + Text font used when the event is a state event. + Default is italic SFUIText-Regular 14. + */ +@property (nonatomic) UIFont *stateEventTextFont; + +/** + Text font used to display call notices (invite, answer, hangup). + Default is SFUIText-Regular 14. + */ +@property (nonatomic) UIFont *callNoticesTextFont; + +/** + Text font used to display encrypted messages. + Default is SFUIText-Regular 14. + */ +@property (nonatomic) UIFont *encryptedMessagesTextFont; + +/** + Text font used to display message containing a single emoji. + Default is nil (same font as self.emojiOnlyTextFont). + */ +@property (nonatomic) UIFont *singleEmojiTextFont; + +/** + Text font used to display message containing only emojis. + Default is nil (same font as self.defaultTextFont). + */ +@property (nonatomic) UIFont *emojiOnlyTextFont; + +@end diff --git a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m new file mode 100644 index 000000000..d28641dbf --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m @@ -0,0 +1,2215 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEventFormatter.h" + +@import MatrixSDK; +@import DTCoreText; + +#import "MXEvent+MatrixKit.h" +#import "NSBundle+MatrixKit.h" +#import "MXKSwiftHeader.h" +#import "MXKTools.h" +#import "MXRoom+Sync.h" + +#import "MXKRoomNameStringLocalizer.h" + +static NSString *const kHTMLATagRegexPattern = @"([^<]*)"; + +@interface MXKEventFormatter () +{ + /** + The default CSS converted in DTCoreText object. + */ + DTCSSStylesheet *dtCSS; + + /** + Links detector in strings. + */ + NSDataDetector *linkDetector; +} +@end + +@implementation MXKEventFormatter + +- (instancetype)initWithMatrixSession:(MXSession *)matrixSession +{ + self = [super init]; + if (self) + { + mxSession = matrixSession; + + [self initDateTimeFormatters]; + + // Use the same list as matrix-react-sdk ( https://github.com/matrix-org/matrix-react-sdk/blob/24223ae2b69debb33fa22fcda5aeba6fa93c93eb/src/HtmlUtils.js#L25 ) + _allowedHTMLTags = @[ + @"font", // custom to matrix for IRC-style font coloring + @"del", // for markdown + @"body", // added internally by DTCoreText + @"mx-reply", + @"h1", @"h2", @"h3", @"h4", @"h5", @"h6", @"blockquote", @"p", @"a", @"ul", @"ol", + @"nl", @"li", @"b", @"i", @"u", @"strong", @"em", @"strike", @"code", @"hr", @"br", @"div", + @"table", @"thead", @"caption", @"tbody", @"tr", @"th", @"td", @"pre" + ]; + + self.defaultCSS = @" \ + pre,code { \ + background-color: #eeeeee; \ + display: inline; \ + font-family: monospace; \ + white-space: pre; \ + -coretext-fontname: Menlo-Regular; \ + font-size: small; \ + } \ + h1,h2 { \ + font-size: 1.2em; \ + }"; // match the size of h1/h2 to h3 to stop people shouting. + + // Set default colors + _defaultTextColor = [UIColor blackColor]; + _subTitleTextColor = [UIColor blackColor]; + _prefixTextColor = [UIColor blackColor]; + _bingTextColor = [UIColor blueColor]; + _encryptingTextColor = [UIColor lightGrayColor]; + _sendingTextColor = [UIColor lightGrayColor]; + _errorTextColor = [UIColor redColor]; + _htmlBlockquoteBorderColor = [MXKTools colorWithRGBValue:0xDDDDDD]; + + _defaultTextFont = [UIFont systemFontOfSize:14]; + _prefixTextFont = [UIFont systemFontOfSize:14]; + _bingTextFont = [UIFont systemFontOfSize:14]; + _stateEventTextFont = [UIFont italicSystemFontOfSize:14]; + _callNoticesTextFont = [UIFont italicSystemFontOfSize:14]; + _encryptedMessagesTextFont = [UIFont italicSystemFontOfSize:14]; + + _eventTypesFilterForMessages = nil; + + // Consider the shared app settings by default + _settings = [MXKAppSettings standardAppSettings]; + + defaultRoomSummaryUpdater = [MXRoomSummaryUpdater roomSummaryUpdaterForSession:matrixSession]; + defaultRoomSummaryUpdater.lastMessageEventTypesAllowList = MXKAppSettings.standardAppSettings.lastMessageEventTypesAllowList; + defaultRoomSummaryUpdater.ignoreRedactedEvent = !_settings.showRedactionsInRoomHistory; + defaultRoomSummaryUpdater.roomNameStringLocalizer = [MXKRoomNameStringLocalizer new]; + + linkDetector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeLink error:nil]; + + _markdownToHTMLRenderer = [MarkdownToHTMLRendererHardBreaks new]; + } + return self; +} + +- (void)initDateTimeFormatters +{ + // Prepare internal date formatter + dateFormatter = [[NSDateFormatter alloc] init]; + [dateFormatter setLocale:[[NSLocale alloc] initWithLocaleIdentifier:[[[NSBundle mainBundle] preferredLocalizations] objectAtIndex:0]]]; + [dateFormatter setFormatterBehavior:NSDateFormatterBehavior10_4]; + // Set default date format + [dateFormatter setDateFormat:@"MMM dd"]; + + // Create a time formatter to get time string by considered the current system time formatting. + timeFormatter = [[NSDateFormatter alloc] init]; + [timeFormatter setDateStyle:NSDateFormatterNoStyle]; + [timeFormatter setTimeStyle:NSDateFormatterShortStyle]; +} + +#pragma mark - Event formatter settings + +// Checks whether the event is related to an attachment and if it is supported +- (BOOL)isSupportedAttachment:(MXEvent*)event +{ + BOOL isSupportedAttachment = NO; + + if (event.eventType == MXEventTypeRoomMessage) + { + NSString *msgtype; + MXJSONModelSetString(msgtype, event.content[@"msgtype"]); + + NSString *urlField; + NSDictionary *fileField; + MXJSONModelSetString(urlField, event.content[@"url"]); + MXJSONModelSetDictionary(fileField, event.content[@"file"]); + + BOOL hasUrl = urlField.length; + BOOL hasFile = NO; + + if (fileField) + { + NSString *fileUrlField; + MXJSONModelSetString(fileUrlField, fileField[@"url"]); + NSString *fileIvField; + MXJSONModelSetString(fileIvField, fileField[@"iv"]); + NSDictionary *fileHashesField; + MXJSONModelSetDictionary(fileHashesField, fileField[@"hashes"]); + NSDictionary *fileKeyField; + MXJSONModelSetDictionary(fileKeyField, fileField[@"key"]); + + hasFile = fileUrlField.length && fileIvField.length && fileHashesField && fileKeyField; + } + + if ([msgtype isEqualToString:kMXMessageTypeImage]) + { + isSupportedAttachment = hasUrl || hasFile; + } + else if ([msgtype isEqualToString:kMXMessageTypeAudio]) + { + isSupportedAttachment = hasUrl || hasFile; + } + else if ([msgtype isEqualToString:kMXMessageTypeVideo]) + { + isSupportedAttachment = hasUrl || hasFile; + } + else if ([msgtype isEqualToString:kMXMessageTypeLocation]) + { + // Not supported yet + } + else if ([msgtype isEqualToString:kMXMessageTypeFile]) + { + isSupportedAttachment = hasUrl || hasFile; + } + } + else if (event.eventType == MXEventTypeSticker) + { + NSString *urlField; + NSDictionary *fileField; + MXJSONModelSetString(urlField, event.content[@"url"]); + MXJSONModelSetDictionary(fileField, event.content[@"file"]); + + BOOL hasUrl = urlField.length; + BOOL hasFile = NO; + + // @TODO: Check whether the encrypted sticker uses the same `file dict than other media + if (fileField) + { + NSString *fileUrlField; + MXJSONModelSetString(fileUrlField, fileField[@"url"]); + NSString *fileIvField; + MXJSONModelSetString(fileIvField, fileField[@"iv"]); + NSDictionary *fileHashesField; + MXJSONModelSetDictionary(fileHashesField, fileField[@"hashes"]); + NSDictionary *fileKeyField; + MXJSONModelSetDictionary(fileKeyField, fileField[@"key"]); + + hasFile = fileUrlField.length && fileIvField.length && fileHashesField && fileKeyField; + } + + isSupportedAttachment = hasUrl || hasFile; + } + return isSupportedAttachment; +} + + +#pragma mark event sender/target info + +- (NSString*)senderDisplayNameForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState +{ + // Check whether the sender name is updated by the current event. This happens in case of a + // newly joined member. Otherwise, fall back to the current display name defined in the provided + // room state (note: this room state is supposed to not take the new event into account). + return [self userDisplayNameFromContentInEvent:event withMembershipFilter:@"join"] ?: [roomState.members memberName:event.sender]; +} + +- (NSString*)targetDisplayNameForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState +{ + if (![event.type isEqualToString:kMXEventTypeStringRoomMember]) + { + return nil; // Non-membership events don't have a target + } + return [self userDisplayNameFromContentInEvent:event withMembershipFilter:nil] ?: [roomState.members memberName:event.stateKey]; +} + +- (NSString*)userDisplayNameFromContentInEvent:(MXEvent*)event withMembershipFilter:(NSString *)filter +{ + NSString* membership; + MXJSONModelSetString(membership, event.content[@"membership"]); + NSString* displayname; + MXJSONModelSetString(displayname, event.content[@"displayname"]); + + if (membership && (!filter || [membership isEqualToString:filter]) && [displayname length]) + { + return displayname; + } + + return nil; +} + +- (NSString*)senderAvatarUrlForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState +{ + // Check whether the avatar URL is updated by the current event. This happens in case of a + // newly joined member. Otherwise, fall back to the avatar URL defined in the provided room + // state (note: this room state is supposed to not take the new event into account). + NSString *avatarUrl = [self userAvatarUrlFromContentInEvent:event withMembershipFilter:@"join"] ?: [roomState.members memberWithUserId:event.sender].avatarUrl; + + // Handle here the case where no avatar is defined + return avatarUrl ?: [self fallbackAvatarUrlForUserId:event.sender]; +} + +- (NSString*)targetAvatarUrlForEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState +{ + if (![event.type isEqualToString:kMXEventTypeStringRoomMember]) + { + return nil; // Non-membership events don't have a target + } + NSString *avatarUrl = [self userAvatarUrlFromContentInEvent:event withMembershipFilter:nil] ?: [roomState.members memberWithUserId:event.stateKey].avatarUrl; + return avatarUrl ?: [self fallbackAvatarUrlForUserId:event.stateKey]; +} + +- (NSString*)userAvatarUrlFromContentInEvent:(MXEvent*)event withMembershipFilter:(NSString *)filter +{ + NSString* membership; + MXJSONModelSetString(membership, event.content[@"membership"]); + NSString* avatarUrl; + MXJSONModelSetString(avatarUrl, event.content[@"avatar_url"]); + + if (membership && (!filter || [membership isEqualToString:filter]) && [avatarUrl length]) + { + // We ignore non mxc avatar url + if ([avatarUrl hasPrefix:kMXContentUriScheme]) + { + return avatarUrl; + } + } + + return nil; +} + +- (NSString*)fallbackAvatarUrlForUserId:(NSString*)userId { + if ([MXSDKOptions sharedInstance].disableIdenticonUseForUserAvatar) + { + return nil; + } + return [mxSession.mediaManager urlOfIdenticon:userId]; +} + + +#pragma mark - Events to strings conversion methods +- (NSString*)stringFromEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState error:(MXKEventFormatterError*)error +{ + NSString *stringFromEvent; + NSAttributedString *attributedStringFromEvent = [self attributedStringFromEvent:event withRoomState:roomState error:error]; + if (*error == MXKEventFormatterErrorNone) + { + stringFromEvent = attributedStringFromEvent.string; + } + + return stringFromEvent; +} + +- (NSAttributedString *)attributedStringFromEvent:(MXEvent *)event withRoomState:(MXRoomState *)roomState error:(MXKEventFormatterError *)error +{ + // Check we can output the error + NSParameterAssert(error); + + *error = MXKEventFormatterErrorNone; + + // Filter the events according to their type. + if (_eventTypesFilterForMessages && ([_eventTypesFilterForMessages indexOfObject:event.type] == NSNotFound)) + { + // Ignore this event + return nil; + } + + BOOL isEventSenderMyUser = [event.sender isEqualToString:mxSession.myUserId]; + + // Check first whether the event has been redacted + NSString *redactedInfo = nil; + BOOL isRedacted = (event.redactedBecause != nil); + if (isRedacted) + { + // Check whether redacted information is required + if (_settings.showRedactionsInRoomHistory) + { + MXLogDebug(@"[MXKEventFormatter] Redacted event %@ (%@)", event.description, event.redactedBecause); + + NSString *redactorId = event.redactedBecause[@"sender"]; + NSString *redactedBy = @""; + // Consider live room state to resolve redactor name if no roomState is provided + MXRoomState *aRoomState = roomState ? roomState : [mxSession roomWithRoomId:event.roomId].dangerousSyncState; + redactedBy = [aRoomState.members memberName:redactorId]; + + NSString *redactedReason = (event.redactedBecause[@"content"])[@"reason"]; + if (redactedReason.length) + { + if ([redactorId isEqualToString:mxSession.myUserId]) + { + redactedBy = [NSString stringWithFormat:@"%@%@", [MatrixKitL10n noticeEventRedactedByYou], [MatrixKitL10n noticeEventRedactedReason:redactedReason]]; + } + else if (redactedBy.length) + { + redactedBy = [NSString stringWithFormat:@"%@%@", [MatrixKitL10n noticeEventRedactedBy:redactedBy], [MatrixKitL10n noticeEventRedactedReason:redactedReason]]; + } + else + { + redactedBy = [MatrixKitL10n noticeEventRedactedReason:redactedReason]; + } + } + else if ([redactorId isEqualToString:mxSession.myUserId]) + { + redactedBy = [MatrixKitL10n noticeEventRedactedByYou]; + } + else if (redactedBy.length) + { + redactedBy = [MatrixKitL10n noticeEventRedactedBy:redactedBy]; + } + + redactedInfo = [MatrixKitL10n noticeEventRedacted:redactedBy]; + } + } + + // Prepare returned description + NSString *displayText = nil; + NSAttributedString *attributedDisplayText = nil; + BOOL isRoomDirect = [mxSession roomWithRoomId:event.roomId].isDirect; + + // Prepare the display name of the sender + NSString *senderDisplayName; + senderDisplayName = roomState ? [self senderDisplayNameForEvent:event withRoomState:roomState] : event.sender; + + switch (event.eventType) + { + case MXEventTypeRoomName: + { + NSString *roomName; + MXJSONModelSetString(roomName, event.content[@"name"]); + + if (isRedacted) + { + if (!redactedInfo) + { + // Here the event is ignored (no display) + return nil; + } + roomName = redactedInfo; + } + + if (roomName.length) + { + if (isEventSenderMyUser) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomNameChangedByYouForDm:roomName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomNameChangedByYou:roomName]; + } + } + else + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomNameChangedForDm:senderDisplayName :roomName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomNameChanged:senderDisplayName :roomName]; + } + } + } + else + { + if (isEventSenderMyUser) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomNameRemovedByYouForDm]; + } + else + { + displayText = [MatrixKitL10n noticeRoomNameRemovedByYou]; + } + } + else + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomNameRemovedForDm:senderDisplayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomNameRemoved:senderDisplayName]; + } + } + } + break; + } + case MXEventTypeRoomTopic: + { + NSString *roomTopic; + MXJSONModelSetString(roomTopic, event.content[@"topic"]); + + if (isRedacted) + { + if (!redactedInfo) + { + // Here the event is ignored (no display) + return nil; + } + roomTopic = redactedInfo; + } + + if (roomTopic.length) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeTopicChangedByYou:roomTopic]; + } + else + { + displayText = [MatrixKitL10n noticeTopicChanged:senderDisplayName :roomTopic]; + } + } + else + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomTopicRemovedByYou]; + } + else + { + displayText = [MatrixKitL10n noticeRoomTopicRemoved:senderDisplayName]; + } + } + + break; + } + case MXEventTypeRoomMember: + { + // Presently only change on membership, display name and avatar are supported + + // Check whether the sender has updated his profile + if (event.isUserProfileChange) + { + // Is redacted event? + if (isRedacted) + { + if (!redactedInfo) + { + // Here the event is ignored (no display) + return nil; + } + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeProfileChangeRedactedByYou:redactedInfo]; + } + else + { + displayText = [MatrixKitL10n noticeProfileChangeRedacted:senderDisplayName :redactedInfo]; + } + } + else + { + // Check whether the display name has been changed + NSString *displayname; + MXJSONModelSetString(displayname, event.content[@"displayname"]); + NSString *prevDisplayname; + MXJSONModelSetString(prevDisplayname, event.prevContent[@"displayname"]); + + if (!displayname.length) + { + displayname = nil; + } + if (!prevDisplayname.length) + { + prevDisplayname = nil; + } + if ((displayname || prevDisplayname) && ([displayname isEqualToString:prevDisplayname] == NO)) + { + if (!prevDisplayname) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeDisplayNameSetByYou:displayname]; + } + else + { + displayText = [MatrixKitL10n noticeDisplayNameSet:event.sender :displayname]; + } + } + else if (!displayname) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeDisplayNameRemovedByYou]; + } + else + { + displayText = [MatrixKitL10n noticeDisplayNameRemoved:event.sender]; + } + } + else + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeDisplayNameChangedFromByYou:prevDisplayname :displayname]; + } + else + { + displayText = [MatrixKitL10n noticeDisplayNameChangedFrom:event.sender :prevDisplayname :displayname]; + } + } + } + + // Check whether the avatar has been changed + NSString *avatar; + MXJSONModelSetString(avatar, event.content[@"avatar_url"]); + NSString *prevAvatar; + MXJSONModelSetString(prevAvatar, event.prevContent[@"avatar_url"]); + + if (!avatar.length) + { + avatar = nil; + } + if (!prevAvatar.length) + { + prevAvatar = nil; + } + if ((prevAvatar || avatar) && ([avatar isEqualToString:prevAvatar] == NO)) + { + if (displayText) + { + displayText = [NSString stringWithFormat:@"%@ %@", displayText, [MatrixKitL10n noticeAvatarChangedToo]]; + } + else + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeAvatarUrlChangedByYou]; + } + else + { + displayText = [MatrixKitL10n noticeAvatarUrlChanged:senderDisplayName]; + } + } + } + } + } + else + { + // Retrieve membership + NSString* membership; + MXJSONModelSetString(membership, event.content[@"membership"]); + + // Prepare targeted member display name + NSString *targetDisplayName = event.stateKey; + + // Retrieve content displayname + NSString *contentDisplayname; + MXJSONModelSetString(contentDisplayname, event.content[@"displayname"]); + NSString *prevContentDisplayname; + MXJSONModelSetString(prevContentDisplayname, event.prevContent[@"displayname"]); + + // Consider here a membership change + if ([membership isEqualToString:@"invite"]) + { + if (event.content[@"third_party_invite"]) + { + if ([event.stateKey isEqualToString:mxSession.myUserId]) + { + displayText = [MatrixKitL10n noticeRoomThirdPartyRegisteredInviteByYou:event.content[@"third_party_invite"][@"display_name"]]; + } + else + { + displayText = [MatrixKitL10n noticeRoomThirdPartyRegisteredInvite:targetDisplayName :event.content[@"third_party_invite"][@"display_name"]]; + } + } + else + { + if ([MXCallManager isConferenceUser:event.stateKey]) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeConferenceCallRequestByYou]; + } + else + { + displayText = [MatrixKitL10n noticeConferenceCallRequest:senderDisplayName]; + } + } + else + { + // The targeted member display name (if any) is available in content + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomInviteByYou:targetDisplayName]; + } + else if ([targetDisplayName isEqualToString:mxSession.myUserId]) + { + displayText = [MatrixKitL10n noticeRoomInviteYou:senderDisplayName]; + } + else + { + if (contentDisplayname.length) + { + targetDisplayName = contentDisplayname; + } + + displayText = [MatrixKitL10n noticeRoomInvite:senderDisplayName :targetDisplayName]; + } + } + } + } + else if ([membership isEqualToString:@"join"]) + { + if ([MXCallManager isConferenceUser:event.stateKey]) + { + displayText = [MatrixKitL10n noticeConferenceCallStarted]; + } + else + { + // The targeted member display name (if any) is available in content + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomJoinByYou]; + } + else + { + if (contentDisplayname.length) + { + targetDisplayName = contentDisplayname; + } + + displayText = [MatrixKitL10n noticeRoomJoin:targetDisplayName]; + } + } + } + else if ([membership isEqualToString:@"leave"]) + { + NSString *prevMembership = nil; + if (event.prevContent) + { + MXJSONModelSetString(prevMembership, event.prevContent[@"membership"]); + } + + // The targeted member display name (if any) is available in prevContent + if (prevContentDisplayname.length) + { + targetDisplayName = prevContentDisplayname; + } + + if ([event.sender isEqualToString:event.stateKey]) + { + if ([MXCallManager isConferenceUser:event.stateKey]) + { + displayText = [MatrixKitL10n noticeConferenceCallFinished]; + } + else + { + if (prevMembership && [prevMembership isEqualToString:@"invite"]) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomRejectByYou]; + } + else + { + displayText = [MatrixKitL10n noticeRoomReject:targetDisplayName]; + } + } + else + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomLeaveByYou]; + } + else + { + displayText = [MatrixKitL10n noticeRoomLeave:targetDisplayName]; + } + } + } + } + else if (prevMembership) + { + if ([prevMembership isEqualToString:@"invite"]) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomWithdrawByYou:targetDisplayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomWithdraw:senderDisplayName :targetDisplayName]; + } + if (event.content[@"reason"]) + { + displayText = [displayText stringByAppendingString:[MatrixKitL10n noticeRoomReason:event.content[@"reason"]]]; + } + + } + else if ([prevMembership isEqualToString:@"join"]) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomKickByYou:targetDisplayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomKick:senderDisplayName :targetDisplayName]; + } + + // add reason if exists + if (event.content[@"reason"]) + { + displayText = [displayText stringByAppendingString:[MatrixKitL10n noticeRoomReason:event.content[@"reason"]]]; + } + } + else if ([prevMembership isEqualToString:@"ban"]) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomUnbanByYou:targetDisplayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomUnban:senderDisplayName :targetDisplayName]; + } + } + } + } + else if ([membership isEqualToString:@"ban"]) + { + // The targeted member display name (if any) is available in prevContent + if (prevContentDisplayname.length) + { + targetDisplayName = prevContentDisplayname; + } + + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomBanByYou:targetDisplayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomBan:senderDisplayName :targetDisplayName]; + } + if (event.content[@"reason"]) + { + displayText = [displayText stringByAppendingString:[MatrixKitL10n noticeRoomReason:event.content[@"reason"]]]; + } + } + + // Append redacted info if any + if (redactedInfo) + { + displayText = [NSString stringWithFormat:@"%@ %@", displayText, redactedInfo]; + } + } + + if (!displayText) + { + *error = MXKEventFormatterErrorUnexpected; + } + break; + } + case MXEventTypeRoomCreate: + { + NSString *creatorId; + MXJSONModelSetString(creatorId, event.content[@"creator"]); + + if (creatorId) + { + if ([creatorId isEqualToString:mxSession.myUserId]) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomCreatedByYouForDm]; + } + else + { + displayText = [MatrixKitL10n noticeRoomCreatedByYou]; + } + } + else + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomCreatedForDm:(roomState ? [roomState.members memberName:creatorId] : creatorId)]; + } + else + { + displayText = [MatrixKitL10n noticeRoomCreated:(roomState ? [roomState.members memberName:creatorId] : creatorId)]; + } + } + // Append redacted info if any + if (redactedInfo) + { + displayText = [NSString stringWithFormat:@"%@ %@", displayText, redactedInfo]; + } + } + break; + } + case MXEventTypeRoomJoinRules: + { + NSString *joinRule; + MXJSONModelSetString(joinRule, event.content[@"join_rule"]); + + if (joinRule) + { + if ([event.sender isEqualToString:mxSession.myUserId]) + { + if ([joinRule isEqualToString:kMXRoomJoinRulePublic]) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomJoinRulePublicByYouForDm]; + } + else + { + displayText = [MatrixKitL10n noticeRoomJoinRulePublicByYou]; + } + } + else if ([joinRule isEqualToString:kMXRoomJoinRuleInvite]) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomJoinRuleInviteByYouForDm]; + } + else + { + displayText = [MatrixKitL10n noticeRoomJoinRuleInviteByYou]; + } + } + } + else + { + NSString *displayName = roomState ? [roomState.members memberName:event.sender] : event.sender; + if ([joinRule isEqualToString:kMXRoomJoinRulePublic]) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomJoinRulePublicForDm:displayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomJoinRulePublic:displayName]; + } + } + else if ([joinRule isEqualToString:kMXRoomJoinRuleInvite]) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomJoinRuleInviteForDm:displayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomJoinRuleInvite:displayName]; + } + } + } + + if (!displayText) + { + // use old string for non-handled cases: "knock" and "private" + displayText = [MatrixKitL10n noticeRoomJoinRule:joinRule]; + } + + // Append redacted info if any + if (redactedInfo) + { + displayText = [NSString stringWithFormat:@"%@ %@", displayText, redactedInfo]; + } + } + break; + } + case MXEventTypeRoomPowerLevels: + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomPowerLevelIntroForDm]; + } + else + { + displayText = [MatrixKitL10n noticeRoomPowerLevelIntro]; + } + NSDictionary *users; + MXJSONModelSetDictionary(users, event.content[@"users"]); + + for (NSString *key in users.allKeys) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 %@: %@", displayText, key, [users objectForKey:key]]; + } + if (event.content[@"users_default"]) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 %@: %@", displayText, [MatrixKitL10n default], event.content[@"users_default"]]; + } + + displayText = [NSString stringWithFormat:@"%@\n%@", displayText, [MatrixKitL10n noticeRoomPowerLevelActingRequirement]]; + if (event.content[@"ban"]) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 ban: %@", displayText, event.content[@"ban"]]; + } + if (event.content[@"kick"]) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 kick: %@", displayText, event.content[@"kick"]]; + } + if (event.content[@"redact"]) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 redact: %@", displayText, event.content[@"redact"]]; + } + if (event.content[@"invite"]) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 invite: %@", displayText, event.content[@"invite"]]; + } + + displayText = [NSString stringWithFormat:@"%@\n%@", displayText, [MatrixKitL10n noticeRoomPowerLevelEventRequirement]]; + + NSDictionary *events; + MXJSONModelSetDictionary(events, event.content[@"events"]); + for (NSString *key in events.allKeys) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 %@: %@", displayText, key, [events objectForKey:key]]; + } + if (event.content[@"events_default"]) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 %@: %@", displayText, @"events_default", event.content[@"events_default"]]; + } + if (event.content[@"state_default"]) + { + displayText = [NSString stringWithFormat:@"%@\n\u2022 %@: %@", displayText, @"state_default", event.content[@"state_default"]]; + } + + // Append redacted info if any + if (redactedInfo) + { + displayText = [NSString stringWithFormat:@"%@\n %@", displayText, redactedInfo]; + } + break; + } + case MXEventTypeRoomAliases: + { + NSArray *aliases; + MXJSONModelSetArray(aliases, event.content[@"aliases"]); + if (aliases) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomAliasesForDm:[aliases componentsJoinedByString:@", "]]; + } + else + { + displayText = [MatrixKitL10n noticeRoomAliases:[aliases componentsJoinedByString:@", "]]; + } + // Append redacted info if any + if (redactedInfo) + { + displayText = [NSString stringWithFormat:@"%@\n %@", displayText, redactedInfo]; + } + } + break; + } + case MXEventTypeRoomRelatedGroups: + { + NSArray *groups; + MXJSONModelSetArray(groups, event.content[@"groups"]); + if (groups) + { + displayText = [MatrixKitL10n noticeRoomRelatedGroups:[groups componentsJoinedByString:@", "]]; + // Append redacted info if any + if (redactedInfo) + { + displayText = [NSString stringWithFormat:@"%@\n %@", displayText, redactedInfo]; + } + } + break; + } + case MXEventTypeRoomEncrypted: + { + // Is redacted? + if (isRedacted) + { + if (!redactedInfo) + { + // Here the event is ignored (no display) + return nil; + } + displayText = redactedInfo; + } + else + { + // If the message still appears as encrypted, there was propably an error for decryption + // Show this error + if (event.decryptionError) + { + NSString *errorDescription; + + if ([event.decryptionError.domain isEqualToString:MXDecryptingErrorDomain] + && [MXKAppSettings standardAppSettings].hideUndecryptableEvents) + { + // Hide this event, it cannot be decrypted + displayText = nil; + } + else if ([event.decryptionError.domain isEqualToString:MXDecryptingErrorDomain] + && event.decryptionError.code == MXDecryptingErrorUnknownInboundSessionIdCode) + { + // Make the unknown inbound session id error description more user friendly + errorDescription = [MatrixKitL10n noticeCryptoErrorUnknownInboundSessionId]; + } + else if ([event.decryptionError.domain isEqualToString:MXDecryptingErrorDomain] + && event.decryptionError.code == MXDecryptingErrorDuplicateMessageIndexCode) + { + // Hide duplicate message warnings + MXLogDebug(@"[MXKEventFormatter] Warning: Duplicate message with error description %@", event.decryptionError); + displayText = nil; + } + else + { + errorDescription = event.decryptionError.localizedDescription; + } + + if (errorDescription) + { + displayText = [MatrixKitL10n noticeCryptoUnableToDecrypt:errorDescription]; + } + } + else + { + displayText = [MatrixKitL10n noticeEncryptedMessage]; + } + } + + break; + } + case MXEventTypeRoomEncryption: + { + NSString *algorithm; + MXJSONModelSetString(algorithm, event.content[@"algorithm"]); + + if (isRedacted) + { + if (!redactedInfo) + { + // Here the event is ignored (no display) + return nil; + } + algorithm = redactedInfo; + } + + if ([algorithm isEqualToString:kMXCryptoMegolmAlgorithm]) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeEncryptionEnabledOkByYou]; + } + else + { + displayText = [MatrixKitL10n noticeEncryptionEnabledOk:senderDisplayName]; + } + } + else + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeEncryptionEnabledUnknownAlgorithmByYou:algorithm]; + } + else + { + displayText = [MatrixKitL10n noticeEncryptionEnabledUnknownAlgorithm:senderDisplayName :algorithm]; + } + } + + break; + } + case MXEventTypeRoomHistoryVisibility: + { + if (isRedacted) + { + displayText = redactedInfo; + } + else + { + MXRoomHistoryVisibility historyVisibility; + MXJSONModelSetString(historyVisibility, event.content[@"history_visibility"]); + + if (historyVisibility) + { + if ([historyVisibility isEqualToString:kMXRoomHistoryVisibilityWorldReadable]) + { + if (!isRoomDirect) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToAnyoneByYou]; + } + else + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToAnyone:senderDisplayName]; + } + } + } + else if ([historyVisibility isEqualToString:kMXRoomHistoryVisibilityShared]) + { + if (isEventSenderMyUser) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersByYouForDm]; + } + else + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersByYou]; + } + } + else + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersForDm:senderDisplayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembers:senderDisplayName]; + } + } + } + else if ([historyVisibility isEqualToString:kMXRoomHistoryVisibilityInvited]) + { + if (isEventSenderMyUser) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersFromInvitedPointByYouForDm]; + } + else + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersFromInvitedPointByYou]; + } + } + else + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersFromInvitedPointForDm:senderDisplayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersFromInvitedPoint:senderDisplayName]; + } + } + } + else if ([historyVisibility isEqualToString:kMXRoomHistoryVisibilityJoined]) + { + if (isEventSenderMyUser) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersFromJoinedPointByYouForDm]; + } + else + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersFromJoinedPointByYou]; + } + } + else + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersFromJoinedPointForDm:senderDisplayName]; + } + else + { + displayText = [MatrixKitL10n noticeRoomHistoryVisibleToMembersFromJoinedPoint:senderDisplayName]; + } + } + } + } + } + break; + } + case MXEventTypeRoomMessage: + { + // Is redacted? + if (isRedacted) + { + if (!redactedInfo) + { + // Here the event is ignored (no display) + return nil; + } + displayText = redactedInfo; + } + else if (event.isEditEvent) + { + return nil; + } + else + { + NSString *msgtype; + MXJSONModelSetString(msgtype, event.content[@"msgtype"]); + + NSString *body; + BOOL isHTML = NO; + NSString *eventThreadIdentifier = event.threadIdentifier; + + // Use the HTML formatted string if provided + if ([event.content[@"format"] isEqualToString:kMXRoomMessageFormatHTML]) + { + isHTML =YES; + MXJSONModelSetString(body, event.content[@"formatted_body"]); + } + else if (eventThreadIdentifier) + { + isHTML = YES; + MXJSONModelSetString(body, event.content[@"body"]); + MXEvent *threadRootEvent = [mxSession.store eventWithEventId:eventThreadIdentifier + inRoom:event.roomId]; + + NSString *threadRootEventContent; + MXJSONModelSetString(threadRootEventContent, threadRootEvent.content[@"body"]); + body = [NSString stringWithFormat:@"
In reply to %@
%@
%@", + [MXTools permalinkToEvent:eventThreadIdentifier inRoom:event.roomId], + [MXTools permalinkToUserWithUserId:threadRootEvent.sender], + threadRootEvent.sender, + threadRootEventContent, + body]; + + } + else + { + MXJSONModelSetString(body, event.content[@"body"]); + } + + if (body) + { + if ([msgtype isEqualToString:kMXMessageTypeImage]) + { + body = body? body : [MatrixKitL10n noticeImageAttachment]; + // Check attachment validity + if (![self isSupportedAttachment:event]) + { + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + body = [MatrixKitL10n noticeInvalidAttachment]; + *error = MXKEventFormatterErrorUnsupported; + } + } + else if ([msgtype isEqualToString:kMXMessageTypeAudio]) + { + body = body? body : [MatrixKitL10n noticeAudioAttachment]; + if (![self isSupportedAttachment:event]) + { + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + if (_isForSubtitle || !_settings.showUnsupportedEventsInRoomHistory) + { + body = [MatrixKitL10n noticeInvalidAttachment]; + } + else + { + body = [MatrixKitL10n noticeUnsupportedAttachment:event.description]; + } + *error = MXKEventFormatterErrorUnsupported; + } + } + else if ([msgtype isEqualToString:kMXMessageTypeVideo]) + { + body = body? body : [MatrixKitL10n noticeVideoAttachment]; + if (![self isSupportedAttachment:event]) + { + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + if (_isForSubtitle || !_settings.showUnsupportedEventsInRoomHistory) + { + body = [MatrixKitL10n noticeInvalidAttachment]; + } + else + { + body = [MatrixKitL10n noticeUnsupportedAttachment:event.description]; + } + *error = MXKEventFormatterErrorUnsupported; + } + } + else if ([msgtype isEqualToString:kMXMessageTypeLocation]) + { + body = body? body : [MatrixKitL10n noticeLocationAttachment]; + if (![self isSupportedAttachment:event]) + { + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + if (_isForSubtitle || !_settings.showUnsupportedEventsInRoomHistory) + { + body = [MatrixKitL10n noticeInvalidAttachment]; + } + else + { + body = [MatrixKitL10n noticeUnsupportedAttachment:event.description]; + } + *error = MXKEventFormatterErrorUnsupported; + } + } + else if ([msgtype isEqualToString:kMXMessageTypeFile]) + { + body = body? body : [MatrixKitL10n noticeFileAttachment]; + // Check attachment validity + if (![self isSupportedAttachment:event]) + { + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + body = [MatrixKitL10n noticeInvalidAttachment]; + *error = MXKEventFormatterErrorUnsupported; + } + } + + if (isHTML) + { + // Build the attributed string from the HTML string + attributedDisplayText = [self renderHTMLString:body forEvent:event withRoomState:roomState]; + } + else + { + // Build the attributed string with the right font and color for the event + attributedDisplayText = [self renderString:body forEvent:event]; + } + + // Build the full emote string after the body message formatting + if ([msgtype isEqualToString:kMXMessageTypeEmote]) + { + __block NSUInteger insertAt = 0; + + // For replies, look for the end of the parent message + // This helps us insert the emote prefix in the right place + NSDictionary *relatesTo; + MXJSONModelSetDictionary(relatesTo, event.content[@"m.relates_to"]); + if ([relatesTo[@"m.in_reply_to"] isKindOfClass:NSDictionary.class] || event.isInThread) + { + [attributedDisplayText enumerateAttribute:kMXKToolsBlockquoteMarkAttribute + inRange:NSMakeRange(0, attributedDisplayText.length) + options:(NSAttributedStringEnumerationReverse) + usingBlock:^(id value, NSRange range, BOOL *stop) { + insertAt = range.location; + *stop = YES; + }]; + } + + // Always use default font and color for the emote prefix + NSString *emotePrefix = [NSString stringWithFormat:@"* %@ ", senderDisplayName]; + NSAttributedString *attributedEmotePrefix = + [[NSAttributedString alloc] initWithString:emotePrefix + attributes:@{ + NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: _defaultTextFont + }]; + + // Then, insert the emote prefix at the start of the message + // (location varies depending on whether it was a reply) + NSMutableAttributedString *newAttributedDisplayText = + [[NSMutableAttributedString alloc] initWithAttributedString:attributedDisplayText]; + [newAttributedDisplayText insertAttributedString:attributedEmotePrefix + atIndex:insertAt]; + attributedDisplayText = newAttributedDisplayText; + } + } + } + break; + } + case MXEventTypeRoomMessageFeedback: + { + NSString *type; + MXJSONModelSetString(type, event.content[@"type"]); + NSString *eventId; + MXJSONModelSetString(eventId, event.content[@"target_event_id"]); + + if (type && eventId) + { + displayText = [MatrixKitL10n noticeFeedback:eventId :type]; + // Append redacted info if any + if (redactedInfo) + { + displayText = [NSString stringWithFormat:@"%@ %@", displayText, redactedInfo]; + } + } + break; + } + case MXEventTypeRoomRedaction: + { + NSString *eventId = event.redacts; + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeRedactionByYou:eventId]; + } + else + { + displayText = [MatrixKitL10n noticeRedaction:senderDisplayName :eventId]; + } + break; + } + case MXEventTypeRoomThirdPartyInvite: + { + NSString *displayname; + MXJSONModelSetString(displayname, event.content[@"display_name"]); + if (displayname) + { + if (isEventSenderMyUser) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomThirdPartyInviteByYouForDm:displayname]; + } + else + { + displayText = [MatrixKitL10n noticeRoomThirdPartyInviteByYou:displayname]; + } + } + else + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomThirdPartyInviteForDm:senderDisplayName :displayname]; + } + else + { + displayText = [MatrixKitL10n noticeRoomThirdPartyInvite:senderDisplayName :displayname]; + } + } + } + else + { + // Consider the invite has been revoked + MXJSONModelSetString(displayname, event.prevContent[@"display_name"]); + if (isEventSenderMyUser) + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomThirdPartyRevokedInviteByYouForDm:displayname]; + } + else + { + displayText = [MatrixKitL10n noticeRoomThirdPartyRevokedInviteByYou:displayname]; + } + } + else + { + if (isRoomDirect) + { + displayText = [MatrixKitL10n noticeRoomThirdPartyRevokedInviteForDm:senderDisplayName :displayname]; + } + else + { + displayText = [MatrixKitL10n noticeRoomThirdPartyRevokedInvite:senderDisplayName :displayname]; + } + } + } + break; + } + case MXEventTypeCallInvite: + { + MXCallInviteEventContent *callInviteEventContent = [MXCallInviteEventContent modelFromJSON:event.content]; + + if (callInviteEventContent.isVideoCall) + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticePlacedVideoCallByYou]; + } + else + { + displayText = [MatrixKitL10n noticePlacedVideoCall:senderDisplayName]; + } + } + else + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticePlacedVoiceCallByYou]; + } + else + { + displayText = [MatrixKitL10n noticePlacedVoiceCall:senderDisplayName]; + } + } + break; + } + case MXEventTypeCallAnswer: + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeAnsweredVideoCallByYou]; + } + else + { + displayText = [MatrixKitL10n noticeAnsweredVideoCall:senderDisplayName]; + } + break; + } + case MXEventTypeCallHangup: + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeEndedVideoCallByYou]; + } + else + { + displayText = [MatrixKitL10n noticeEndedVideoCall:senderDisplayName]; + } + break; + } + case MXEventTypeCallReject: + { + if (isEventSenderMyUser) + { + displayText = [MatrixKitL10n noticeDeclinedVideoCallByYou]; + } + else + { + displayText = [MatrixKitL10n noticeDeclinedVideoCall:senderDisplayName]; + } + break; + } + case MXEventTypeSticker: + { + // Is redacted? + if (isRedacted) + { + if (!redactedInfo) + { + // Here the event is ignored (no display) + return nil; + } + displayText = redactedInfo; + } + else + { + NSString *body; + MXJSONModelSetString(body, event.content[@"body"]); + + // Check sticker validity + if (![self isSupportedAttachment:event]) + { + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported sticker %@", event.description); + body = [MatrixKitL10n noticeInvalidAttachment]; + *error = MXKEventFormatterErrorUnsupported; + } + + displayText = body? body : [MatrixKitL10n noticeSticker]; + } + break; + } + + default: + *error = MXKEventFormatterErrorUnknownEventType; + break; + } + + if (!attributedDisplayText && displayText) + { + // Build the attributed string with the right font and color for the event + attributedDisplayText = [self renderString:displayText forEvent:event]; + } + + if (!attributedDisplayText) + { + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported event %@)", event.description); + if (_settings.showUnsupportedEventsInRoomHistory) + { + if (MXKEventFormatterErrorNone == *error) + { + *error = MXKEventFormatterErrorUnsupported; + } + + NSString *shortDescription = nil; + + switch (*error) + { + case MXKEventFormatterErrorUnsupported: + shortDescription = [MatrixKitL10n noticeErrorUnsupportedEvent]; + break; + case MXKEventFormatterErrorUnexpected: + shortDescription = [MatrixKitL10n noticeErrorUnexpectedEvent]; + break; + case MXKEventFormatterErrorUnknownEventType: + shortDescription = [MatrixKitL10n noticeErrorUnknownEventType]; + break; + + default: + break; + } + + if (!_isForSubtitle) + { + // Return event content as unsupported event + displayText = [NSString stringWithFormat:@"%@: %@", shortDescription, event.description]; + } + else + { + // Return a short error description + displayText = shortDescription; + } + + // Build the attributed string with the right font for the event + attributedDisplayText = [self renderString:displayText forEvent:event]; + } + } + + return attributedDisplayText; +} + +- (NSAttributedString*)attributedStringFromEvents:(NSArray*)events withRoomState:(MXRoomState*)roomState error:(MXKEventFormatterError*)error +{ + // TODO: Do a full summary + return nil; +} + +- (NSAttributedString*)renderString:(NSString*)string forEvent:(MXEvent*)event +{ + // Sanity check + if (!string) + { + return nil; + } + + NSMutableAttributedString *str = [[NSMutableAttributedString alloc] initWithString:string]; + + NSRange wholeString = NSMakeRange(0, str.length); + + // Apply color and font corresponding to the event state + [str addAttribute:NSForegroundColorAttributeName value:[self textColorForEvent:event] range:wholeString]; + [str addAttribute:NSFontAttributeName value:[self fontForEvent:event] range:wholeString]; + + // If enabled, make links clickable + if (!([[_settings httpLinkScheme] isEqualToString: @"http"] && + [[_settings httpsLinkScheme] isEqualToString: @"https"])) + { + NSArray *matches = [linkDetector matchesInString:[str string] options:0 range:wholeString]; + for (NSTextCheckingResult *match in matches) + { + NSRange matchRange = [match range]; + NSURL *matchUrl = [match URL]; + NSURLComponents *url = [[NSURLComponents new] initWithURL:matchUrl resolvingAgainstBaseURL:NO]; + + if (url) + { + if ([url.scheme isEqualToString: @"http"]) + { + url.scheme = [_settings httpLinkScheme]; + } + else if ([url.scheme isEqualToString: @"https"]) + { + url.scheme = [_settings httpsLinkScheme]; + } + + if (url.URL) + { + [str addAttribute:NSLinkAttributeName value:url.URL range:matchRange]; + } + } + } + } + + // Apply additional treatments + return [self postRenderAttributedString:str]; +} + +- (NSAttributedString*)renderHTMLString:(NSString*)htmlString forEvent:(MXEvent*)event withRoomState:(MXRoomState*)roomState +{ + NSString *html = htmlString; + + // Special treatment for "In reply to" message + if (event.isReplyEvent || event.isInThread) + { + html = [self renderReplyTo:html withRoomState:roomState]; + } + + // Apply the css style that corresponds to the event state + UIFont *font = [self fontForEvent:event]; + + // Do some sanitisation before finalizing the string + MXWeakify(self); + DTHTMLAttributedStringBuilderWillFlushCallback sanitizeCallback = ^(DTHTMLElement *element) { + MXStrongifyAndReturnIfNil(self); + [element sanitizeWith:self.allowedHTMLTags bodyFont:font imageHandler:self.htmlImageHandler]; + }; + + NSDictionary *options = @{ + DTUseiOS6Attributes: @(YES), // Enable it to be able to display the attributed string in a UITextView + DTDefaultFontFamily: font.familyName, + DTDefaultFontName: font.fontName, + DTDefaultFontSize: @(font.pointSize), + DTDefaultTextColor: [self textColorForEvent:event], + DTDefaultLinkDecoration: @(NO), + DTDefaultStyleSheet: dtCSS, + DTWillFlushBlockCallBack: sanitizeCallback + }; + + // Do not use the default HTML renderer of NSAttributedString because this method + // runs on the UI thread which we want to avoid because renderHTMLString is called + // most of the time from a background thread. + // Use DTCoreText HTML renderer instead. + // Using DTCoreText, which renders static string, helps to avoid code injection attacks + // that could happen with the default HTML renderer of NSAttributedString which is a + // webview. + NSAttributedString *str = [[NSAttributedString alloc] initWithHTMLData:[html dataUsingEncoding:NSUTF8StringEncoding] options:options documentAttributes:NULL]; + + // Apply additional treatments + str = [self postRenderAttributedString:str]; + + // Finalize the attributed string by removing DTCoreText artifacts (Trim trailing newlines). + str = [MXKTools removeDTCoreTextArtifacts:str]; + + // Finalize HTML blockquote blocks marking + str = [MXKTools removeMarkedBlockquotesArtifacts:str]; + + return str; +} + +/** + Special treatment for "In reply to" message. + + According to https://docs.google.com/document/d/1BPd4lBrooZrWe_3s_lHw_e-Dydvc7bXbm02_sV2k6Sc/edit. + + @param htmlString an html string containing a reply-to message. + @param roomState the room state right before the event. + @return a displayable internationalised html string. + */ +- (NSString*)renderReplyTo:(NSString*)htmlString withRoomState:(MXRoomState*)roomState +{ + NSString *html = htmlString; + + static NSRegularExpression *htmlATagRegex; + + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + htmlATagRegex = [NSRegularExpression regularExpressionWithPattern:kHTMLATagRegexPattern options:NSRegularExpressionCaseInsensitive error:nil]; + }); + + __block NSUInteger hrefCount = 0; + + __block NSRange inReplyToLinkRange = NSMakeRange(NSNotFound, 0); + __block NSRange inReplyToTextRange = NSMakeRange(NSNotFound, 0); + __block NSRange userIdRange = NSMakeRange(NSNotFound, 0); + + [htmlATagRegex enumerateMatchesInString:html + options:0 + range:NSMakeRange(0, html.length) + usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags flags, BOOL *stop) { + + if (hrefCount > 1) + { + *stop = YES; + } + else if (hrefCount == 0 && match.numberOfRanges >= 2) + { + inReplyToLinkRange = [match rangeAtIndex:1]; + inReplyToTextRange = [match rangeAtIndex:2]; + } + else if (hrefCount == 1 && match.numberOfRanges >= 2) + { + userIdRange = [match rangeAtIndex:2]; + } + + hrefCount++; + }]; + + // Note: Take care to replace text starting with the end + + // Replace mxid + // By Display name + // To replace the user Matrix ID by his display name when available. + // This link is the second HTML node of the html string + + if (userIdRange.location != NSNotFound) + { + NSString *userId = [html substringWithRange:userIdRange]; + + NSString *senderDisplayName = [roomState.members memberName:userId]; + + if (senderDisplayName) + { + html = [html stringByReplacingCharactersInRange:userIdRange withString:senderDisplayName]; + } + } + + // Replace
In reply to + // By
['In reply to' from resources] + // To disable the link and to localize the "In reply to" string + // This link is the first HTML node of the html string + + if (inReplyToTextRange.location != NSNotFound) + { + html = [html stringByReplacingCharactersInRange:inReplyToTextRange withString:[MatrixKitL10n noticeInReplyTo]]; + } + + if (inReplyToLinkRange.location != NSNotFound) + { + html = [html stringByReplacingCharactersInRange:inReplyToLinkRange withString:@"#"]; + } + + return html; +} + +- (NSAttributedString*)postRenderAttributedString:(NSAttributedString*)attributedString +{ + if (!attributedString) + { + return nil; + } + + NSInteger enabledMatrixIdsBitMask= 0; + + // If enabled, make user id clickable + if (_treatMatrixUserIdAsLink) + { + enabledMatrixIdsBitMask |= MXKTOOLS_USER_IDENTIFIER_BITWISE; + } + + // If enabled, make room id clickable + if (_treatMatrixRoomIdAsLink) + { + enabledMatrixIdsBitMask |= MXKTOOLS_ROOM_IDENTIFIER_BITWISE; + } + + // If enabled, make room alias clickable + if (_treatMatrixRoomAliasAsLink) + { + enabledMatrixIdsBitMask |= MXKTOOLS_ROOM_ALIAS_BITWISE; + } + + // If enabled, make event id clickable + if (_treatMatrixEventIdAsLink) + { + enabledMatrixIdsBitMask |= MXKTOOLS_EVENT_IDENTIFIER_BITWISE; + } + + // If enabled, make group id clickable + if (_treatMatrixGroupIdAsLink) + { + enabledMatrixIdsBitMask |= MXKTOOLS_GROUP_IDENTIFIER_BITWISE; + } + + return [MXKTools createLinksInAttributedString:attributedString forEnabledMatrixIds:enabledMatrixIdsBitMask]; +} + +- (NSAttributedString *)renderString:(NSString *)string withPrefix:(NSString *)prefix forEvent:(MXEvent *)event +{ + NSMutableAttributedString *str; + + if (prefix) + { + str = [[NSMutableAttributedString alloc] initWithString:prefix]; + + // Apply the prefix font and color on the prefix + NSRange prefixRange = NSMakeRange(0, prefix.length); + [str addAttribute:NSForegroundColorAttributeName value:_prefixTextColor range:prefixRange]; + [str addAttribute:NSFontAttributeName value:_prefixTextFont range:prefixRange]; + + // And append the string rendered according to event state + [str appendAttributedString:[self renderString:string forEvent:event]]; + + return str; + } + else + { + // Use the legacy method + return [self renderString:string forEvent:event]; + } +} + +- (void)setDefaultCSS:(NSString*)defaultCSS +{ + // Make sure we mark HTML blockquote blocks for later computation + _defaultCSS = [NSString stringWithFormat:@"%@%@", [MXKTools cssToMarkBlockquotes], defaultCSS]; + + dtCSS = [[DTCSSStylesheet alloc] initWithStyleBlock:_defaultCSS]; +} + +#pragma mark - MXRoomSummaryUpdating +- (BOOL)session:(MXSession *)session updateRoomSummary:(MXRoomSummary *)summary withStateEvents:(NSArray *)stateEvents roomState:(MXRoomState *)roomState +{ + // We build strings containing the sender displayname (ex: "Bob: Hello!") + // If a sender changes his displayname, we need to update the lastMessage. + MXRoomLastMessage *lastMessage; + for (MXEvent *event in stateEvents) + { + if (event.isUserProfileChange) + { + if (!lastMessage) + { + // Load lastMessageEvent on demand to save I/O + lastMessage = summary.lastMessage; + } + + if ([event.sender isEqualToString:lastMessage.sender]) + { + // The last message must be recomputed + [summary resetLastMessage:nil failure:nil commit:YES]; + break; + } + } + else if (event.eventType == MXEventTypeRoomJoinRules) + { + summary.joinRule = roomState.joinRule; + } + } + + return [defaultRoomSummaryUpdater session:session updateRoomSummary:summary withStateEvents:stateEvents roomState:roomState]; +} + +- (BOOL)session:(MXSession *)session updateRoomSummary:(MXRoomSummary *)summary withLastEvent:(MXEvent *)event eventState:(MXRoomState *)eventState roomState:(MXRoomState *)roomState +{ + // Use the default updater as first pass + MXRoomLastMessage *currentlastMessage = summary.lastMessage; + BOOL updated = [defaultRoomSummaryUpdater session:session updateRoomSummary:summary withLastEvent:event eventState:eventState roomState:roomState]; + if (updated) + { + // Then customise + + // Compute the text message + // Note that we use the current room state (roomState) because when we display + // users displaynames, we want current displaynames + MXKEventFormatterError error; + NSString *lastMessageString = [self stringFromEvent:event withRoomState:roomState error:&error]; + + if (0 == lastMessageString.length) + { + // @TODO: there is a conflict with what [defaultRoomSummaryUpdater updateRoomSummary] did :/ + updated = NO; + // Restore the previous lastMessageEvent + [summary updateLastMessage:currentlastMessage]; + } + else + { + summary.lastMessage.text = lastMessageString; + + if (summary.lastMessage.others == nil) + { + summary.lastMessage.others = [NSMutableDictionary dictionary]; + } + + // Store the potential error + summary.lastMessage.others[@"mxkEventFormatterError"] = @(error); + + summary.lastMessage.others[@"lastEventDate"] = [self dateStringFromEvent:event withTime:YES]; + + // Check whether the sender name has to be added + NSString *prefix = nil; + + if (event.eventType == MXEventTypeRoomMessage) + { + NSString *msgtype = event.content[@"msgtype"]; + if ([msgtype isEqualToString:kMXMessageTypeEmote] == NO) + { + NSString *senderDisplayName = [self senderDisplayNameForEvent:event withRoomState:roomState]; + prefix = [NSString stringWithFormat:@"%@: ", senderDisplayName]; + } + } + else if (event.eventType == MXEventTypeSticker) + { + NSString *senderDisplayName = [self senderDisplayNameForEvent:event withRoomState:roomState]; + prefix = [NSString stringWithFormat:@"%@: ", senderDisplayName]; + } + + // Compute the attribute text message + summary.lastMessage.attributedText = [self renderString:summary.lastMessage.text withPrefix:prefix forEvent:event]; + } + } + + return updated; +} + +- (BOOL)session:(MXSession *)session updateRoomSummary:(MXRoomSummary *)summary withServerRoomSummary:(MXRoomSyncSummary *)serverRoomSummary roomState:(MXRoomState *)roomState +{ + return [defaultRoomSummaryUpdater session:session updateRoomSummary:summary withServerRoomSummary:serverRoomSummary roomState:roomState]; +} + + +#pragma mark - Conversion private methods + +/** + Get the text color to use according to the event state. + + @param event the event. + @return the text color. + */ +- (UIColor*)textColorForEvent:(MXEvent*)event +{ + // Select the text color + UIColor *textColor; + + // Check whether an error occurred during event formatting. + if (event.mxkEventFormatterError != MXKEventFormatterErrorNone) + { + textColor = _errorTextColor; + } + // Check whether the message is highlighted. + else if (event.mxkIsHighlighted || (event.isInThread && ![event.sender isEqualToString:mxSession.myUserId])) + { + textColor = _bingTextColor; + } + else + { + // Consider here the sending state of the event, and the property `isForSubtitle`. + switch (event.sentState) + { + case MXEventSentStateSent: + if (_isForSubtitle) + { + textColor = _subTitleTextColor; + } + else + { + textColor = _defaultTextColor; + } + break; + case MXEventSentStateEncrypting: + textColor = _encryptingTextColor; + break; + case MXEventSentStatePreparing: + case MXEventSentStateUploading: + case MXEventSentStateSending: + textColor = _sendingTextColor; + break; + case MXEventSentStateFailed: + textColor = _errorTextColor; + break; + default: + if (_isForSubtitle) + { + textColor = _subTitleTextColor; + } + else + { + textColor = _defaultTextColor; + } + break; + } + } + + return textColor; +} + +/** + Get the text font to use according to the event state. + + @param event the event. + @return the text font. + */ +- (UIFont*)fontForEvent:(MXEvent*)event +{ + // Select text font + UIFont *font = _defaultTextFont; + if (event.isState) + { + font = _stateEventTextFont; + } + else if (event.eventType == MXEventTypeCallInvite || event.eventType == MXEventTypeCallAnswer || event.eventType == MXEventTypeCallHangup) + { + font = _callNoticesTextFont; + } + else if (event.mxkIsHighlighted || (event.isInThread && ![event.sender isEqualToString:mxSession.myUserId])) + { + font = _bingTextFont; + } + else if (event.eventType == MXEventTypeRoomEncrypted) + { + font = _encryptedMessagesTextFont; + } + else if (!_isForSubtitle && event.eventType == MXEventTypeRoomMessage && (_emojiOnlyTextFont || _singleEmojiTextFont)) + { + NSString *message; + MXJSONModelSetString(message, event.content[@"body"]); + + if (_emojiOnlyTextFont && [MXKTools isEmojiOnlyString:message]) + { + font = _emojiOnlyTextFont; + } + else if (_singleEmojiTextFont && [MXKTools isSingleEmojiString:message]) + { + font = _singleEmojiTextFont; + } + } + return font; +} + +#pragma mark - Conversion tools + +- (NSString *)htmlStringFromMarkdownString:(NSString *)markdownString +{ + NSString *htmlString = [_markdownToHTMLRenderer renderToHTMLWithMarkdown:markdownString]; + + // Strip off the trailing newline, if it exists. + if ([htmlString hasSuffix:@"\n"]) + { + htmlString = [htmlString substringToIndex:htmlString.length - 1]; + } + + // Strip start and end

tags else you get 'orrible spacing. + // But only do this if it's a single paragraph we're dealing with, + // otherwise we'll produce some garbage (`something

another`). + if ([htmlString hasPrefix:@"

"] && [htmlString hasSuffix:@"

"]) + { + NSArray *components = [htmlString componentsSeparatedByString:@"

"]; + NSUInteger paragrapsCount = components.count - 1; + + if (paragrapsCount == 1) { + htmlString = [htmlString substringFromIndex:3]; + htmlString = [htmlString substringToIndex:htmlString.length - 4]; + } + } + + return htmlString; +} + +#pragma mark - Timestamp formatting + +- (NSString*)dateStringFromDate:(NSDate *)date withTime:(BOOL)time +{ + // Get first date string without time (if a date format is defined, else only time string is returned) + NSString *dateString = nil; + if (dateFormatter.dateFormat) + { + dateString = [dateFormatter stringFromDate:date]; + } + + if (time) + { + NSString *timeString = [self timeStringFromDate:date]; + if (dateString.length) + { + // Add time string + dateString = [NSString stringWithFormat:@"%@ %@", dateString, timeString]; + } + else + { + dateString = timeString; + } + } + + return dateString; +} + +- (NSString*)dateStringFromTimestamp:(uint64_t)timestamp withTime:(BOOL)time +{ + NSDate *date = [NSDate dateWithTimeIntervalSince1970:timestamp / 1000]; + + return [self dateStringFromDate:date withTime:time]; +} + +- (NSString*)dateStringFromEvent:(MXEvent *)event withTime:(BOOL)time +{ + if (event.originServerTs != kMXUndefinedTimestamp) + { + return [self dateStringFromTimestamp:event.originServerTs withTime:time]; + } + + return nil; +} + +- (NSString*)timeStringFromDate:(NSDate *)date +{ + NSString *timeString = [timeFormatter stringFromDate:date]; + + return timeString.lowercaseString; +} + +@end diff --git a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKRoomNameStringLocalizer.h b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKRoomNameStringLocalizer.h new file mode 100644 index 000000000..3c1f419c8 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKRoomNameStringLocalizer.h @@ -0,0 +1,26 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +@import Foundation; + +#import + +/** + The `MXKRoomNameStringLocalizer` implements localization strings for `MXRoomNameStringLocalizerProtocol`. + */ +@interface MXKRoomNameStringLocalizer : NSObject + +@end diff --git a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKRoomNameStringLocalizer.m b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKRoomNameStringLocalizer.m new file mode 100644 index 000000000..904207536 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKRoomNameStringLocalizer.m @@ -0,0 +1,42 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomNameStringLocalizer.h" +#import "MXKSwiftHeader.h" + +@implementation MXKRoomNameStringLocalizer + +- (NSString *)emptyRoom +{ + return [MatrixKitL10n roomDisplaynameEmptyRoom]; +} + +- (NSString *)twoMembers:(NSString *)firstMember second:(NSString *)secondMember +{ + return [MatrixKitL10n roomDisplaynameTwoMembers:firstMember :secondMember]; +} + +- (NSString *)moreThanTwoMembers:(NSString *)firstMember count:(NSNumber *)memberCount +{ + return [MatrixKitL10n roomDisplaynameMoreThanTwoMembers:firstMember :memberCount.stringValue]; +} + +- (NSString *)allOtherMembersLeft:(NSString *)member +{ + return [MatrixKitL10n roomDisplaynameAllOtherMembersLeft:member]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Utils/EventFormatter/MarkdownToHTMLRenderer.swift b/Riot/Modules/MatrixKit/Utils/EventFormatter/MarkdownToHTMLRenderer.swift new file mode 100644 index 000000000..820928fb2 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/EventFormatter/MarkdownToHTMLRenderer.swift @@ -0,0 +1,52 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import Foundation +import Down +import libcmark + +@objc public protocol MarkdownToHTMLRendererProtocol: NSObjectProtocol { + func renderToHTML(markdown: String) -> String? +} + +@objcMembers +public class MarkdownToHTMLRenderer: NSObject { + + fileprivate var options: DownOptions = [] + + // Do not expose an initializer with options, because `DownOptions` is not ObjC compatible. + public override init() { + super.init() + } +} + +extension MarkdownToHTMLRenderer: MarkdownToHTMLRendererProtocol { + + public func renderToHTML(markdown: String) -> String? { + return try? Down(markdownString: markdown).toHTML(options) + } + +} + +@objcMembers +public class MarkdownToHTMLRendererHardBreaks: MarkdownToHTMLRenderer { + + public override init() { + super.init() + options = .hardBreaks + } + +} diff --git a/Riot/Modules/MatrixKit/Utils/MXKAnalyticsConstants.h b/Riot/Modules/MatrixKit/Utils/MXKAnalyticsConstants.h new file mode 100644 index 000000000..97d3c25f2 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKAnalyticsConstants.h @@ -0,0 +1,33 @@ +// +// Copyright 2020 The Matrix.org Foundation C.I.C +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + + +typedef NSString *const MXKAnalyticsCategory NS_TYPED_EXTENSIBLE_ENUM; + +/** + The analytics category for local contacts. + */ +static MXKAnalyticsCategory const MXKAnalyticsCategoryContacts = @"localContacts"; + + +typedef NSString *const MXKAnalyticsName NS_TYPED_EXTENSIBLE_ENUM; + +/** + The analytics value for accept/decline of local contacts access. + */ +static MXKAnalyticsName const MXKAnalyticsNameContactsAccessGranted = @"accessGranted"; diff --git a/Riot/Modules/MatrixKit/Utils/MXKConstants.h b/Riot/Modules/MatrixKit/Utils/MXKConstants.h new file mode 100644 index 000000000..b580d234e --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKConstants.h @@ -0,0 +1,41 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#define MXK_DEPRECATED_ATTRIBUTE __attribute__((deprecated)) +#define MXK_DEPRECATED_ATTRIBUTE_WITH_MSG(msg) __attribute((deprecated((msg)))) + +/** + The Matrix iOS Kit version. + */ +FOUNDATION_EXPORT NSString *const MatrixKitVersion; + +/** + Posted when an error is observed at Matrix Kit level. + This notification may be used to inform user by showing the error as an alert. + The notification object is the NSError instance. + + The passed userInfo dictionary may contain: + - `kMXKErrorUserIdKey` the matrix identifier of the account concerned by this error. + */ +FOUNDATION_EXPORT NSString *const kMXKErrorNotification; + +/** + The key in notification userInfo dictionary representating the account userId. + */ +FOUNDATION_EXPORT NSString *const kMXKErrorUserIdKey; diff --git a/Riot/Modules/MatrixKit/Utils/MXKConstants.m b/Riot/Modules/MatrixKit/Utils/MXKConstants.m new file mode 100644 index 000000000..cf6c87873 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKConstants.m @@ -0,0 +1,23 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKConstants.h" + +NSString *const kMXKErrorNotification = @"kMXKErrorNotification"; + +NSString *const kMXKErrorUserIdKey = @"kMXKErrorUserIdKey"; diff --git a/Riot/Modules/MatrixKit/Utils/MXKDocumentPickerPresenter.swift b/Riot/Modules/MatrixKit/Utils/MXKDocumentPickerPresenter.swift new file mode 100644 index 000000000..1f9b9c89c --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKDocumentPickerPresenter.swift @@ -0,0 +1,80 @@ +/* + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import UIKit +import MobileCoreServices + +@objc public protocol MXKDocumentPickerPresenterDelegate { + func documentPickerPresenter(_ presenter: MXKDocumentPickerPresenter, didPickDocumentsAt url: URL) + func documentPickerPresenterWasCancelled(_ presenter: MXKDocumentPickerPresenter) +} + +/// MXKDocumentPickerPresenter presents a controller that provides access to documents or destinations outside the app’s sandbox. +/// Internally presents a UIDocumentPickerViewController in UIDocumentPickerMode.import. +/// Note: You must turn on the iCloud Documents capabilities in Xcode (see https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/DocumentPickerProgrammingGuide/Introduction/Introduction.html#//apple_ref/doc/uid/TP40014451) +@objcMembers +public class MXKDocumentPickerPresenter: NSObject { + + // MARK: - Properties + + // MARK: Private + + private weak var presentingViewController: UIViewController? + + // MARK: Public + + public weak var delegate: MXKDocumentPickerPresenterDelegate? + + public var isPresenting: Bool { + return self.presentingViewController?.parent != nil + } + + // MARK: - Public + + /// Presents a document picker view controller modally. + /// + /// - Parameters: + /// - allowedUTIs: Allowed pickable file UTIs. + /// - viewController: The view controller on which to present the document picker. + /// - animated: Indicate true to animate. + /// - completion: Animation completion. + public func presentDocumentPicker(with allowedUTIs: [MXKUTI], from viewController: UIViewController, animated: Bool, completion: (() -> Void)?) { + let documentTypes = allowedUTIs.map { return $0.rawValue } + let documentPicker = UIDocumentPickerViewController(documentTypes: documentTypes, in: .import) + documentPicker.delegate = self + viewController.present(documentPicker, animated: animated, completion: completion) + self.presentingViewController = viewController + } +} + +// MARK - UIDocumentPickerDelegate +extension MXKDocumentPickerPresenter: UIDocumentPickerDelegate { + + public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { + return + } + self.delegate?.documentPickerPresenter(self, didPickDocumentsAt: url) + } + + public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + self.delegate?.documentPickerPresenterWasCancelled(self) + } + + public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { + self.delegate?.documentPickerPresenter(self, didPickDocumentsAt: url) + } +} diff --git a/Riot/Modules/MatrixKit/Utils/MXKResponderRageShaking.h b/Riot/Modules/MatrixKit/Utils/MXKResponderRageShaking.h new file mode 100644 index 000000000..7c77aad24 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKResponderRageShaking.h @@ -0,0 +1,47 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +/** + `MXKResponderRageShaking` defines a protocol an object must conform to handle rage shake + on view controllers or other kinds of `UIResponder`. + */ +@protocol MXKResponderRageShaking + +/** + Tells the receiver that a motion event has begun. + + @param responder the view controller (or another kind of `UIResponder`) which observed the motion. + */ +- (void)startShaking:(UIResponder*)responder; + +/** + Tells the receiver that a motion event has ended. + + @param responder the view controller (or another kind of `UIResponder`) which observed the motion. + */ +- (void)stopShaking:(UIResponder*)responder; + +/** + Ignore pending rage shake related to the provided responder. + + @param responder a view controller (or another kind of `UIResponder`). + */ +- (void)cancel:(UIResponder*)responder; + +@end + diff --git a/Riot/Modules/MatrixKit/Utils/MXKSoundPlayer.h b/Riot/Modules/MatrixKit/Utils/MXKSoundPlayer.h new file mode 100644 index 000000000..2643eeac7 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKSoundPlayer.h @@ -0,0 +1,36 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MXKSoundPlayer : NSObject + ++ (instancetype)sharedInstance; + ++ (instancetype)init NS_UNAVAILABLE; ++ (instancetype)new NS_UNAVAILABLE; + +- (void)playSoundAt:(NSURL *)url repeat:(BOOL)repeat vibrate:(BOOL)vibrate routeToBuiltInReceiver:(BOOL)useBuiltInReceiver; +- (void)stopPlayingWithAudioSessionDeactivation:(BOOL)deactivateAudioSession; + +- (void)vibrateWithRepeat:(BOOL)repeat; +- (void)stopVibrating; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Utils/MXKSoundPlayer.m b/Riot/Modules/MatrixKit/Utils/MXKSoundPlayer.m new file mode 100644 index 000000000..665e31f32 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKSoundPlayer.m @@ -0,0 +1,145 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSoundPlayer.h" + +#import +#import + +static const NSTimeInterval kVibrationInterval = 1.24875; + +@interface MXKSoundPlayer () + +@property (nonatomic, nullable) AVAudioPlayer *audioPlayer; + +@property (nonatomic, getter=isVibrating) BOOL vibrating; + +@end + +@implementation MXKSoundPlayer + ++ (instancetype)sharedInstance +{ + static MXKSoundPlayer *soundPlayer; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + soundPlayer = [MXKSoundPlayer alloc]; + }); + return soundPlayer; +} + +- (void)playSoundAt:(NSURL *)url repeat:(BOOL)repeat vibrate:(BOOL)vibrate routeToBuiltInReceiver:(BOOL)useBuiltInReceiver +{ + if (self.audioPlayer) + { + [self stopPlayingWithAudioSessionDeactivation:NO]; + } + + self.audioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:nil]; + + if (!self.audioPlayer) + return; + + self.audioPlayer.delegate = self; + self.audioPlayer.numberOfLoops = repeat ? -1 : 0; + [self.audioPlayer prepareToPlay]; + + // Setup AVAudioSession + // We use SoloAmbient instead of Playback category to respect silent mode + NSString *audioSessionCategory = useBuiltInReceiver ? AVAudioSessionCategoryPlayAndRecord : AVAudioSessionCategorySoloAmbient; + [[AVAudioSession sharedInstance] setCategory:audioSessionCategory error:nil]; + + if (vibrate) + [self vibrateWithRepeat:repeat]; + + [self.audioPlayer play]; +} + +- (void)stopPlayingWithAudioSessionDeactivation:(BOOL)deactivateAudioSession; +{ + if (self.audioPlayer) + { + [self.audioPlayer stop]; + self.audioPlayer = nil; + + if (deactivateAudioSession) + { + // Release the audio session to allow resuming of background music app + [[AVAudioSession sharedInstance] setActive:NO withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:nil]; + } + } + + if (self.isVibrating) + { + [self stopVibrating]; + } +} + +- (void)vibrateWithRepeat:(BOOL)repeat +{ + self.vibrating = YES; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kVibrationInterval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate); + + NSNumber *objRepeat = @(repeat); + AudioServicesAddSystemSoundCompletion(kSystemSoundID_Vibrate, + NULL, + kCFRunLoopCommonModes, + vibrationCompleted, + (__bridge_retained void * _Nullable)(objRepeat)); + }); +} + +- (void)stopVibrating +{ + self.vibrating = NO; + AudioServicesRemoveSystemSoundCompletion(kSystemSoundID_Vibrate); +} + +void vibrationCompleted(SystemSoundID ssID, void* __nullable clientData) +{ + NSNumber *objRepeat = (__bridge NSNumber *)clientData; + BOOL repeat = [objRepeat boolValue]; + CFRelease(clientData); + + MXKSoundPlayer *soundPlayer = [MXKSoundPlayer sharedInstance]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kVibrationInterval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + if (repeat && soundPlayer.isVibrating) + { + [soundPlayer vibrateWithRepeat:repeat]; + } + else + { + [soundPlayer stopVibrating]; + } + }); +} + +#pragma mark - AVAudioPlayerDelegate + +// This method is called only when the end of the player's track is reached. +// If you call `stop` or `pause` on player this method won't be called +- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag +{ + self.audioPlayer = nil; + + // Release the audio session to allow resuming of background music app + [[AVAudioSession sharedInstance] setActive:NO withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:nil]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Utils/MXKTools.h b/Riot/Modules/MatrixKit/Utils/MXKTools.h new file mode 100644 index 000000000..bf6f94a61 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKTools.h @@ -0,0 +1,426 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#define MXKTOOLS_LARGE_IMAGE_SIZE 1024 +#define MXKTOOLS_MEDIUM_IMAGE_SIZE 768 +#define MXKTOOLS_SMALL_IMAGE_SIZE 512 + +#define MXKTOOLS_USER_IDENTIFIER_BITWISE 0x01 +#define MXKTOOLS_ROOM_IDENTIFIER_BITWISE 0x02 +#define MXKTOOLS_ROOM_ALIAS_BITWISE 0x04 +#define MXKTOOLS_EVENT_IDENTIFIER_BITWISE 0x08 +#define MXKTOOLS_GROUP_IDENTIFIER_BITWISE 0x10 + +// Attribute in an NSAttributeString that marks a blockquote block that was in the original HTML string. +extern NSString *const kMXKToolsBlockquoteMarkAttribute; + +/** + Structure representing an the size of an image and its file size. + */ +typedef struct +{ + CGSize imageSize; + NSUInteger fileSize; + +} MXKImageCompressionSize; + +/** + Structure representing the sizes of image (image size and file size) according to + different level of compression. + */ + +typedef struct +{ + MXKImageCompressionSize small; + MXKImageCompressionSize medium; + MXKImageCompressionSize large; + MXKImageCompressionSize original; + + CGFloat actualLargeSize; + +} MXKImageCompressionSizes; + +@interface MXKTools : NSObject + +#pragma mark - Strings + +/** + Determine if a string contains one emoji and only one. + + @param string the string to check. + @return YES if YES. + */ ++ (BOOL)isSingleEmojiString:(NSString*)string; + +/** + Determine if a string contains only emojis. + + @param string the string to check. + @return YES if YES. + */ ++ (BOOL)isEmojiOnlyString:(NSString*)string; + +#pragma mark - Time + +/** + Format time interval. + ex: "5m 31s". + + @param secondsInterval time interval in seconds. + @return formatted string + */ ++ (NSString*)formatSecondsInterval:(CGFloat)secondsInterval; + +/** + Format time interval but rounded to the nearest time unit below. + ex: "5s", "1m", "2h" or "3d". + + @param secondsInterval time interval in seconds. + @return formatted string + */ ++ (NSString*)formatSecondsIntervalFloored:(CGFloat)secondsInterval; + +#pragma mark - Phone number + +/** + Return the number used to identify a mobile phone number internationally. + + The provided country code is ignored when the phone number is already internationalized, or when it + is a valid msisdn. + + @param phoneNumber the phone number. + @param countryCode the ISO 3166-1 country code representation (required when the phone number is in national format). + + @return a valid msisdn or nil if the provided phone number is invalid. + */ ++ (NSString*)msisdnWithPhoneNumber:(NSString *)phoneNumber andCountryCode:(NSString *)countryCode; + +/** + Format an MSISDN to a human readable international phone number. + + @param msisdn The MSISDN to format. + + @return Human readable international phone number. + */ ++ (NSString*)readableMSISDN:(NSString*)msisdn; + +#pragma mark - Hex color to UIColor conversion + +/** + Build a UIColor from an hexadecimal color value + + @param rgbValue the color expressed in hexa (0xRRGGBB) + @return the UIColor + */ ++ (UIColor*)colorWithRGBValue:(NSUInteger)rgbValue; + +/** + Build a UIColor from an hexadecimal color value with transparency + + @param argbValue the color expressed in hexa (0xAARRGGBB) + @return the UIColor + */ ++ (UIColor*)colorWithARGBValue:(NSUInteger)argbValue; + +/** + Return an hexadecimal color value from UIColor + + @param color the UIColor + @return rgbValue the color expressed in hexa (0xRRGGBB) + */ ++ (NSUInteger)rgbValueWithColor:(UIColor*)color; + +/** + Return an hexadecimal color value with transparency from UIColor + + @param color the UIColor + @return argbValue the color expressed in hexa (0xAARRGGBB) + */ ++ (NSUInteger)argbValueWithColor:(UIColor*)color; + +#pragma mark - Image processing + +/** + Force image orientation to up + + @param imageSrc the original image. + @return image with `UIImageOrientationUp` orientation. + */ ++ (UIImage*)forceImageOrientationUp:(UIImage*)imageSrc; + +/** + Return struct MXKImageCompressionSizes representing the available compression sizes for the image + + @param image the image to get available sizes for + @param originalFileSize the size in bytes of the original image file or the image data (0 if this value is unknown). + */ ++ (MXKImageCompressionSizes)availableCompressionSizesForImage:(UIImage*)image originalFileSize:(NSUInteger)originalFileSize; + +/** + Compute image size to fit in specific box size (in aspect fit mode) + + @param originalSize the original size + @param maxSize the box size + @param canExpand tell whether the image can be expand or not + @return the resized size. + */ ++ (CGSize)resizeImageSize:(CGSize)originalSize toFitInSize:(CGSize)maxSize canExpand:(BOOL)canExpand; + +/** + Compute image size to fill specific box size (in aspect fill mode) + + @param originalSize the original size + @param maxSize the box size + @param canExpand tell whether the image can be expand or not + @return the resized size. + */ ++ (CGSize)resizeImageSize:(CGSize)originalSize toFillWithSize:(CGSize)maxSize canExpand:(BOOL)canExpand; + +/** + Reduce image to fit in the provided size. + The aspect ratio is kept. + If the image is smaller than the provided size, the image is not recomputed. + + @discussion This method call `+ [reduceImage:toFitInSize:useMainScreenScale:]` with `useMainScreenScale` value to `NO`. + + @param image the image to modify. + @param size to fit in. + @return resized image. + + @see reduceImage:toFitInSize:useMainScreenScale: + */ ++ (UIImage *)reduceImage:(UIImage *)image toFitInSize:(CGSize)size; + +/** + Reduce image to fit in the provided size. + The aspect ratio is kept. + If the image is smaller than the provided size, the image is not recomputed. + + @param image the image to modify. + @param size to fit in. + @param useMainScreenScale Indicate true to use main screen scale. + @return resized image. + */ ++ (UIImage *)reduceImage:(UIImage *)image toFitInSize:(CGSize)size useMainScreenScale:(BOOL)useMainScreenScale; + +/** + Reduce image to fit in the provided size. + The aspect ratio is kept. + + @discussion This method use less memory than `+ [reduceImage:toFitInSize:useMainScreenScale:]`. + + @param imageData The image data. + @param size Size to fit in. + @return Resized image or nil if the data is not interpreted. + */ ++ (UIImage*)resizeImageWithData:(NSData*)imageData toFitInSize:(CGSize)size; + +/** + Resize image to a provided size. + + @param image the image to modify. + @param size the new size. + @return resized image. + */ ++ (UIImage*)resizeImage:(UIImage *)image toSize:(CGSize)size; + +/** + Resize image with rounded corners to a provided size. + + @param image the image to modify. + @param size the new size. + @return resized image. + */ ++ (UIImage*)resizeImageWithRoundedCorners:(UIImage *)image toSize:(CGSize)size; + +/** + Paint an image with a color. + + @discussion + All non fully transparent (alpha = 0) will be painted with the provided color. + + @param image the image to paint. + @param color the color to use. + @result a new UIImage object. + */ ++ (UIImage*)paintImage:(UIImage*)image withColor:(UIColor*)color; + +/** + Convert a rotation angle to the most suitable image orientation. + + @param angle rotation angle in degree. + @return image orientation. + */ ++ (UIImageOrientation)imageOrientationForRotationAngleInDegree:(NSInteger)angle; + +/** + Draw the image resource in a view and transforms it to a pattern color. + The view size is defined by patternSize and will have a "backgroundColor" backgroundColor. + The resource image is drawn with the resourceSize size and is centered into its parent view. + + @param reourceName the image resource name. + @param backgroundColor the pattern background color. + @param patternSize the pattern size. + @param resourceSize the resource size in the pattern. + @return the pattern color which can be used to define the background color of a view in order to display the provided image as its background. + */ ++ (UIColor*)convertImageToPatternColor:(NSString*)reourceName backgroundColor:(UIColor*)backgroundColor patternSize:(CGSize)patternSize resourceSize:(CGSize)resourceSize; + +#pragma mark - Video conversion +/** +Creates a `UIAlertController` with appropriate `AVAssetExportPreset` choices for the video passed in. + @param videoAsset The video to generate the choices for. + @param completion The block called when a preset has been chosen. `presetName` will contain the preset name or `nil` if cancelled. +*/ ++ (UIAlertController*)videoConversionPromptForVideoAsset:(AVAsset *)videoAsset + withCompletion:(void (^)(NSString * _Nullable presetName))completion; + +#pragma mark - App permissions + +/** + Check permission to access a media. + +@discussion + If the access was not yet granted, a dialog will be shown to the user. + If it is the first attempt to access the media, the dialog is the classic iOS one. + Else, the dialog will ask the user to manually change the permission in the app settings. + + @param mediaType the media type, either AVMediaTypeVideo or AVMediaTypeAudio. + @param manualChangeMessage the message to display if the end user must change the app settings manually. + @param viewController the view controller to attach the dialog displaying manualChangeMessage. + @param handler the block called with the result of requesting access + */ ++ (void)checkAccessForMediaType:(NSString *)mediaType + manualChangeMessage:(NSString*)manualChangeMessage + showPopUpInViewController:(UIViewController*)viewController + completionHandler:(void (^)(BOOL granted))handler; + +/** + Check required permission for the provided call. + + @param isVideoCall flag set to YES in case of video call. + @param manualChangeMessageForAudio the message to display if the end user must change the app settings manually for audio. + @param manualChangeMessageForVideo the message to display if the end user must change the app settings manually for video + @param viewController the view controller to attach the dialog displaying manualChangeMessage. + @param handler the block called with the result of requesting access + */ ++ (void)checkAccessForCall:(BOOL)isVideoCall +manualChangeMessageForAudio:(NSString*)manualChangeMessageForAudio +manualChangeMessageForVideo:(NSString*)manualChangeMessageForVideo + showPopUpInViewController:(UIViewController*)viewController + completionHandler:(void (^)(BOOL granted))handler; + +/** + Check permission to access Contacts. + + @discussion + If the access was not yet granted, a dialog will be shown to the user. + If it is the first attempt to access the media, the dialog is the classic iOS one. + Else, the dialog will ask the user to manually change the permission in the app settings. + + @param manualChangeMessage the message to display if the end user must change the app settings manually. + If nil, the dialog for displaying manualChangeMessage will not be shown. + @param viewController the view controller to attach the dialog displaying manualChangeMessage. + @param handler the block called with the result of requesting access + */ ++ (void)checkAccessForContacts:(NSString*)manualChangeMessage + showPopUpInViewController:(UIViewController*)viewController + completionHandler:(void (^)(BOOL granted))handler; + +/** + Check permission to access Contacts. + + @discussion + If the access was not yet granted, a dialog will be shown to the user. + If it is the first attempt to access the media, the dialog is the classic iOS one. + Else, the dialog will ask the user to manually change the permission in the app settings. + + @param manualChangeTitle the title to display if the end user must change the app settings manually. + @param manualChangeMessage the message to display if the end user must change the app settings manually. + If nil, the dialog for displaying manualChangeMessage will not be shown. + @param viewController the view controller to attach the dialog displaying manualChangeMessage. + @param handler the block called with the result of requesting access + */ ++ (void)checkAccessForContacts:(NSString *)manualChangeTitle + withManualChangeMessage:(NSString *)manualChangeMessage + showPopUpInViewController:(UIViewController *)viewController + completionHandler:(void (^)(BOOL granted))handler; + +#pragma mark - HTML processing + +/** + Removing DTCoreText artifacts: + - Trim trailing whitespace and newlines in the string content. + - Replace DTImageTextAttachments with a simple NSTextAttachment subclass. + + @param attributedString an attributed string. + @return the resulting string. + */ ++ (NSAttributedString*)removeDTCoreTextArtifacts:(NSAttributedString*)attributedString; + +/** + Make some matrix identifiers clickable in the string content. + + @param attributedString an attributed string. + @param enabledMatrixIdsBitMask the bitmask used to list the types of matrix id to process (see MXKTOOLS_XXX__BITWISE). + @return the resulting string. + */ ++ (NSAttributedString*)createLinksInAttributedString:(NSAttributedString*)attributedString forEnabledMatrixIds:(NSInteger)enabledMatrixIdsBitMask; + +#pragma mark - HTML processing - blockquote display handling + +/** + Return a CSS to make DTCoreText mark blockquote blocks in the `NSAttributedString` output. + + These blocks output will have a `DTTextBlocksAttribute` attribute in the `NSAttributedString` + that can be used for later computation (in `removeMarkedBlockquotesArtifacts`). + + @return a CSS string. + */ ++ (NSString*)cssToMarkBlockquotes; + +/** + Removing DTCoreText artifacts used to mark blockquote blocks. + + @param attributedString an attributed string. + @return the resulting string. + */ ++ (NSAttributedString*)removeMarkedBlockquotesArtifacts:(NSAttributedString*)attributedString; + +/** + Enumerate all sections of the attributed string that refer to an HTML blockquote block. + + Must be used with `cssToMarkBlockquotes` and `removeMarkedBlockquotesArtifacts`. + + @param attributedString the attributed string. + @param block a block called for each HTML blockquote blocks. + */ ++ (void)enumerateMarkedBlockquotesInAttributedString:(NSAttributedString*)attributedString usingBlock:(void (^)(NSRange range, BOOL *stop))block; + +#pragma mark - Push + +/** + Trim push token in order to log it. + + @param pushToken the token to trim. + @return a trimmed description. + */ ++ (NSString*)logForPushToken:(NSData*)pushToken; + +@end diff --git a/Riot/Modules/MatrixKit/Utils/MXKTools.m b/Riot/Modules/MatrixKit/Utils/MXKTools.m new file mode 100644 index 000000000..76753eed5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKTools.m @@ -0,0 +1,1230 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTools.h" + +@import MatrixSDK; +@import Contacts; +@import libPhoneNumber_iOS; +@import DTCoreText; + +#import "MXKConstants.h" +#import "NSBundle+MatrixKit.h" +#import "MXKAppSettings.h" +#import +#import "MXKSwiftHeader.h" +#import "MXKAnalyticsConstants.h" + +#pragma mark - Constants definitions + +// Temporary background color used to identify blockquote blocks with DTCoreText. +#define kMXKToolsBlockquoteMarkColor [UIColor magentaColor] + +// Attribute in an NSAttributeString that marks a blockquote block that was in the original HTML string. +NSString *const kMXKToolsBlockquoteMarkAttribute = @"kMXKToolsBlockquoteMarkAttribute"; + +#pragma mark - MXKTools static private members +// The regex used to find matrix ids. +static NSRegularExpression *userIdRegex; +static NSRegularExpression *roomIdRegex; +static NSRegularExpression *roomAliasRegex; +static NSRegularExpression *eventIdRegex; +static NSRegularExpression *groupIdRegex; +// A regex to find http URLs. +static NSRegularExpression *httpLinksRegex; +// A regex to find all HTML tags +static NSRegularExpression *htmlTagsRegex; + +@implementation MXKTools + ++ (void)initialize +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + + userIdRegex = [NSRegularExpression regularExpressionWithPattern:kMXToolsRegexStringForMatrixUserIdentifier options:NSRegularExpressionCaseInsensitive error:nil]; + roomIdRegex = [NSRegularExpression regularExpressionWithPattern:kMXToolsRegexStringForMatrixRoomIdentifier options:NSRegularExpressionCaseInsensitive error:nil]; + roomAliasRegex = [NSRegularExpression regularExpressionWithPattern:kMXToolsRegexStringForMatrixRoomAlias options:NSRegularExpressionCaseInsensitive error:nil]; + eventIdRegex = [NSRegularExpression regularExpressionWithPattern:kMXToolsRegexStringForMatrixEventIdentifier options:NSRegularExpressionCaseInsensitive error:nil]; + groupIdRegex = [NSRegularExpression regularExpressionWithPattern:kMXToolsRegexStringForMatrixGroupIdentifier options:NSRegularExpressionCaseInsensitive error:nil]; + + httpLinksRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)\\b(https?://.*)\\b" options:NSRegularExpressionCaseInsensitive error:nil]; + htmlTagsRegex = [NSRegularExpression regularExpressionWithPattern:@"<(\\w+)[^>]*>" options:NSRegularExpressionCaseInsensitive error:nil]; + }); +} + +#pragma mark - Strings + ++ (BOOL)isSingleEmojiString:(NSString *)string +{ + return [MXKTools isEmojiString:string singleEmoji:YES]; +} + ++ (BOOL)isEmojiOnlyString:(NSString *)string +{ + return [MXKTools isEmojiString:string singleEmoji:NO]; +} + +// Highly inspired from https://stackoverflow.com/a/34659249 ++ (BOOL)isEmojiString:(NSString*)string singleEmoji:(BOOL)singleEmoji +{ + if (string.length == 0) + { + return NO; + } + + __block BOOL result = YES; + + NSRange stringRange = NSMakeRange(0, [string length]); + + [string enumerateSubstringsInRange:stringRange + options:NSStringEnumerationByComposedCharacterSequences + usingBlock:^(NSString *substring, + NSRange substringRange, + NSRange enclosingRange, + BOOL *stop) + { + BOOL isEmoji = NO; + + if (singleEmoji && !NSEqualRanges(stringRange, substringRange)) + { + // The string contains several characters. Go out + result = NO; + *stop = YES; + return; + } + + const unichar hs = [substring characterAtIndex:0]; + // Surrogate pair + if (0xd800 <= hs && + hs <= 0xdbff) + { + if (substring.length > 1) + { + const unichar ls = [substring characterAtIndex:1]; + const int uc = ((hs - 0xd800) * 0x400) + (ls - 0xdc00) + 0x10000; + if (0x1d000 <= uc && + uc <= 0x1f9ff) + { + isEmoji = YES; + } + } + } + else if (substring.length > 1) + { + const unichar ls = [substring characterAtIndex:1]; + if (ls == 0x20e3 || + ls == 0xfe0f || + ls == 0xd83c) + { + isEmoji = YES; + } + } + else + { + // Non surrogate + if (0x2100 <= hs && + hs <= 0x27ff) + { + isEmoji = YES; + } + else if (0x2B05 <= hs && + hs <= 0x2b07) + { + isEmoji = YES; + } + else if (0x2934 <= hs && + hs <= 0x2935) + { + isEmoji = YES; + } + else if (0x3297 <= hs && + hs <= 0x3299) + { + isEmoji = YES; + } + else if (hs == 0xa9 || + hs == 0xae || + hs == 0x303d || + hs == 0x3030 || + hs == 0x2b55 || + hs == 0x2b1c || + hs == 0x2b1b || + hs == 0x2b50) + { + isEmoji = YES; + } + } + + if (!isEmoji) + { + result = NO; + *stop = YES; + } + }]; + + return result; +} + +#pragma mark - Time interval + ++ (NSString*)formatSecondsInterval:(CGFloat)secondsInterval +{ + NSMutableString* formattedString = [[NSMutableString alloc] init]; + + if (secondsInterval < 1) + { + [formattedString appendFormat:@"< 1%@", [MatrixKitL10n formatTimeS]]; + } + else if (secondsInterval < 60) + { + [formattedString appendFormat:@"%d%@", (int)secondsInterval, [MatrixKitL10n formatTimeS]]; + } + else if (secondsInterval < 3600) + { + [formattedString appendFormat:@"%d%@ %2d%@", (int)(secondsInterval/60), [MatrixKitL10n formatTimeM], + ((int)secondsInterval) % 60, [MatrixKitL10n formatTimeS]]; + } + else if (secondsInterval >= 3600) + { + [formattedString appendFormat:@"%d%@ %d%@ %d%@", (int)(secondsInterval / 3600), [MatrixKitL10n formatTimeH], + ((int)(secondsInterval) % 3600) / 60, [MatrixKitL10n formatTimeM], + (int)(secondsInterval) % 60, [MatrixKitL10n formatTimeS]]; + } + [formattedString appendString:@" left"]; + + return formattedString; +} + ++ (NSString *)formatSecondsIntervalFloored:(CGFloat)secondsInterval +{ + NSString* formattedString; + + if (secondsInterval < 0) + { + formattedString = [NSString stringWithFormat:@"0%@", [MatrixKitL10n formatTimeS]]; + } + else + { + NSUInteger seconds = secondsInterval; + if (seconds < 60) + { + formattedString = [NSString stringWithFormat:@"%tu%@", seconds, [MatrixKitL10n formatTimeS]]; + } + else if (secondsInterval < 3600) + { + formattedString = [NSString stringWithFormat:@"%tu%@", seconds / 60, [MatrixKitL10n formatTimeM]]; + } + else if (secondsInterval < 86400) + { + formattedString = [NSString stringWithFormat:@"%tu%@", seconds / 3600, [MatrixKitL10n formatTimeH]]; + } + else + { + formattedString = [NSString stringWithFormat:@"%tu%@", seconds / 86400, [MatrixKitL10n formatTimeD]]; + } + } + + return formattedString; +} + +#pragma mark - Phone number + ++ (NSString*)msisdnWithPhoneNumber:(NSString *)phoneNumber andCountryCode:(NSString *)countryCode +{ + NSString *msisdn = nil; + NBPhoneNumber *phoneNb; + + if ([phoneNumber hasPrefix:@"+"] || [phoneNumber hasPrefix:@"00"]) + { + phoneNb = [[NBPhoneNumberUtil sharedInstance] parse:phoneNumber defaultRegion:nil error:nil]; + } + else + { + // Check whether the provided phone number is a valid msisdn. + NSString *e164 = [NSString stringWithFormat:@"+%@", phoneNumber]; + phoneNb = [[NBPhoneNumberUtil sharedInstance] parse:e164 defaultRegion:nil error:nil]; + + if (![[NBPhoneNumberUtil sharedInstance] isValidNumber:phoneNb]) + { + // Consider the phone number as a national one, and use the country code. + phoneNb = [[NBPhoneNumberUtil sharedInstance] parse:phoneNumber defaultRegion:countryCode error:nil]; + } + } + + if ([[NBPhoneNumberUtil sharedInstance] isValidNumber:phoneNb]) + { + NSString *e164 = [[NBPhoneNumberUtil sharedInstance] format:phoneNb numberFormat:NBEPhoneNumberFormatE164 error:nil]; + + if ([e164 hasPrefix:@"+"]) + { + msisdn = [e164 substringFromIndex:1]; + } + else if ([e164 hasPrefix:@"00"]) + { + msisdn = [e164 substringFromIndex:2]; + } + } + + return msisdn; +} + ++ (NSString*)readableMSISDN:(NSString*)msisdn +{ + NSString *e164; + + if (([e164 hasPrefix:@"+"])) + { + e164 = msisdn; + } + else + { + e164 = [NSString stringWithFormat:@"+%@", msisdn]; + } + + NBPhoneNumber *phoneNb = [[NBPhoneNumberUtil sharedInstance] parse:e164 defaultRegion:nil error:nil]; + return [[NBPhoneNumberUtil sharedInstance] format:phoneNb numberFormat:NBEPhoneNumberFormatINTERNATIONAL error:nil]; +} + +#pragma mark - Hex color to UIColor conversion + ++ (UIColor *)colorWithRGBValue:(NSUInteger)rgbValue +{ + return [UIColor colorWithRed:((float)((rgbValue & 0xFF0000) >> 16))/255.0 green:((float)((rgbValue & 0xFF00) >> 8))/255.0 blue:((float)(rgbValue & 0xFF))/255.0 alpha:1.0]; +} + ++ (UIColor *)colorWithARGBValue:(NSUInteger)argbValue +{ + return [UIColor colorWithRed:((float)((argbValue & 0xFF0000) >> 16))/255.0 green:((float)((argbValue & 0xFF00) >> 8))/255.0 blue:((float)(argbValue & 0xFF))/255.0 alpha:((float)((argbValue & 0xFF000000) >> 24))/255.0]; +} + ++ (NSUInteger)rgbValueWithColor:(UIColor*)color +{ + CGFloat red, green, blue, alpha; + + [color getRed:&red green:&green blue:&blue alpha:&alpha]; + + NSUInteger rgbValue = ((int)(red * 255) << 16) + ((int)(green * 255) << 8) + (blue * 255); + + return rgbValue; +} + ++ (NSUInteger)argbValueWithColor:(UIColor*)color +{ + CGFloat red, green, blue, alpha; + + [color getRed:&red green:&green blue:&blue alpha:&alpha]; + + NSUInteger argbValue = ((int)(alpha * 255) << 24) + ((int)(red * 255) << 16) + ((int)(green * 255) << 8) + (blue * 255); + + return argbValue; +} + +#pragma mark - Image + ++ (UIImage*)forceImageOrientationUp:(UIImage*)imageSrc +{ + if ((imageSrc.imageOrientation == UIImageOrientationUp) || (!imageSrc)) + { + // Nothing to do + return imageSrc; + } + + // Draw the entire image in a graphics context, respecting the image’s orientation setting + UIGraphicsBeginImageContext(imageSrc.size); + [imageSrc drawAtPoint:CGPointMake(0, 0)]; + UIImage *retImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + return retImage; +} + ++ (MXKImageCompressionSizes)availableCompressionSizesForImage:(UIImage*)image originalFileSize:(NSUInteger)originalFileSize +{ + MXKImageCompressionSizes compressionSizes; + memset(&compressionSizes, 0, sizeof(MXKImageCompressionSizes)); + + // Store the original + compressionSizes.original.imageSize = image.size; + compressionSizes.original.fileSize = originalFileSize ? originalFileSize : UIImageJPEGRepresentation(image, 0.9).length; + + MXLogDebug(@"[MXKTools] availableCompressionSizesForImage: %f %f - File size: %tu", compressionSizes.original.imageSize.width, compressionSizes.original.imageSize.height, compressionSizes.original.fileSize); + + compressionSizes.actualLargeSize = MXKTOOLS_LARGE_IMAGE_SIZE; + + // Compute the file size for each compression level + CGFloat maxSize = MAX(compressionSizes.original.imageSize.width, compressionSizes.original.imageSize.height); + if (maxSize >= MXKTOOLS_SMALL_IMAGE_SIZE) + { + compressionSizes.small.imageSize = [MXKTools resizeImageSize:compressionSizes.original.imageSize toFitInSize:CGSizeMake(MXKTOOLS_SMALL_IMAGE_SIZE, MXKTOOLS_SMALL_IMAGE_SIZE) canExpand:NO]; + + compressionSizes.small.fileSize = (NSUInteger)[MXTools roundFileSize:(long long)(compressionSizes.small.imageSize.width * compressionSizes.small.imageSize.height * 0.20)]; + + if (maxSize >= MXKTOOLS_MEDIUM_IMAGE_SIZE) + { + compressionSizes.medium.imageSize = [MXKTools resizeImageSize:compressionSizes.original.imageSize toFitInSize:CGSizeMake(MXKTOOLS_MEDIUM_IMAGE_SIZE, MXKTOOLS_MEDIUM_IMAGE_SIZE) canExpand:NO]; + + compressionSizes.medium.fileSize = (NSUInteger)[MXTools roundFileSize:(long long)(compressionSizes.medium.imageSize.width * compressionSizes.medium.imageSize.height * 0.20)]; + + if (maxSize >= MXKTOOLS_LARGE_IMAGE_SIZE) + { + // In case of panorama the large resolution (1024 x ...) is not relevant. We prefer consider the third of the panarama width. + compressionSizes.actualLargeSize = maxSize / 3; + if (compressionSizes.actualLargeSize < MXKTOOLS_LARGE_IMAGE_SIZE) + { + compressionSizes.actualLargeSize = MXKTOOLS_LARGE_IMAGE_SIZE; + } + else + { + // Keep a multiple of predefined large size + compressionSizes.actualLargeSize = floor(compressionSizes.actualLargeSize / MXKTOOLS_LARGE_IMAGE_SIZE) * MXKTOOLS_LARGE_IMAGE_SIZE; + } + + compressionSizes.large.imageSize = [MXKTools resizeImageSize:compressionSizes.original.imageSize toFitInSize:CGSizeMake(compressionSizes.actualLargeSize, compressionSizes.actualLargeSize) canExpand:NO]; + + compressionSizes.large.fileSize = (NSUInteger)[MXTools roundFileSize:(long long)(compressionSizes.large.imageSize.width * compressionSizes.large.imageSize.height * 0.20)]; + } + else + { + MXLogDebug(@" - too small to fit in %d", MXKTOOLS_LARGE_IMAGE_SIZE); + } + } + else + { + MXLogDebug(@" - too small to fit in %d", MXKTOOLS_MEDIUM_IMAGE_SIZE); + } + } + else + { + MXLogDebug(@" - too small to fit in %d", MXKTOOLS_SMALL_IMAGE_SIZE); + } + + return compressionSizes; +} + + ++ (CGSize)resizeImageSize:(CGSize)originalSize toFitInSize:(CGSize)maxSize canExpand:(BOOL)canExpand +{ + if ((originalSize.width == 0) || (originalSize.height == 0)) + { + return CGSizeZero; + } + + CGSize resized = originalSize; + + if ((maxSize.width > 0) && (maxSize.height > 0) && (canExpand || ((originalSize.width > maxSize.width) || (originalSize.height > maxSize.height)))) + { + CGFloat ratioX = maxSize.width / originalSize.width; + CGFloat ratioY = maxSize.height / originalSize.height; + + CGFloat scale = MIN(ratioX, ratioY); + resized.width *= scale; + resized.height *= scale; + + // padding + resized.width = floorf(resized.width / 2) * 2; + resized.height = floorf(resized.height / 2) * 2; + } + + return resized; +} + ++ (CGSize)resizeImageSize:(CGSize)originalSize toFillWithSize:(CGSize)maxSize canExpand:(BOOL)canExpand +{ + CGSize resized = originalSize; + + if ((maxSize.width > 0) && (maxSize.height > 0) && (canExpand || ((originalSize.width > maxSize.width) && (originalSize.height > maxSize.height)))) + { + CGFloat ratioX = maxSize.width / originalSize.width; + CGFloat ratioY = maxSize.height / originalSize.height; + + CGFloat scale = MAX(ratioX, ratioY); + resized.width *= scale; + resized.height *= scale; + + // padding + resized.width = floorf(resized.width / 2) * 2; + resized.height = floorf(resized.height / 2) * 2; + } + + return resized; +} + ++ (UIImage *)reduceImage:(UIImage *)image toFitInSize:(CGSize)size +{ + return [self reduceImage:image toFitInSize:size useMainScreenScale:NO]; +} + ++ (UIImage *)reduceImage:(UIImage *)image toFitInSize:(CGSize)size useMainScreenScale:(BOOL)useMainScreenScale +{ + UIImage *resizedImage; + + // Check whether resize is required + if (size.width && size.height) + { + CGFloat width = image.size.width; + CGFloat height = image.size.height; + + if (width > size.width) + { + height = (height * size.width) / width; + height = floorf(height / 2) * 2; + width = size.width; + } + if (height > size.height) + { + width = (width * size.height) / height; + width = floorf(width / 2) * 2; + height = size.height; + } + + if (width != image.size.width || height != image.size.height) + { + // Create the thumbnail + CGSize imageSize = CGSizeMake(width, height); + + // Convert first the provided size in pixels + + // The scale factor is set to 0.0 to use the scale factor of the device’s main screen. + CGFloat scale = useMainScreenScale ? 0.0 : 1.0; + + UIGraphicsBeginImageContextWithOptions(imageSize, NO, scale); + + // // set to the top quality + // CGContextRef context = UIGraphicsGetCurrentContext(); + // CGContextSetInterpolationQuality(context, kCGInterpolationHigh); + + CGRect thumbnailRect = CGRectMake(0, 0, 0, 0); + thumbnailRect.origin = CGPointMake(0.0,0.0); + thumbnailRect.size.width = imageSize.width; + thumbnailRect.size.height = imageSize.height; + + [image drawInRect:thumbnailRect]; + resizedImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + } + } + else + { + resizedImage = image; + } + + return resizedImage; +} + ++ (UIImage*)resizeImageWithData:(NSData*)imageData toFitInSize:(CGSize)size +{ + // Create the image source + CGImageSourceRef imageSource = CGImageSourceCreateWithData((__bridge CFDataRef)imageData, NULL); + + // Take the max dimension of size to fit in + CGFloat maxPixelSize = fmax(size.width, size.height); + + //Create thumbnail options + CFDictionaryRef options = (__bridge CFDictionaryRef) @{ + (id) kCGImageSourceCreateThumbnailWithTransform : (id)kCFBooleanTrue, + (id) kCGImageSourceCreateThumbnailFromImageAlways : (id)kCFBooleanTrue, + (id) kCGImageSourceThumbnailMaxPixelSize : @(maxPixelSize) + }; + + // Generate the thumbnail + CGImageRef resizedImageRef = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options); + + UIImage *resizedImage = [[UIImage alloc] initWithCGImage:resizedImageRef]; + + CGImageRelease(resizedImageRef); + CFRelease(imageSource); + + return resizedImage; +} + ++ (UIImage*)resizeImage:(UIImage *)image toSize:(CGSize)size +{ + UIImage *resizedImage = image; + + // Check whether resize is required + if (size.width && size.height) + { + // Convert first the provided size in pixels + // The scale factor is set to 0.0 to use the scale factor of the device’s main screen. + UIGraphicsBeginImageContextWithOptions(size, NO, 0.0); + + CGContextRef context = UIGraphicsGetCurrentContext(); + CGContextSetInterpolationQuality(context, kCGInterpolationHigh); + + [image drawInRect:CGRectMake(0, 0, size.width, size.height)]; + resizedImage = UIGraphicsGetImageFromCurrentImageContext(); + + UIGraphicsEndImageContext(); + } + + return resizedImage; +} + ++ (UIImage*)resizeImageWithRoundedCorners:(UIImage *)image toSize:(CGSize)size +{ + UIImage *resizedImage = image; + + // Check whether resize is required + if (size.width && size.height) + { + // Convert first the provided size in pixels + // The scale factor is set to 0.0 to use the scale factor of the device’s main screen. + UIGraphicsBeginImageContextWithOptions(size, NO, 0.0); + + CGContextRef context = UIGraphicsGetCurrentContext(); + CGContextSetInterpolationQuality(context, kCGInterpolationHigh); + + // Add a clip to round corners + [[UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, size.width, size.height) cornerRadius:size.width/2] addClip]; + + [image drawInRect:CGRectMake(0, 0, size.width, size.height)]; + resizedImage = UIGraphicsGetImageFromCurrentImageContext(); + + UIGraphicsEndImageContext(); + } + + return resizedImage; +} + ++ (UIImage*)paintImage:(UIImage*)image withColor:(UIColor*)color +{ + UIImage *newImage; + + const CGFloat *colorComponents = CGColorGetComponents(color.CGColor); + + // Create a new image with the same size + UIGraphicsBeginImageContextWithOptions(image.size, 0, 0); + + CGContextRef gc = UIGraphicsGetCurrentContext(); + + CGRect rect = (CGRect){ .size = image.size}; + + [image drawInRect:rect + blendMode:kCGBlendModeNormal + alpha:1]; + + // Binarize the image: Transform all colors into the provided color but keep the alpha + CGContextSetBlendMode(gc, kCGBlendModeSourceIn); + CGContextSetRGBFillColor(gc, colorComponents[0], colorComponents[1], colorComponents[2], colorComponents[3]); + CGContextFillRect(gc, rect); + + // Retrieve the result into an UIImage + newImage = UIGraphicsGetImageFromCurrentImageContext(); + + UIGraphicsEndImageContext(); + + return newImage; +} + ++ (UIImageOrientation)imageOrientationForRotationAngleInDegree:(NSInteger)angle +{ + NSInteger modAngle = angle % 360; + + UIImageOrientation orientation = UIImageOrientationUp; + if (45 <= modAngle && modAngle < 135) + { + return UIImageOrientationRight; + } + else if (135 <= modAngle && modAngle < 225) + { + return UIImageOrientationDown; + } + else if (225 <= modAngle && modAngle < 315) + { + return UIImageOrientationLeft; + } + + return orientation; +} + +static NSMutableDictionary* backgroundByImageNameDict; + ++ (UIColor*)convertImageToPatternColor:(NSString*)resourceName backgroundColor:(UIColor*)backgroundColor patternSize:(CGSize)patternSize resourceSize:(CGSize)resourceSize +{ + if (!resourceName) + { + return backgroundColor; + } + + if (!backgroundByImageNameDict) + { + backgroundByImageNameDict = [[NSMutableDictionary alloc] init]; + } + + NSString* key = [NSString stringWithFormat:@"%@ %f %f", resourceName, patternSize.width, resourceSize.width]; + + UIColor* bgColor = [backgroundByImageNameDict objectForKey:key]; + + if (!bgColor) + { + UIImageView* backgroundView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, patternSize.width, patternSize.height)]; + backgroundView.backgroundColor = backgroundColor; + + CGFloat offsetX = (patternSize.width - resourceSize.width) / 2.0f; + CGFloat offsetY = (patternSize.height - resourceSize.height) / 2.0f; + + UIImageView* resourceImageView = [[UIImageView alloc] initWithFrame:CGRectMake(offsetX, offsetY, resourceSize.width, resourceSize.height)]; + resourceImageView.backgroundColor = [UIColor clearColor]; + UIImage *resImage = [UIImage imageNamed:resourceName]; + if (CGSizeEqualToSize(resImage.size, resourceSize)) + { + resourceImageView.image = resImage; + } + else + { + resourceImageView.image = [MXKTools resizeImage:resImage toSize:resourceSize]; + } + + + [backgroundView addSubview:resourceImageView]; + + // Create a "canvas" (image context) to draw in. + UIGraphicsBeginImageContextWithOptions(backgroundView.frame.size, NO, 0); + + // set to the top quality + CGContextRef context = UIGraphicsGetCurrentContext(); + CGContextSetInterpolationQuality(context, kCGInterpolationHigh); + [[backgroundView layer] renderInContext: UIGraphicsGetCurrentContext()]; + UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + + bgColor = [[UIColor alloc] initWithPatternImage:image]; + [backgroundByImageNameDict setObject:bgColor forKey:key]; + } + + return bgColor; +} + +#pragma mark - Video Conversion + ++ (UIAlertController*)videoConversionPromptForVideoAsset:(AVAsset *)videoAsset + withCompletion:(void (^)(NSString * _Nullable presetName))completion +{ + UIAlertController *compressionPrompt = [UIAlertController alertControllerWithTitle:[MatrixKitL10n attachmentSizePromptTitle] + message:[MatrixKitL10n attachmentSizePromptMessage] + preferredStyle:UIAlertControllerStyleActionSheet]; + + CGSize naturalSize = [videoAsset tracksWithMediaType:AVMediaTypeVideo].firstObject.naturalSize; + + // Provide 480p as the baseline preset. + NSString *fileSizeString = [MXKTools estimatedFileSizeStringForVideoAsset:videoAsset withPresetName:AVAssetExportPreset640x480]; + NSString *title = [MatrixKitL10n attachmentSmallWithResolution:@"480p" :fileSizeString]; + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + // Call the completion with 480p preset. + completion(AVAssetExportPreset640x480); + }]]; + + // Allow 720p when the video exceeds 480p. + if (naturalSize.height > 480) + { + NSString *fileSizeString = [MXKTools estimatedFileSizeStringForVideoAsset:videoAsset withPresetName:AVAssetExportPreset1280x720]; + NSString *title = [MatrixKitL10n attachmentMediumWithResolution:@"720p" :fileSizeString]; + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + // Call the completion with 720p preset. + completion(AVAssetExportPreset1280x720); + }]]; + } + + // Allow 1080p when the video exceeds 720p. + if (naturalSize.height > 720) + { + NSString *fileSizeString = [MXKTools estimatedFileSizeStringForVideoAsset:videoAsset withPresetName:AVAssetExportPreset1920x1080]; + NSString *title = [MatrixKitL10n attachmentLargeWithResolution:@"1080p" :fileSizeString]; + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + // Call the completion with 1080p preset. + completion(AVAssetExportPreset1920x1080); + }]]; + } + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleCancel + handler:^(UIAlertAction * action) { + // Cancelled. Call the completion with nil. + completion(nil); + }]]; + + return compressionPrompt; +} + ++ (NSString *)estimatedFileSizeStringForVideoAsset:(AVAsset *)videoAsset withPresetName:(NSString *)presetName +{ + AVAssetExportSession *exportSession = [AVAssetExportSession exportSessionWithAsset:videoAsset presetName:presetName]; + exportSession.timeRange = CMTimeRangeMake(kCMTimeZero, videoAsset.duration); + + return [MXTools fileSizeToString:exportSession.estimatedOutputFileLength]; +} + +#pragma mark - App permissions + ++ (void)checkAccessForMediaType:(NSString *)mediaType + manualChangeMessage:(NSString *)manualChangeMessage + showPopUpInViewController:(UIViewController *)viewController + completionHandler:(void (^)(BOOL))handler +{ + [AVCaptureDevice requestAccessForMediaType:mediaType completionHandler:^(BOOL granted) { + + dispatch_async(dispatch_get_main_queue(), ^{ + + if (granted) + { + handler(YES); + } + else + { + // Access not granted to mediaType + // Display manualChangeMessage + UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil message:manualChangeMessage preferredStyle:UIAlertControllerStyleAlert]; + + // On iOS >= 8, add a shortcut to the app settings (This requires the shared application instance) + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication && UIApplicationOpenSettingsURLString) + { + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n settings] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + NSURL *url = [NSURL URLWithString:UIApplicationOpenSettingsURLString]; + [sharedApplication performSelector:@selector(openURL:) withObject:url]; + + // Note: it does not worth to check if the user changes the permission + // because iOS restarts the app in case of change of app privacy settings + handler(NO); + + }]]; + } + + [alert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + handler(NO); + + }]]; + + [viewController presentViewController:alert animated:YES completion:nil]; + } + + }); + }]; +} + ++ (void)checkAccessForCall:(BOOL)isVideoCall +manualChangeMessageForAudio:(NSString*)manualChangeMessageForAudio +manualChangeMessageForVideo:(NSString*)manualChangeMessageForVideo + showPopUpInViewController:(UIViewController*)viewController + completionHandler:(void (^)(BOOL granted))handler +{ + // Check first microphone permission + [MXKTools checkAccessForMediaType:AVMediaTypeAudio manualChangeMessage:manualChangeMessageForAudio showPopUpInViewController:viewController completionHandler:^(BOOL granted) { + + if (granted) + { + // Check camera permission in case of video call + if (isVideoCall) + { + [MXKTools checkAccessForMediaType:AVMediaTypeVideo manualChangeMessage:manualChangeMessageForVideo showPopUpInViewController:viewController completionHandler:^(BOOL granted) { + + handler(granted); + }]; + } + else + { + handler(YES); + } + } + else + { + handler(NO); + } + }]; +} + ++ (void)checkAccessForContacts:(NSString *)manualChangeMessage + showPopUpInViewController:(UIViewController *)viewController + completionHandler:(void (^)(BOOL granted))handler +{ + [self checkAccessForContacts:nil withManualChangeMessage:manualChangeMessage showPopUpInViewController:viewController completionHandler:handler]; +} + ++ (void)checkAccessForContacts:(NSString *)manualChangeTitle + withManualChangeMessage:(NSString *)manualChangeMessage + showPopUpInViewController:(UIViewController *)viewController + completionHandler:(void (^)(BOOL granted))handler +{ + // Check if the application is allowed to list the contacts + CNAuthorizationStatus authStatus = [CNContactStore authorizationStatusForEntityType:CNEntityTypeContacts]; + if (authStatus == CNAuthorizationStatusAuthorized) + { + handler(YES); + } + else if (authStatus == CNAuthorizationStatusNotDetermined) + { + // Request address book access + [[CNContactStore new] requestAccessForEntityType:CNEntityTypeContacts completionHandler:^(BOOL granted, NSError * _Nullable error) { + + [MXSDKOptions.sharedInstance.analyticsDelegate trackValue:[NSNumber numberWithBool:granted] + category:MXKAnalyticsCategoryContacts + name:MXKAnalyticsNameContactsAccessGranted]; + + dispatch_async(dispatch_get_main_queue(), ^{ + + handler(granted); + + }); + }]; + } + else if (authStatus == CNAuthorizationStatusDenied && viewController && manualChangeMessage) + { + // Access not granted to the local contacts + // Display manualChangeMessage + UIAlertController *alert = [UIAlertController alertControllerWithTitle:manualChangeTitle message:manualChangeMessage preferredStyle:UIAlertControllerStyleAlert]; + + [alert addAction:[UIAlertAction actionWithTitle:MatrixKitL10n.cancel + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + handler(NO); + + }]]; + + // Add a shortcut to the app settings (This requires the shared application instance) + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + UIAlertAction *settingsAction = [UIAlertAction actionWithTitle:MatrixKitL10n.settings + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + [MXKAppSettings standardAppSettings].syncLocalContactsPermissionOpenedSystemSettings = YES; + // Wait for the setting to be saved as the app could be killed imminently. + [[NSUserDefaults standardUserDefaults] synchronize]; + + NSURL *url = [NSURL URLWithString:UIApplicationOpenSettingsURLString]; + [sharedApplication performSelector:@selector(openURL:) withObject:url]; + + // Note: it does not worth to check if the user changes the permission + // because iOS restarts the app in case of change of app privacy settings + handler(NO); + }]; + + [alert addAction: settingsAction]; + alert.preferredAction = settingsAction; + } + + [viewController presentViewController:alert animated:YES completion:nil]; + } + else + { + handler(NO); + } +} + +#pragma mark - HTML processing + ++ (NSAttributedString*)removeDTCoreTextArtifacts:(NSAttributedString*)attributedString +{ + NSMutableAttributedString *mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString]; + + // DTCoreText adds a newline at the end of plain text ( https://github.com/Cocoanetics/DTCoreText/issues/779 ) + // or after a blockquote section. + // Trim trailing whitespace and newlines in the string content + while ([mutableAttributedString.string hasSuffixCharacterFromSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]) + { + [mutableAttributedString deleteCharactersInRange:NSMakeRange(mutableAttributedString.length - 1, 1)]; + } + + // New lines may have also been introduced by the paragraph style + // Make sure the last paragraph style has no spacing + [mutableAttributedString enumerateAttributesInRange:NSMakeRange(0, mutableAttributedString.length) options:(NSAttributedStringEnumerationReverse) usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) { + + if (attrs[NSParagraphStyleAttributeName]) + { + NSString *subString = [mutableAttributedString.string substringWithRange:range]; + NSArray *components = [subString componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]; + + NSMutableDictionary *updatedAttrs = [NSMutableDictionary dictionaryWithDictionary:attrs]; + NSMutableParagraphStyle *paragraphStyle = [updatedAttrs[NSParagraphStyleAttributeName] mutableCopy]; + paragraphStyle.paragraphSpacing = 0; + updatedAttrs[NSParagraphStyleAttributeName] = paragraphStyle; + + if (components.count > 1) + { + NSString *lastComponent = components.lastObject; + + NSRange range2 = NSMakeRange(range.location, range.length - lastComponent.length); + [mutableAttributedString setAttributes:attrs range:range2]; + + range2 = NSMakeRange(range2.location + range2.length, lastComponent.length); + [mutableAttributedString setAttributes:updatedAttrs range:range2]; + } + else + { + [mutableAttributedString setAttributes:updatedAttrs range:range]; + } + } + + // Check only the last paragraph + *stop = YES; + }]; + + // Image rendering failed on an exception until we replace the DTImageTextAttachments with a simple NSTextAttachment subclass + // (thanks to https://github.com/Cocoanetics/DTCoreText/issues/863). + [mutableAttributedString enumerateAttribute:NSAttachmentAttributeName + inRange:NSMakeRange(0, mutableAttributedString.length) + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + + if ([value isKindOfClass:DTImageTextAttachment.class]) + { + DTImageTextAttachment *attachment = (DTImageTextAttachment*)value; + NSTextAttachment *textAttachment = [[NSTextAttachment alloc] init]; + if (attachment.image) + { + textAttachment.image = attachment.image; + + CGRect frame = textAttachment.bounds; + frame.size = attachment.displaySize; + textAttachment.bounds = frame; + } + // Note we remove here attachment without image. + NSAttributedString *attrStringWithImage = [NSAttributedString attributedStringWithAttachment:textAttachment]; + [mutableAttributedString replaceCharactersInRange:range withAttributedString:attrStringWithImage]; + } + }]; + + return mutableAttributedString; +} + ++ (NSAttributedString*)createLinksInAttributedString:(NSAttributedString*)attributedString forEnabledMatrixIds:(NSInteger)enabledMatrixIdsBitMask +{ + if (!attributedString) + { + return nil; + } + + NSMutableAttributedString *postRenderAttributedString; + + // If enabled, make user id clickable + if (enabledMatrixIdsBitMask & MXKTOOLS_USER_IDENTIFIER_BITWISE) + { + [MXKTools createLinksInAttributedString:attributedString matchingRegex:userIdRegex withWorkingAttributedString:&postRenderAttributedString]; + } + + // If enabled, make room id clickable + if (enabledMatrixIdsBitMask & MXKTOOLS_ROOM_IDENTIFIER_BITWISE) + { + [MXKTools createLinksInAttributedString:attributedString matchingRegex:roomIdRegex withWorkingAttributedString:&postRenderAttributedString]; + } + + // If enabled, make room alias clickable + if (enabledMatrixIdsBitMask & MXKTOOLS_ROOM_ALIAS_BITWISE) + { + [MXKTools createLinksInAttributedString:attributedString matchingRegex:roomAliasRegex withWorkingAttributedString:&postRenderAttributedString]; + } + + // If enabled, make event id clickable + if (enabledMatrixIdsBitMask & MXKTOOLS_EVENT_IDENTIFIER_BITWISE) + { + [MXKTools createLinksInAttributedString:attributedString matchingRegex:eventIdRegex withWorkingAttributedString:&postRenderAttributedString]; + } + + // If enabled, make group id clickable + if (enabledMatrixIdsBitMask & MXKTOOLS_GROUP_IDENTIFIER_BITWISE) + { + [MXKTools createLinksInAttributedString:attributedString matchingRegex:groupIdRegex withWorkingAttributedString:&postRenderAttributedString]; + } + + return postRenderAttributedString ? postRenderAttributedString : attributedString; +} + ++ (void)createLinksInAttributedString:(NSAttributedString*)attributedString matchingRegex:(NSRegularExpression*)regex withWorkingAttributedString:(NSMutableAttributedString* __autoreleasing *)mutableAttributedString +{ + __block NSArray *linkMatches; + + // Enumerate each string matching the regex + [regex enumerateMatchesInString:attributedString.string options:0 range:NSMakeRange(0, attributedString.length) usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags flags, BOOL *stop) { + + // Do not create a link if there is already one on the found match + __block BOOL hasAlreadyLink = NO; + [attributedString enumerateAttributesInRange:match.range options:0 usingBlock:^(NSDictionary * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) { + + if (attrs[NSLinkAttributeName]) + { + hasAlreadyLink = YES; + *stop = YES; + } + }]; + + // Do not create a link if the match is part of an http link. + // The http link will be automatically generated by the UI afterwards. + // So, do not break it now by adding a link on a subset of this http link. + if (!hasAlreadyLink) + { + if (!linkMatches) + { + // Search for the links in the string only once + // Do not use NSDataDetector with NSTextCheckingTypeLink because is not able to + // manage URLs with 2 hashes like "https://matrix.to/#/#matrix:matrix.org" + // Such URL is not valid but web browsers can open them and users C+P them... + // NSDataDetector does not support it but UITextView and UIDataDetectorTypeLink + // detect them when they are displayed. So let the UI create the link at display. + linkMatches = [httpLinksRegex matchesInString:attributedString.string options:0 range:NSMakeRange(0, attributedString.length)]; + } + + for (NSTextCheckingResult *linkMatch in linkMatches) + { + // If the match is fully in the link, skip it + if (NSIntersectionRange(match.range, linkMatch.range).length == match.range.length) + { + hasAlreadyLink = YES; + break; + } + } + } + + if (!hasAlreadyLink) + { + // Create the output string only if it is necessary because attributed strings cost CPU + if (!*mutableAttributedString) + { + *mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString]; + } + + // Make the link clickable + // Caution: We need here to escape the non-ASCII characters (like '#' in room alias) + // to convert the link into a legal URL string. + NSString *link = [attributedString.string substringWithRange:match.range]; + link = [link stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding]; + [*mutableAttributedString addAttribute:NSLinkAttributeName value:link range:match.range]; + } + }]; +} + +#pragma mark - HTML processing - blockquote display handling + ++ (NSString*)cssToMarkBlockquotes +{ + return [NSString stringWithFormat:@"blockquote {background: #%lX; display: block;}", (unsigned long)[MXKTools rgbValueWithColor:kMXKToolsBlockquoteMarkColor]]; +} + ++ (NSAttributedString*)removeMarkedBlockquotesArtifacts:(NSAttributedString*)attributedString +{ + NSMutableAttributedString *mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString]; + + // Enumerate all sections marked thanks to `cssToMarkBlockquotes` + // and apply our own attribute instead. + + // According to blockquotes in the string, DTCoreText can apply 2 policies: + // - define a `DTTextBlocksAttribute` attribute on a

block + // - or, just define a `NSBackgroundColorAttributeName` attribute + + // `DTTextBlocksAttribute` case + [attributedString enumerateAttribute:DTTextBlocksAttribute + inRange:NSMakeRange(0, attributedString.length) + options:0 + usingBlock:^(id _Nullable value, NSRange range, BOOL * _Nonnull stop) + { + if ([value isKindOfClass:NSArray.class]) + { + NSArray *array = (NSArray*)value; + if (array.count > 0 && [array[0] isKindOfClass:DTTextBlock.class]) + { + DTTextBlock *dtTextBlock = (DTTextBlock *)array[0]; + if ([dtTextBlock.backgroundColor isEqual:kMXKToolsBlockquoteMarkColor]) + { + // Apply our own attribute + [mutableAttributedString addAttribute:kMXKToolsBlockquoteMarkAttribute value:@(YES) range:range]; + + // Fix a boring behaviour where DTCoreText add a " " string before a string corresponding + // to an HTML blockquote. This " " string has ParagraphStyle.headIndent = 0 which breaks + // the blockquote block indentation + if (range.location > 0) + { + NSRange prevRange = NSMakeRange(range.location - 1, 1); + + NSRange effectiveRange; + NSParagraphStyle *paragraphStyle = [attributedString attribute:NSParagraphStyleAttributeName + atIndex:prevRange.location + effectiveRange:&effectiveRange]; + + // Check if this is the " " string + if (paragraphStyle && effectiveRange.length == 1 && paragraphStyle.firstLineHeadIndent != 25) + { + // Fix its paragraph style + NSMutableParagraphStyle *newParagraphStyle = [paragraphStyle mutableCopy]; + newParagraphStyle.firstLineHeadIndent = 25.0; + newParagraphStyle.headIndent = 25.0; + + [mutableAttributedString addAttribute:NSParagraphStyleAttributeName value:newParagraphStyle range:prevRange]; + } + } + } + } + } + }]; + + // `NSBackgroundColorAttributeName` case + [mutableAttributedString enumerateAttribute:NSBackgroundColorAttributeName + inRange:NSMakeRange(0, mutableAttributedString.length) + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) + { + + if ([value isKindOfClass:UIColor.class] && [(UIColor*)value isEqual:[UIColor magentaColor]]) + { + // Remove the marked background + [mutableAttributedString removeAttribute:NSBackgroundColorAttributeName range:range]; + + // And apply our own attribute + [mutableAttributedString addAttribute:kMXKToolsBlockquoteMarkAttribute value:@(YES) range:range]; + } + }]; + + return mutableAttributedString; +} + ++ (void)enumerateMarkedBlockquotesInAttributedString:(NSAttributedString*)attributedString usingBlock:(void (^)(NSRange range, BOOL *stop))block +{ + [attributedString enumerateAttribute:kMXKToolsBlockquoteMarkAttribute + inRange:NSMakeRange(0, attributedString.length) + options:0 + usingBlock:^(id _Nullable value, NSRange range, BOOL * _Nonnull stop) + { + if ([value isKindOfClass:NSNumber.class] && ((NSNumber*)value).boolValue) + { + block(range, stop); + } + }]; +} + +#pragma mark - Push + +// Trim push token before printing it in logs ++ (NSString*)logForPushToken:(NSData*)pushToken +{ + NSUInteger len = ((pushToken.length > 8) ? 8 : pushToken.length / 2); + return [NSString stringWithFormat:@"%@...", [pushToken subdataWithRange:NSMakeRange(0, len)]]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Utils/MXKUTI.swift b/Riot/Modules/MatrixKit/Utils/MXKUTI.swift new file mode 100644 index 000000000..911beec65 --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKUTI.swift @@ -0,0 +1,203 @@ +/* + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import ImageIO +import MobileCoreServices + +// We do not use the SwiftUTI pod anymore +// The library is embedded in MatrixKit. See Libs/SwiftUTI/README.md for more details +//import SwiftUTI + +/// MXKUTI represents a Universal Type Identifier (e.g. kUTTypePNG). +/// See https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/understanding_utis/understand_utis_conc/understand_utis_conc.html#//apple_ref/doc/uid/TP40001319-CH202-SW5 for more information. +/// MXKUTI wraps UTI class from SwiftUTI library (https://github.com/mkeiser/SwiftUTI) to make it available for Objective-C. +@objcMembers +open class MXKUTI: NSObject, RawRepresentable { + + public typealias RawValue = String + + // MARK: - Properties + + // MARK: Private + + private let utiWrapper: UTI + + // MARK: Public + + /// UTI string + public var rawValue: String { + return utiWrapper.rawValue + } + + /// Return associated prefered file extension (e.g. "png"). + public var fileExtension: String? { + return utiWrapper.fileExtension + } + + /// Return associated prefered mime-type (e.g. "image/png"). + public var mimeType: String? { + return utiWrapper.mimeType + } + + // MARK: - Setup + + // MARK: Private + + private init(utiWrapper: UTI) { + self.utiWrapper = utiWrapper + super.init() + } + + // MARK: Public + + /// Initialize with UTI String. + /// Note: Although this initializer is marked as failable, due to RawRepresentable conformity, it cannot fail. + /// + /// - Parameter rawValue: UTI String (e.g. "public.png"). + public required init?(rawValue: String) { + let utiWrapper = UTI(rawValue: rawValue) + self.utiWrapper = utiWrapper + super.init() + } + + /// Initialize with UTI CFString. + /// + /// - Parameter cfRawValue: UTI CFString (e.g. kUTTypePNG). + public convenience init?(cfRawValue: CFString) { + self.init(rawValue: cfRawValue as String) + } + + /// Initialize with file extension. + /// + /// - Parameter fileExtension: A file extesion (e.g. "png"). + public convenience init(fileExtension: String) { + let utiWrapper = UTI(withExtension: fileExtension) + self.init(utiWrapper: utiWrapper) + } + + /// Initialize with MIME type. + /// + /// - Parameter mimeType: A MIME type (e.g. "image/png"). + public convenience init?(mimeType: String) { + let utiWrapper = UTI(withMimeType: mimeType) + self.init(utiWrapper: utiWrapper) + } + + /// Check current UTI conformance with another UTI. + /// + /// - Parameter otherUTI: UTI which to conform with. + /// - Returns: true if self conforms to other UTI. + public func conforms(to otherUTI: MXKUTI) -> Bool { + return self.utiWrapper.conforms(to: otherUTI.utiWrapper) + } + + /// Check whether the current UTI conforms to any UTIs within an array. + /// + /// - Parameter otherUTIs: UTI which to conform with. + /// - Returns: true if self conforms to any of the other UTIs. + public func conformsToAny(of otherUTIs: [MXKUTI]) -> Bool { + for uti in otherUTIs { + if conforms(to: uti) { + return true + } + } + + return false + } +} + +// MARK: - Other convenients initializers +extension MXKUTI { + + /// Initialize with image data. + /// + /// - Parameter imageData: Image data. + convenience init?(imageData: Data) { + guard let imageSource = CGImageSourceCreateWithData(imageData as CFData, nil), + let uti = CGImageSourceGetType(imageSource) else { + return nil + } + self.init(rawValue: uti as String) + } + + /// Initialize with local file URL. + /// This method is currently applicable only to URLs for file system resources. + /// + /// - Parameters: + /// - localFileURL: Local file URL. + /// - loadResourceValues: Indicate true to prefetch `typeIdentifierKey` URLResourceKey + convenience init?(localFileURL: URL, loadResourceValues: Bool = true) { + if loadResourceValues, + let _ = try? FileManager.default.contentsOfDirectory(at: localFileURL.deletingLastPathComponent(), includingPropertiesForKeys: [.typeIdentifierKey], options: []), + let uti = try? localFileURL.resourceValues(forKeys: [.typeIdentifierKey]).typeIdentifier { + self.init(rawValue: uti) + } else if localFileURL.pathExtension.isEmpty == false { + let fileExtension = localFileURL.pathExtension + self.init(fileExtension: fileExtension) + } else { + return nil + } + } + + public convenience init?(localFileURL: URL) { + self.init(localFileURL: localFileURL, loadResourceValues: true) + } +} + +// MARK: - Convenients conformance UTIs methods +extension MXKUTI { + public var isImage: Bool { + return self.conforms(to: MXKUTI.image) + } + + public var isVideo: Bool { + return self.conforms(to: MXKUTI.movie) + } + + public var isFile: Bool { + return self.conforms(to: MXKUTI.data) + } +} + +// MARK: - Some system defined UTIs +extension MXKUTI { + public static let data = MXKUTI(cfRawValue: kUTTypeData)! + public static let text = MXKUTI(cfRawValue: kUTTypeText)! + public static let audio = MXKUTI(cfRawValue: kUTTypeAudio)! + public static let video = MXKUTI(cfRawValue: kUTTypeVideo)! + public static let movie = MXKUTI(cfRawValue: kUTTypeMovie)! + public static let image = MXKUTI(cfRawValue: kUTTypeImage)! + public static let png = MXKUTI(cfRawValue: kUTTypePNG)! + public static let jpeg = MXKUTI(cfRawValue: kUTTypeJPEG)! + public static let svg = MXKUTI(cfRawValue: kUTTypeScalableVectorGraphics)! + public static let url = MXKUTI(cfRawValue: kUTTypeURL)! + public static let fileUrl = MXKUTI(cfRawValue: kUTTypeFileURL)! + public static let html = MXKUTI(cfRawValue: kUTTypeHTML)! + public static let xml = MXKUTI(cfRawValue: kUTTypeXML)! +} + +// MARK: - Convenience static methods +extension MXKUTI { + + public static func mimeType(from fileExtension: String) -> String? { + return MXKUTI(fileExtension: fileExtension).mimeType + } + + public static func fileExtension(from mimeType: String) -> String? { + return MXKUTI(mimeType: mimeType)?.fileExtension + } +} diff --git a/Riot/Modules/MatrixKit/Utils/MXKVideoThumbnailGenerator.swift b/Riot/Modules/MatrixKit/Utils/MXKVideoThumbnailGenerator.swift new file mode 100644 index 000000000..9f999294b --- /dev/null +++ b/Riot/Modules/MatrixKit/Utils/MXKVideoThumbnailGenerator.swift @@ -0,0 +1,76 @@ +/* + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import UIKit +import AVFoundation + +/// MXKVideoThumbnailGenerator is a utility class to generate a thumbnail image from a video file. +@objcMembers +public class MXKVideoThumbnailGenerator: NSObject { + + public static let shared = MXKVideoThumbnailGenerator() + + // MARK - Public + + /// Generate thumbnail image from a video URL. + /// Note: Do not make `maximumSize` optional with default nil value for Objective-C compatibility. + /// + /// - Parameters: + /// - url: Video URL. + /// - maximumSize: Maximum dimension for generated thumbnail image. + /// - Returns: Thumbnail image or nil. + public func generateThumbnail(from url: URL, with maximumSize: CGSize) -> UIImage? { + let finalSize: CGSize? = maximumSize != .zero ? maximumSize : nil + return self.generateThumbnail(from: url, with: finalSize) + } + + /// Generate thumbnail image from a video URL. + /// + /// - Parameter url: Video URL. + /// - Returns: Thumbnail image or nil. + public func generateThumbnail(from url: URL) -> UIImage? { + return generateThumbnail(from: url, with: nil) + } + + // MARK - Private + + /// Generate thumbnail image from a video URL. + /// + /// - Parameters: + /// - url: Video URL. + /// - maximumSize: Maximum dimension for generated thumbnail image or nil to keep video dimension. + /// - Returns: Thumbnail image or nil. + private func generateThumbnail(from url: URL, with maximumSize: CGSize?) -> UIImage? { + let thumbnailImage: UIImage? + + let asset = AVAsset(url: url) + let assetImageGenerator = AVAssetImageGenerator(asset: asset) + assetImageGenerator.appliesPreferredTrackTransform = true + if let maximumSize = maximumSize { + assetImageGenerator.maximumSize = maximumSize + } + do { + // Generate thumbnail from first video image + let image = try assetImageGenerator.copyCGImage(at: .zero, actualTime: nil) + thumbnailImage = UIImage(cgImage: image) + } catch { + MXLog.error(error.localizedDescription) + thumbnailImage = nil + } + + return thumbnailImage + } +} diff --git a/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.h b/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.h new file mode 100644 index 000000000..466872958 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.h @@ -0,0 +1,43 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +#import "MXKImageView.h" +#import "MXKAccount.h" + +/** + MXKAccountTableViewCell instance is a table view cell used to display a matrix user. + */ +@interface MXKAccountTableViewCell : MXKTableViewCell + +/** + The displayed account + */ +@property (nonatomic) MXKAccount* mxAccount; + +/** + The default account picture displayed when no picture is defined. + */ +@property (nonatomic) UIImage *picturePlaceholder; + +@property (strong, nonatomic) IBOutlet MXKImageView* accountPicture; + +@property (strong, nonatomic) IBOutlet UILabel* accountDisplayName; + +@property (strong, nonatomic) IBOutlet UISwitch* accountSwitchToggle; + +@end diff --git a/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.m b/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.m new file mode 100644 index 000000000..c47f5e4e5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.m @@ -0,0 +1,96 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAccountTableViewCell.h" + +@import MatrixSDK.MXMediaManager; + +#import "NSBundle+MatrixKit.h" + +@implementation MXKAccountTableViewCell + +- (void)customizeTableViewCellRendering +{ + [super customizeTableViewCellRendering]; + + self.accountPicture.defaultBackgroundColor = [UIColor clearColor]; +} + +- (void)setMxAccount:(MXKAccount *)mxAccount +{ + UIColor *presenceColor = nil; + + _accountDisplayName.text = mxAccount.fullDisplayName; + + if (mxAccount.mxSession) + { + _accountPicture.mediaFolder = kMXMediaManagerAvatarThumbnailFolder; + _accountPicture.enableInMemoryCache = YES; + [_accountPicture setImageURI:mxAccount.userAvatarUrl + withType:nil + andImageOrientation:UIImageOrientationUp + toFitViewSize:_accountPicture.frame.size + withMethod:MXThumbnailingMethodCrop + previewImage:self.picturePlaceholder + mediaManager:mxAccount.mxSession.mediaManager]; + + presenceColor = [MXKAccount presenceColor:mxAccount.userPresence]; + } + else + { + _accountPicture.image = self.picturePlaceholder; + } + + if (presenceColor) + { + _accountPicture.layer.borderWidth = 2; + _accountPicture.layer.borderColor = presenceColor.CGColor; + } + else + { + _accountPicture.layer.borderWidth = 0; + } + + _accountSwitchToggle.on = !mxAccount.disabled; + if (mxAccount.disabled) + { + _accountDisplayName.textColor = [UIColor lightGrayColor]; + } + else + { + _accountDisplayName.textColor = [UIColor blackColor]; + } + + _mxAccount = mxAccount; +} + +- (UIImage*)picturePlaceholder +{ + return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"]; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + // Round image view + [_accountPicture.layer setCornerRadius:_accountPicture.frame.size.width / 2]; + _accountPicture.clipsToBounds = YES; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.xib b/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.xib new file mode 100644 index 000000000..f5696126d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Account/MXKAccountTableViewCell.xib @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.h b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.h new file mode 100644 index 000000000..d5406f4d5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.h @@ -0,0 +1,41 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAuthInputsView.h" + +@interface MXKAuthInputsEmailCodeBasedView : MXKAuthInputsView + +/** + The input text field related to user id or user login. + */ +@property (weak, nonatomic) IBOutlet UITextField *userLoginTextField; + +/** + The input text field used to fill an email or the related token. + */ +@property (weak, nonatomic) IBOutlet UITextField *emailAndTokenTextField; + +/** + Label used to prompt user to fill the email token. + */ +@property (weak, nonatomic) IBOutlet UILabel *promptEmailTokenLabel; + +/** + The text field related to the display name. This item is displayed in case of registration. + */ +@property (weak, nonatomic) IBOutlet UITextField *displayNameTextField; + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.m b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.m new file mode 100644 index 000000000..f43aada0d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.m @@ -0,0 +1,202 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAuthInputsEmailCodeBasedView.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKAuthInputsEmailCodeBasedView + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKAuthInputsEmailCodeBasedView class]) + bundle:[NSBundle bundleForClass:[MXKAuthInputsEmailCodeBasedView class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + _userLoginTextField.placeholder = [MatrixKitL10n loginUserIdPlaceholder]; + _emailAndTokenTextField.placeholder = [MatrixKitL10n loginEmailPlaceholder]; + _promptEmailTokenLabel.text = [MatrixKitL10n loginPromptEmailToken]; + + _displayNameTextField.placeholder = [MatrixKitL10n loginDisplayNamePlaceholder]; +} + +#pragma mark - + +- (BOOL)setAuthSession:(MXAuthenticationSession *)authSession withAuthType:(MXKAuthenticationType)authType; +{ + if (type == MXKAuthenticationTypeLogin || type == MXKAuthenticationTypeRegister) + { + // Validate first the provided session + MXAuthenticationSession *validSession = [self validateAuthenticationSession:authSession]; + + if ([super setAuthSession:validSession withAuthType:authType]) + { + // Set initial layout + self.userLoginTextField.hidden = NO; + self.promptEmailTokenLabel.hidden = YES; + + if (type == MXKAuthenticationTypeLogin) + { + self.emailAndTokenTextField.returnKeyType = UIReturnKeyDone; + self.displayNameTextField.hidden = YES; + + self.viewHeightConstraint.constant = self.displayNameTextField.frame.origin.y; + } + else + { + self.emailAndTokenTextField.returnKeyType = UIReturnKeyNext; + self.displayNameTextField.hidden = NO; + + self.viewHeightConstraint.constant = 122; + } + + return YES; + } + } + + return NO; +} + +- (NSString*)validateParameters +{ + NSString *errorMsg = [super validateParameters]; + + if (!errorMsg) + { + if (!self.areAllRequiredFieldsSet) + { + errorMsg = [MatrixKitL10n loginInvalidParam]; + } + } + + return errorMsg; +} + +- (BOOL)areAllRequiredFieldsSet +{ + BOOL ret = [super areAllRequiredFieldsSet]; + + // Check required fields //FIXME what are required fields in this authentication flow? + ret = (ret && self.userLoginTextField.text.length && self.emailAndTokenTextField.text.length); + + return ret; +} + +- (void)dismissKeyboard +{ + [self.userLoginTextField resignFirstResponder]; + [self.emailAndTokenTextField resignFirstResponder]; + [self.displayNameTextField resignFirstResponder]; + + [super dismissKeyboard]; +} + +- (void)nextStep +{ + // Consider here the email token has been requested with success + [super nextStep]; + + self.userLoginTextField.hidden = YES; + self.promptEmailTokenLabel.hidden = NO; + self.emailAndTokenTextField.placeholder = nil; + self.emailAndTokenTextField.returnKeyType = UIReturnKeyDone; + + self.displayNameTextField.hidden = YES; +} + +- (NSString*)userId +{ + return self.userLoginTextField.text; +} + +#pragma mark UITextField delegate + +- (BOOL)textFieldShouldReturn:(UITextField*)textField +{ + if (textField.returnKeyType == UIReturnKeyDone) + { + // "Done" key has been pressed + [textField resignFirstResponder]; + + // Launch authentication now + [self.delegate authInputsViewDidPressDoneKey:self]; + } + else + { + //"Next" key has been pressed + if (textField == self.userLoginTextField) + { + [self.emailAndTokenTextField becomeFirstResponder]; + } + else if (textField == self.emailAndTokenTextField) + { + [self.displayNameTextField becomeFirstResponder]; + } + } + + return YES; +} + +#pragma mark - + +- (MXAuthenticationSession*)validateAuthenticationSession:(MXAuthenticationSession*)authSession +{ + // Check whether at least one of the listed flow is supported. + BOOL isSupported = NO; + + for (MXLoginFlow *loginFlow in authSession.flows) + { + // Check whether flow type is defined + if ([loginFlow.type isEqualToString:kMXLoginFlowTypeEmailCode]) + { + isSupported = YES; + break; + } + else if (loginFlow.stages.count == 1 && [loginFlow.stages.firstObject isEqualToString:kMXLoginFlowTypeEmailCode]) + { + isSupported = YES; + break; + } + } + + if (isSupported) + { + if (authSession.flows.count == 1) + { + // Return the original session. + return authSession; + } + else + { + // Keep only the supported flow. + MXAuthenticationSession *updatedAuthSession = [[MXAuthenticationSession alloc] init]; + updatedAuthSession.session = authSession.session; + updatedAuthSession.params = authSession.params; + updatedAuthSession.flows = @[[MXLoginFlow modelFromJSON:@{@"stages":@[kMXLoginFlowTypeEmailCode]}]]; + return updatedAuthSession; + } + } + + return nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.xib b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.xib new file mode 100644 index 000000000..e70ee341a --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsEmailCodeBasedView.xib @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.h b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.h new file mode 100644 index 000000000..d5d62ace6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.h @@ -0,0 +1,46 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAuthInputsView.h" + +@interface MXKAuthInputsPasswordBasedView : MXKAuthInputsView + +/** + The input text field related to user id or user login. + */ +@property (weak, nonatomic) IBOutlet UITextField *userLoginTextField; + +/** + The input text field used to fill the password. + */ +@property (weak, nonatomic) IBOutlet UITextField *passWordTextField; + +/** + The input text field used to fill an email. This item is optional, it is added in case of registration. + */ +@property (weak, nonatomic) IBOutlet UITextField *emailTextField; + +/** + Label used to display email field information. + */ +@property (weak, nonatomic) IBOutlet UILabel *emailInfoLabel; + +/** + The text field related to the display name. This item is displayed in case of registration. + */ +@property (weak, nonatomic) IBOutlet UITextField *displayNameTextField; + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.m b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.m new file mode 100644 index 000000000..237a7a1cc --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.m @@ -0,0 +1,253 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAuthInputsPasswordBasedView.h" + +#import "MXKTools.h" + +#import "MXKAppSettings.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKAuthInputsPasswordBasedView + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKAuthInputsPasswordBasedView class]) + bundle:[NSBundle bundleForClass:[MXKAuthInputsPasswordBasedView class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + _userLoginTextField.placeholder = [MatrixKitL10n loginUserIdPlaceholder]; + _passWordTextField.placeholder = [MatrixKitL10n loginPasswordPlaceholder]; + _emailTextField.placeholder = [NSString stringWithFormat:@"%@ (%@)", [MatrixKitL10n loginEmailPlaceholder], [MatrixKitL10n loginOptionalField]]; + _emailInfoLabel.text = [MatrixKitL10n loginEmailInfo]; + + _displayNameTextField.placeholder = [MatrixKitL10n loginDisplayNamePlaceholder]; +} + +#pragma mark - + +- (BOOL)setAuthSession:(MXAuthenticationSession *)authSession withAuthType:(MXKAuthenticationType)authType; +{ + if (type == MXKAuthenticationTypeLogin || type == MXKAuthenticationTypeRegister) + { + // Validate first the provided session + MXAuthenticationSession *validSession = [self validateAuthenticationSession:authSession]; + + if ([super setAuthSession:validSession withAuthType:authType]) + { + if (type == MXKAuthenticationTypeLogin) + { + self.passWordTextField.returnKeyType = UIReturnKeyDone; + self.emailTextField.hidden = YES; + self.emailInfoLabel.hidden = YES; + self.displayNameTextField.hidden = YES; + + self.viewHeightConstraint.constant = self.displayNameTextField.frame.origin.y; + } + else + { + self.passWordTextField.returnKeyType = UIReturnKeyNext; + self.emailTextField.hidden = NO; + self.emailInfoLabel.hidden = NO; + self.displayNameTextField.hidden = NO; + + self.viewHeightConstraint.constant = 179; + } + + return YES; + } + } + + return NO; +} + +- (NSString*)validateParameters +{ + NSString *errorMsg = [super validateParameters]; + + if (!errorMsg) + { + // Check user login and pass fields + if (!self.areAllRequiredFieldsSet) + { + errorMsg = [MatrixKitL10n loginInvalidParam]; + } + } + + return errorMsg; +} + +- (void)prepareParameters:(void (^)(NSDictionary *parameters, NSError *error))callback +{ + if (callback) + { + // Sanity check on required fields + if (!self.areAllRequiredFieldsSet) + { + callback(nil, [NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n loginInvalidParam]}]); + return; + } + + // Retrieve the user login and check whether it is an email or a username. + // TODO: Update the UI view to support the login based on a mobile phone number. + NSString *user = self.userLoginTextField.text; + user = [user stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + BOOL isEmailAddress = [MXTools isEmailAddress:user]; + + NSDictionary *parameters; + + if (isEmailAddress) + { + parameters = @{ + @"type": kMXLoginFlowTypePassword, + @"identifier": @{ + @"type": kMXLoginIdentifierTypeThirdParty, + @"medium": kMX3PIDMediumEmail, + @"address": user + }, + @"password": self.passWordTextField.text + }; + } + else + { + parameters = @{ + @"type": kMXLoginFlowTypePassword, + @"identifier": @{ + @"type": kMXLoginIdentifierTypeUser, + @"user": user + }, + @"password": self.passWordTextField.text + }; + } + + callback(parameters, nil); + } +} + +- (BOOL)areAllRequiredFieldsSet +{ + BOOL ret = [super areAllRequiredFieldsSet]; + + // Check user login and pass fields + ret = (ret && self.userLoginTextField.text.length && self.passWordTextField.text.length); + + return ret; +} + +- (void)dismissKeyboard +{ + [self.userLoginTextField resignFirstResponder]; + [self.passWordTextField resignFirstResponder]; + [self.emailTextField resignFirstResponder]; + [self.displayNameTextField resignFirstResponder]; + + [super dismissKeyboard]; +} + +- (NSString*)userId +{ + return self.userLoginTextField.text; +} + +- (NSString*)password +{ + return self.passWordTextField.text; +} + +#pragma mark UITextField delegate + +- (BOOL)textFieldShouldReturn:(UITextField*)textField +{ + if (textField.returnKeyType == UIReturnKeyDone) + { + // "Done" key has been pressed + [textField resignFirstResponder]; + + // Launch authentication now + [self.delegate authInputsViewDidPressDoneKey:self]; + } + else + { + //"Next" key has been pressed + if (textField == self.userLoginTextField) + { + [self.passWordTextField becomeFirstResponder]; + } + else if (textField == self.passWordTextField) + { + [self.displayNameTextField becomeFirstResponder]; + } + else if (textField == self.displayNameTextField) + { + [self.emailTextField becomeFirstResponder]; + } + } + + return YES; +} + +#pragma mark - + +- (MXAuthenticationSession*)validateAuthenticationSession:(MXAuthenticationSession*)authSession +{ + // Check whether at least one of the listed flow is supported. + BOOL isSupported = NO; + + for (MXLoginFlow *loginFlow in authSession.flows) + { + // Check whether flow type is defined + if ([loginFlow.type isEqualToString:kMXLoginFlowTypePassword]) + { + isSupported = YES; + break; + } + else if (loginFlow.stages.count == 1 && [loginFlow.stages.firstObject isEqualToString:kMXLoginFlowTypePassword]) + { + isSupported = YES; + break; + } + } + + if (isSupported) + { + if (authSession.flows.count == 1) + { + // Return the original session. + return authSession; + } + else + { + // Keep only the supported flow. + MXAuthenticationSession *updatedAuthSession = [[MXAuthenticationSession alloc] init]; + updatedAuthSession.session = authSession.session; + updatedAuthSession.params = authSession.params; + updatedAuthSession.flows = @[[MXLoginFlow modelFromJSON:@{@"stages":@[kMXLoginFlowTypePassword]}]]; + return updatedAuthSession; + } + } + + return nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.xib b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.xib new file mode 100644 index 000000000..f19346925 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsPasswordBasedView.xib @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.h b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.h new file mode 100644 index 000000000..c71b022ae --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.h @@ -0,0 +1,242 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +extern NSString *const MXKAuthErrorDomain; + +/** + Authentication types + */ +typedef enum { + /** + Type used to sign up. + */ + MXKAuthenticationTypeRegister, + /** + Type used to sign in. + */ + MXKAuthenticationTypeLogin, + /** + Type used to restore an existing account by reseting the password. + */ + MXKAuthenticationTypeForgotPassword + +} MXKAuthenticationType; + +@class MXKAuthInputsView; + +/** + `MXKAuthInputsView` delegate + */ +@protocol MXKAuthInputsViewDelegate + +/** + Tells the delegate that an alert must be presented. + + @param authInputsView the authentication inputs view. + @param alert the alert to present. + */ +- (void)authInputsView:(MXKAuthInputsView*)authInputsView presentAlertController:(UIAlertController*)alert; + +/** + For some input fields, the return key of the keyboard is defined as `Done` key. + By this method, the delegate is notified when this key is pressed. + */ +- (void)authInputsViewDidPressDoneKey:(MXKAuthInputsView *)authInputsView; + +@optional + +/** + The matrix REST Client used to validate third-party identifiers. + */ +- (MXRestClient *)authInputsViewThirdPartyIdValidationRestClient:(MXKAuthInputsView *)authInputsView; + +/** + The identity service used to validate third-party identifiers. + */ +- (MXIdentityService *)authInputsViewThirdPartyIdValidationIdentityService:(MXKAuthInputsView *)authInputsView; + +/** + Tell the delegate to present a view controller modally. + + Note: This method is used to display the countries list during the phone number handling. + + @param authInputsView the authentication inputs view. + @param viewControllerToPresent the view controller to present. + @param animated YES to animate the presentation. + */ +- (void)authInputsView:(MXKAuthInputsView *)authInputsView presentViewController:(UIViewController*)viewControllerToPresent animated:(BOOL)animated; + +/** + Tell the delegate to cancel the current operation. + */ +- (void)authInputsViewDidCancelOperation:(MXKAuthInputsView *)authInputsView; + +/** + Tell the delegate to autodiscover the server configuration. + */ +- (void)authInputsView:(MXKAuthInputsView *)authInputsView autoDiscoverServerWithDomain:(NSString*)domain; + +@end + +/** + `MXKAuthInputsView` is a base class to handle authentication inputs. + */ +@interface MXKAuthInputsView : MXKView +{ +@protected + /** + The authentication type (`MXKAuthenticationTypeLogin` by default). + */ + MXKAuthenticationType type; + + /** + The authentication session (nil by default). + */ + MXAuthenticationSession *currentSession; + + /** + Alert used to display inputs error. + */ + UIAlertController *inputsAlert; +} + +/** + The view delegate. + */ +@property (nonatomic, weak) id delegate; + +/** + The current authentication type (`MXKAuthenticationTypeLogin` by default). + */ +@property (nonatomic, readonly) MXKAuthenticationType authType; + +/** + The current authentication session if any. + */ +@property (nonatomic, readonly) MXAuthenticationSession *authSession; + +/** + The current filled user identifier (nil by default). + */ +@property (nonatomic, readonly) NSString *userId; + +/** + The current filled password (nil by default). + */ +@property (nonatomic, readonly) NSString *password; + +/** + The layout constraint defined on the view height. This height takes into account shown/hidden fields. + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *viewHeightConstraint; + +/** + Returns the `UINib` object initialized for the auth inputs view. + + @return The initialized `UINib` object or `nil` if there were errors during + initialization or the nib file could not be located. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKAuthInputsView` object. + + @discussion This is the designated initializer for programmatic instantiation. + + @return An initialized `MXKAuthInputsView` object if successful, `nil` otherwise. + */ ++ (instancetype)authInputsView; + +/** + Finalize the authentication inputs view with a session and a type. + Use this method to restore the view in its initial step. + + @discussion You may override this method to check/update the flows listed in the provided authentication session. + + @param authSession the authentication session returned by the homeserver. + @param authType the authentication type (see 'MXKAuthenticationType'). + @return YES if the provided session and type are supported by the MXKAuthInputsView-inherited class. Note the unsupported flows should be here removed from the stored authentication session (see the resulting session in the property named 'authSession'). + */ +- (BOOL)setAuthSession:(MXAuthenticationSession *)authSession withAuthType:(MXKAuthenticationType)authType; + +/** + Check the validity of the required parameters. + + @return an error message in case of wrong parameters (nil by default). + */ +- (NSString*)validateParameters; + +/** + Prepare the set of the inputs in order to launch an authentication process. + + @param callback the block called when the parameters are prepared. The resulting parameter dictionary is nil + if something fails (for example when a parameter or a required input is missing). The failure reason is provided in error parameter (nil by default). + */ +- (void)prepareParameters:(void (^)(NSDictionary *parameters, NSError *error))callback; + +/** + Update the current authentication session by providing the list of successful stages. + + @param completedStages the list of stages the client has completed successfully. This is an array of MXLoginFlowType. + @param callback the block called when the parameters have been updated for the next stage. The resulting parameter dictionary is nil + if something fails (for example when a parameter or a required input is missing). The failure reason is provided in error parameter (nil by default). + */ +- (void)updateAuthSessionWithCompletedStages:(NSArray *)completedStages didUpdateParameters:(void (^)(NSDictionary *parameters, NSError *error))callback; + +/** + Update the current authentication session by providing a set of registration parameters. + + @discussion This operation failed if the current authentication type is MXKAuthenticationTypeLogin. + + @param registrationParameters a set of parameters to use during the current registration process. + @return YES if the provided set of parameters is supported. + */ +- (BOOL)setExternalRegistrationParameters:(NSDictionary *)registrationParameters; + +/** + Update the current authentication session by providing soft logout credentials. + */ +@property (nonatomic) MXCredentials *softLogoutCredentials; + +/** + Tell whether all required fields are set + */ +- (BOOL)areAllRequiredFieldsSet; + +/** + Force dismiss keyboard + */ +- (void)dismissKeyboard; + +/** + Switch in next authentication flow step by updating the layout. + + @discussion This method is supposed to be called only if the current operation succeeds. + */ +- (void)nextStep; + +/** + Dispose any resources and listener. + */ +- (void)destroy; + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.m b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.m new file mode 100644 index 000000000..3977b6355 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthInputsView.m @@ -0,0 +1,156 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAuthInputsView.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +NSString *const MXKAuthErrorDomain = @"MXKAuthErrorDomain"; + +@implementation MXKAuthInputsView + ++ (UINib *)nib +{ + // By default, no nib is available. + return nil; +} + ++ (instancetype)authInputsView +{ + // Check whether a xib is defined + if ([[self class] nib]) + { + return [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + } + + return [[[self class] alloc] init]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + [self setTranslatesAutoresizingMaskIntoConstraints: NO]; + + type = MXKAuthenticationTypeLogin; +} + +- (instancetype)init +{ + self = [super init]; + if (self) + { + type = MXKAuthenticationTypeLogin; + } + return self; +} + +#pragma mark - + +- (BOOL)setAuthSession:(MXAuthenticationSession *)authSession withAuthType:(MXKAuthenticationType)authType +{ + if (authSession) + { + type = authType; + currentSession = authSession; + + return YES; + } + + return NO; +} + +- (NSString *)validateParameters +{ + // Currently no field to check here + return nil; +} + +- (void)prepareParameters:(void (^)(NSDictionary *parameters, NSError *error))callback +{ + // Do nothing by default + if (callback) + { + callback (nil, [NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n notSupportedYet]}]); + } +} + +- (void)updateAuthSessionWithCompletedStages:(NSArray *)completedStages didUpdateParameters:(void (^)(NSDictionary *parameters, NSError *error))callback +{ + // Do nothing by default + if (callback) + { + callback (nil, [NSError errorWithDomain:MXKAuthErrorDomain code:0 userInfo:@{NSLocalizedDescriptionKey:[MatrixKitL10n notSupportedYet]}]); + } +} + +- (BOOL)setExternalRegistrationParameters:(NSDictionary *)registrationParameters +{ + // Not supported by default + return NO; +} + +- (BOOL)areAllRequiredFieldsSet +{ + // Currently no field to check here + return YES; +} + +- (void)dismissKeyboard +{ + +} + +- (void)nextStep +{ + +} + +- (void)destroy +{ + if (inputsAlert) + { + [inputsAlert dismissViewControllerAnimated:NO completion:nil]; + inputsAlert = nil; + } +} + +#pragma mark - + +- (MXKAuthenticationType)authType +{ + return type; +} + +- (MXAuthenticationSession*)authSession +{ + return currentSession; +} + +- (NSString*)userId +{ + return nil; +} + +- (NSString*)password +{ + return nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationFallbackWebView.h b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationFallbackWebView.h new file mode 100644 index 000000000..404beb42b --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationFallbackWebView.h @@ -0,0 +1,30 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +@interface MXKAuthenticationFallbackWebView : WKWebView + +/** + Open authentication fallback page into the webview. + + @param fallbackPage the fallback page hosted by a homeserver. + @param success the block called when the user has been successfully logged in or registered. + */ +- (void)openFallbackPage:(NSString*)fallbackPage success:(void (^)(MXLoginResponse *loginResponse))success; + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationFallbackWebView.m b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationFallbackWebView.m new file mode 100644 index 000000000..09c50ffc6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationFallbackWebView.m @@ -0,0 +1,181 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAuthenticationFallbackWebView.h" + +// Generic method to make a bridge between JS and the WKWebView +NSString *kMXKJavascriptSendObjectMessage = @"window.sendObjectMessage = function(parameters) { \ +var iframe = document.createElement('iframe'); \ +iframe.setAttribute('src', 'js:' + JSON.stringify(parameters)); \ +\ +document.documentElement.appendChild(iframe); \ +iframe.parentNode.removeChild(iframe); \ +iframe = null; \ +};"; + +// The function the fallback page calls when the registration is complete +NSString *kMXKJavascriptOnRegistered = @"window.matrixRegistration.onRegistered = function(homeserverUrl, userId, accessToken) { \ +sendObjectMessage({ \ +'action': 'onRegistered', \ +'homeServer': homeserverUrl, \ +'userId': userId, \ +'accessToken': accessToken \ +}); \ +};"; + +// The function the fallback page calls when the login is complete +NSString *kMXKJavascriptOnLogin = @"window.matrixLogin.onLogin = function(response) { \ +sendObjectMessage({ \ +'action': 'onLogin', \ +'response': response \ +}); \ +};"; + +@interface MXKAuthenticationFallbackWebView () +{ + // The block called when the login or the registration is successful + void (^onSuccess)(MXLoginResponse *); + + // Activity indicator + UIActivityIndicatorView *activityIndicator; +} +@end + +@implementation MXKAuthenticationFallbackWebView + +- (void)dealloc +{ + if (activityIndicator) + { + [activityIndicator removeFromSuperview]; + activityIndicator = nil; + } +} + +- (void)openFallbackPage:(NSString *)fallbackPage success:(void (^)(MXLoginResponse *))success +{ + self.navigationDelegate = self; + + onSuccess = success; + + // Add activity indicator + activityIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray]; + activityIndicator.center = self.center; + [self addSubview:activityIndicator]; + [activityIndicator startAnimating]; + + // Delete cookies to launch login process from scratch + for(NSHTTPCookie *cookie in [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies]) + { + [[NSHTTPCookieStorage sharedHTTPCookieStorage] deleteCookie:cookie]; + } + + [self loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:fallbackPage]]]; +} + +#pragma mark - WKNavigationDelegate + +- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation +{ + if (activityIndicator) + { + [activityIndicator stopAnimating]; + [activityIndicator removeFromSuperview]; + activityIndicator = nil; + } + + [self evaluateJavaScript:kMXKJavascriptSendObjectMessage completionHandler:^(id _Nullable response, NSError * _Nullable error) { + + }]; + [self evaluateJavaScript:kMXKJavascriptOnRegistered completionHandler:^(id _Nullable response, NSError * _Nullable error) { + + }]; + [self evaluateJavaScript:kMXKJavascriptOnLogin completionHandler:^(id _Nullable response, NSError * _Nullable error) { + + }]; +} + +- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler +{ + MXLogDebug(@"[MXKAuthenticationFallbackWebView] decidePolicyForNavigationAction"); + + NSString *urlString = navigationAction.request.URL.absoluteString; + + if ([urlString hasPrefix:@"js:"]) + { + // do not log urlString, it may have an access token + MXLogDebug(@"[MXKAuthenticationFallbackWebView] URL has js: prefix"); + + // Listen only to scheme of the JS-WKWebView bridge + NSString *jsonString = [[[urlString componentsSeparatedByString:@"js:"] lastObject] stringByReplacingPercentEscapesUsingEncoding:NSASCIIStringEncoding]; + NSData *jsonData = [jsonString dataUsingEncoding:NSUTF8StringEncoding]; + + NSError *error; + NSDictionary *parameters = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingMutableContainers + error:&error]; + + if (error) + { + MXLogDebug(@"[MXKAuthenticationFallbackWebView] Error when parsing json: %@", error); + } + else + { + if ([@"onRegistered" isEqualToString:parameters[@"action"]]) + { + // Translate the JS registration event to MXLoginResponse + // We cannot use [MXLoginResponse modelFromJSON:] because of https://github.com/matrix-org/synapse/issues/4756 + // Because of this issue, we cannot get the device_id allocated by the homeserver + // TODO: Fix it once the homeserver issue is fixed (filed at https://github.com/vector-im/riot-meta/issues/273). + MXLoginResponse *loginResponse = [MXLoginResponse new]; + loginResponse.homeserver = parameters[@"homeServer"]; + loginResponse.userId = parameters[@"userId"]; + loginResponse.accessToken = parameters[@"accessToken"]; + + MXLogDebug(@"[MXKAuthenticationFallbackWebView] Registered on homeserver: %@", loginResponse.homeserver); + + // Sanity check + if (loginResponse.homeserver.length && loginResponse.userId.length && loginResponse.accessToken.length) + { + MXLogDebug(@"[MXKAuthenticationFallbackWebView] Call success block"); + // And inform the client + onSuccess(loginResponse); + } + } + else if ([@"onLogin" isEqualToString:parameters[@"action"]]) + { + // Translate the JS login event to MXLoginResponse + MXLoginResponse *loginResponse; + MXJSONModelSetMXJSONModel(loginResponse, MXLoginResponse, parameters[@"response"]); + + MXLogDebug(@"[MXKAuthenticationFallbackWebView] Logged in on homeserver: %@", loginResponse.homeserver); + + // Sanity check + if (loginResponse.homeserver.length && loginResponse.userId.length && loginResponse.accessToken.length) + { + MXLogDebug(@"[MXKAuthenticationFallbackWebView] Call success block"); + // And inform the client + onSuccess(loginResponse); + } + } + } + + decisionHandler(WKNavigationActionPolicyCancel); + return; + } + decisionHandler(WKNavigationActionPolicyAllow); +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationRecaptchaWebView.h b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationRecaptchaWebView.h new file mode 100644 index 000000000..c1beeb558 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationRecaptchaWebView.h @@ -0,0 +1,33 @@ +/* + Copyright 2016 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import + +@interface MXKAuthenticationRecaptchaWebView : WKWebView + +/** + Open reCAPTCHA widget into a webview. + + @param siteKey the site key. + @param homeServer the homeserver URL. + @param callback the block called when the user has received reCAPTCHA response. + */ +- (void)openRecaptchaWidgetWithSiteKey:(NSString*)siteKey fromHomeServer:(NSString*)homeServer callback:(void (^)(NSString *response))callback; + +@end diff --git a/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationRecaptchaWebView.m b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationRecaptchaWebView.m new file mode 100644 index 000000000..8e3bfa6fa --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Authentication/MXKAuthenticationRecaptchaWebView.m @@ -0,0 +1,126 @@ +/* + Copyright 2016 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKAuthenticationRecaptchaWebView.h" + +NSString *kMXKRecaptchaHTMLString = @" \ + \ + \ + \ + \ + \ +
\ + \ + \ +"; + +@interface MXKAuthenticationRecaptchaWebView () +{ + // The block called when the reCAPTCHA response is received + void (^onResponse)(NSString *); + + // Activity indicator + UIActivityIndicatorView *activityIndicator; +} +@end + +@implementation MXKAuthenticationRecaptchaWebView + +- (void)dealloc +{ + if (activityIndicator) + { + [activityIndicator removeFromSuperview]; + activityIndicator = nil; + } +} + +- (void)openRecaptchaWidgetWithSiteKey:(NSString*)siteKey fromHomeServer:(NSString*)homeServer callback:(void (^)(NSString *response))callback +{ + self.navigationDelegate = self; + + onResponse = callback; + + // Add activity indicator + activityIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray]; + activityIndicator.center = self.center; + [self addSubview:activityIndicator]; + [activityIndicator startAnimating]; + + NSString *htmlString = [NSString stringWithFormat:kMXKRecaptchaHTMLString, siteKey]; + + [self loadHTMLString:htmlString baseURL:[NSURL URLWithString:homeServer]]; +} + +#pragma mark - WKNavigationDelegate + +- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation +{ + if (activityIndicator) + { + [activityIndicator stopAnimating]; + [activityIndicator removeFromSuperview]; + activityIndicator = nil; + } +} + +- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler +{ + NSString *urlString = navigationAction.request.URL.absoluteString; + + if ([urlString hasPrefix:@"js:"]) + { + // Listen only to scheme of the JS-WKWebView bridge + NSString *jsonString = [[[urlString componentsSeparatedByString:@"js:"] lastObject] stringByReplacingPercentEscapesUsingEncoding:NSASCIIStringEncoding]; + NSData *jsonData = [jsonString dataUsingEncoding:NSUTF8StringEncoding]; + + NSError *error; + NSDictionary *parameters = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingMutableContainers + error:&error]; + + if (!error) + { + if ([@"verifyCallback" isEqualToString:parameters[@"action"]]) + { + // Transfer the reCAPTCHA response + onResponse(parameters[@"response"]); + } + } + decisionHandler(WKNavigationActionPolicyCancel); + return; + } + decisionHandler(WKNavigationActionPolicyAllow); +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/BarButtonItem/MXKBarButtonItem.h b/Riot/Modules/MatrixKit/Views/BarButtonItem/MXKBarButtonItem.h new file mode 100644 index 000000000..47549ed8b --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/BarButtonItem/MXKBarButtonItem.h @@ -0,0 +1,38 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#pragma mark - Imports + +@import Foundation; +@import UIKit; + +#pragma mark - Types + +typedef void (^MXKBarButtonItemAction)(void); + +#pragma mark - Interface + +/** + `MXKBarButtonItem` is a subclass of UIBarButtonItem allowing to use convenient action block instead of action selector. + */ +@interface MXKBarButtonItem : UIBarButtonItem + +#pragma mark - Instance Methods + +- (instancetype)initWithImage:(UIImage *)image style:(UIBarButtonItemStyle)style action:(MXKBarButtonItemAction)action; +- (instancetype)initWithTitle:(NSString *)title style:(UIBarButtonItemStyle)style action:(MXKBarButtonItemAction)action; + +@end diff --git a/Riot/Modules/MatrixKit/Views/BarButtonItem/MXKBarButtonItem.m b/Riot/Modules/MatrixKit/Views/BarButtonItem/MXKBarButtonItem.m new file mode 100644 index 000000000..e04fce515 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/BarButtonItem/MXKBarButtonItem.m @@ -0,0 +1,67 @@ +/* + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#pragma mark - Imports + +#import "MXKBarButtonItem.h" + +#pragma mark - Private Interface + +@interface MXKBarButtonItem () + +#pragma mark - Private Properties + +@property (nonatomic, copy) MXKBarButtonItemAction actionBlock; + +@end + +#pragma mark - Implementation + +@implementation MXKBarButtonItem + +#pragma mark - Public methods + +- (instancetype)initWithImage:(UIImage *)image style:(UIBarButtonItemStyle)style action:(MXKBarButtonItemAction)action +{ + self = [self initWithImage:image style:style target:self action:@selector(executeAction:)]; + if (self) + { + self.actionBlock = action; + } + return self; +} + +- (instancetype)initWithTitle:(NSString *)title style:(UIBarButtonItemStyle)style action:(MXKBarButtonItemAction)action +{ + self = [self initWithTitle:title style:style target:self action:@selector(executeAction:)]; + if (self) + { + self.actionBlock = action; + } + return self; +} + +#pragma mark - Private methods + +- (void)executeAction:(id)sender +{ + if (self.actionBlock) + { + self.actionBlock(); + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.h b/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.h new file mode 100644 index 000000000..96b2586ba --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.h @@ -0,0 +1,90 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +#import "MXKCellRendering.h" +#import "MXKImageView.h" + +/** + List the accessory view types for a 'MXKContactTableCell' instance. + */ +typedef enum : NSUInteger { + /** + Don't show accessory view by default. + */ + MXKContactTableCellAccessoryCustom, + /** + The accessory view is automatically handled. It shown only for contact with matrix identifier(s). + */ + MXKContactTableCellAccessoryMatrixIcon + +} MXKContactTableCellAccessoryType; + + +#pragma mark - MXKCellRenderingDelegate cell tap locations + +/** + Action identifier used when the user tapped on contact thumbnail view. + + The `userInfo` dictionary contains an `NSString` object under the `kMXKContactCellContactIdKey` key, representing the contact id of the tapped avatar. + */ +extern NSString *const kMXKContactCellTapOnThumbnailView; + +/** + Notifications `userInfo` keys + */ +extern NSString *const kMXKContactCellContactIdKey; + +/** + 'MXKContactTableCell' is a base class for displaying a contact. + */ +@interface MXKContactTableCell : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet MXKImageView *thumbnailView; + +@property (strong, nonatomic) IBOutlet UILabel *contactDisplayNameLabel; +@property (strong, nonatomic) IBOutlet UILabel *matrixDisplayNameLabel; +@property (strong, nonatomic) IBOutlet UILabel *matrixIDLabel; + +@property (strong, nonatomic) IBOutlet UIView *contactAccessoryView; +@property (unsafe_unretained, nonatomic) IBOutlet NSLayoutConstraint *contactAccessoryViewHeightConstraint; +@property (unsafe_unretained, nonatomic) IBOutlet NSLayoutConstraint *contactAccessoryViewWidthConstraint; +@property (strong, nonatomic) IBOutlet UIImageView *contactAccessoryImageView; +@property (strong, nonatomic) IBOutlet UIButton *contactAccessoryButton; + +/** + The default picture displayed when no picture is available. + */ +@property (nonatomic) UIImage *picturePlaceholder; + +/** + The thumbnail display box type ('MXKTableViewCellDisplayBoxTypeDefault' by default) + */ +@property (nonatomic) MXKTableViewCellDisplayBoxType thumbnailDisplayBoxType; + +/** + The accessory view type ('MXKContactTableCellAccessoryCustom' by default) + */ +@property (nonatomic) MXKContactTableCellAccessoryType contactAccessoryViewType; + +/** + Tell whether the matrix presence of the contact is displayed or not (NO by default) + */ +@property (nonatomic) BOOL hideMatrixPresence; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.m b/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.m new file mode 100644 index 000000000..471c8f5af --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.m @@ -0,0 +1,349 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKContactTableCell.h" + +@import MatrixSDK.MXTools; + +#import "MXKContactManager.h" +#import "MXKAppSettings.h" + +#import "NSBundle+MatrixKit.h" + +#pragma mark - Constant definitions +NSString *const kMXKContactCellTapOnThumbnailView = @"kMXKContactCellTapOnThumbnailView"; + +NSString *const kMXKContactCellContactIdKey = @"kMXKContactCellContactIdKey"; + +@interface MXKContactTableCell() +{ + /** + The current displayed contact. + */ + MXKContact *contact; + + /** + The observer of the presence for matrix user. + */ + id mxPresenceObserver; +} +@end + +@implementation MXKContactTableCell +@synthesize delegate; + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + self.thumbnailDisplayBoxType = MXKTableViewCellDisplayBoxTypeDefault; + + // No accessory view by default + self.contactAccessoryViewType = MXKContactTableCellAccessoryCustom; + + self.hideMatrixPresence = NO; +} + +- (void)customizeTableViewCellRendering +{ + [super customizeTableViewCellRendering]; + + self.thumbnailView.defaultBackgroundColor = [UIColor clearColor]; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + if (self.thumbnailDisplayBoxType == MXKTableViewCellDisplayBoxTypeCircle) + { + // Round image view for thumbnail + self.thumbnailView.layer.cornerRadius = self.thumbnailView.frame.size.width / 2; + self.thumbnailView.clipsToBounds = YES; + } + else if (self.thumbnailDisplayBoxType == MXKTableViewCellDisplayBoxTypeRoundedCorner) + { + self.thumbnailView.layer.cornerRadius = 5; + self.thumbnailView.clipsToBounds = YES; + } + else + { + self.thumbnailView.layer.cornerRadius = 0; + self.thumbnailView.clipsToBounds = NO; + } +} + +- (UIImage*)picturePlaceholder +{ + return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"]; +} + +- (void)setContactAccessoryViewType:(MXKContactTableCellAccessoryType)contactAccessoryViewType +{ + _contactAccessoryViewType = contactAccessoryViewType; + + if (contactAccessoryViewType == MXKContactTableCellAccessoryMatrixIcon) + { + // Load default matrix icon + self.contactAccessoryImageView.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"matrixUser"]; + self.contactAccessoryImageView.hidden = NO; + self.contactAccessoryButton.hidden = YES; + + // Update accessory view visibility + [self refreshMatrixIdentifiers]; + } + else + { + // Hide accessory view by default + self.contactAccessoryView.hidden = YES; + self.contactAccessoryImageView.hidden = YES; + self.contactAccessoryButton.hidden = YES; + } +} + +#pragma mark - MXKCellRendering + +- (void)render:(MXKCellData *)cellData +{ + // Sanity check: accept only object of MXKContact classes or sub-classes + NSParameterAssert([cellData isKindOfClass:[MXKContact class]]); + + contact = (MXKContact*)cellData; + + // remove any pending observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + if (mxPresenceObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:mxPresenceObserver]; + mxPresenceObserver = nil; + } + + self.thumbnailView.layer.borderWidth = 0; + + if (contact) + { + // Be warned when the thumbnail is updated + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onThumbnailUpdate:) name:kMXKContactThumbnailUpdateNotification object:nil]; + + if (! self.hideMatrixPresence) + { + // Observe contact presence change + mxPresenceObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXKContactManagerMatrixUserPresenceChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + // get the matrix identifiers + NSArray* matrixIdentifiers = self->contact.matrixIdentifiers; + if (matrixIdentifiers.count > 0) + { + // Consider only the first id + NSString *matrixUserID = matrixIdentifiers.firstObject; + if ([matrixUserID isEqualToString:notif.object]) + { + [self refreshPresenceUserRing:[MXTools presence:[notif.userInfo objectForKey:kMXKContactManagerMatrixPresenceKey]]]; + } + } + }]; + } + + if (!contact.isMatrixContact) + { + // Be warned when the linked matrix IDs are updated + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMatrixIdUpdate:) name:kMXKContactManagerDidUpdateLocalContactMatrixIDsNotification object:nil]; + } + + NSArray* matrixIDs = contact.matrixIdentifiers; + + if (matrixIDs.count) + { + self.contactDisplayNameLabel.hidden = YES; + + self.matrixDisplayNameLabel.hidden = NO; + self.matrixDisplayNameLabel.text = contact.displayName; + self.matrixIDLabel.hidden = NO; + self.matrixIDLabel.text = [matrixIDs firstObject]; + } + else + { + self.contactDisplayNameLabel.hidden = NO; + self.contactDisplayNameLabel.text = contact.displayName; + + self.matrixDisplayNameLabel.hidden = YES; + self.matrixIDLabel.hidden = YES; + } + + UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onContactThumbnailTap:)]; + [tap setNumberOfTouchesRequired:1]; + [tap setNumberOfTapsRequired:1]; + [tap setDelegate:self]; + [self.thumbnailView addGestureRecognizer:tap]; + } + + [self refreshContactThumbnail]; + [self manageMatrixIcon]; +} + ++ (CGFloat)heightForCellData:(MXKCellData*)cellData withMaximumWidth:(CGFloat)maxWidth +{ + // The height is fixed + return 50; +} + +- (void)didEndDisplay +{ + // remove any pending observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + if (mxPresenceObserver) { + [[NSNotificationCenter defaultCenter] removeObserver:mxPresenceObserver]; + mxPresenceObserver = nil; + } + + // Remove all gesture recognizer + while (self.thumbnailView.gestureRecognizers.count) + { + [self.thumbnailView removeGestureRecognizer:self.thumbnailView.gestureRecognizers[0]]; + } + + self.delegate = nil; + contact = nil; +} + +#pragma mark - + +- (void)refreshMatrixIdentifiers +{ + // Look for a potential matrix user linked with this contact + NSArray* matrixIdentifiers = contact.matrixIdentifiers; + + if ((matrixIdentifiers.count > 0) && (! self.hideMatrixPresence)) + { + // Consider only the first matrix identifier + NSString* matrixUserID = matrixIdentifiers.firstObject; + + // Consider here all sessions reported into contact manager + NSArray* mxSessions = [MXKContactManager sharedManager].mxSessions; + for (MXSession *mxSession in mxSessions) + { + MXUser *mxUser = [mxSession userWithUserId:matrixUserID]; + if (mxUser) + { + [self refreshPresenceUserRing:mxUser.presence]; + break; + } + } + } + + // Update accessory view visibility + if (self.contactAccessoryViewType == MXKContactTableCellAccessoryMatrixIcon) + { + self.contactAccessoryView.hidden = (!matrixIdentifiers.count); + } +} + +- (void)refreshContactThumbnail +{ + self.thumbnailView.image = [contact thumbnailWithPreferedSize:self.thumbnailView.frame.size]; + + if (!self.thumbnailView.image) + { + self.thumbnailView.image = self.picturePlaceholder; + } +} + +- (void)refreshPresenceUserRing:(MXPresence)presenceStatus +{ + UIColor* ringColor; + + switch (presenceStatus) + { + case MXPresenceOnline: + ringColor = [[MXKAppSettings standardAppSettings] presenceColorForOnlineUser]; + break; + case MXPresenceUnavailable: + ringColor = [[MXKAppSettings standardAppSettings] presenceColorForUnavailableUser]; + break; + case MXPresenceOffline: + ringColor = [[MXKAppSettings standardAppSettings] presenceColorForOfflineUser]; + break; + default: + ringColor = nil; + } + + // if the thumbnail is defined + if (ringColor && (! self.hideMatrixPresence)) + { + self.thumbnailView.layer.borderWidth = 2; + self.thumbnailView.layer.borderColor = ringColor.CGColor; + } + else + { + // remove the border + // else it draws black border + self.thumbnailView.layer.borderWidth = 0; + } +} + +- (void)manageMatrixIcon +{ + // try to update the thumbnail with the matrix thumbnail + if (contact.matrixIdentifiers) + { + [self refreshContactThumbnail]; + } + + [self refreshMatrixIdentifiers]; +} + +- (void)onMatrixIdUpdate:(NSNotification *)notif +{ + // sanity check + if ([notif.object isKindOfClass:[NSString class]]) + { + NSString* contactID = notif.object; + + if ([contactID isEqualToString:contact.contactID]) + { + [self manageMatrixIcon]; + } + } +} + +- (void)onThumbnailUpdate:(NSNotification *)notif +{ + // sanity check + if ([notif.object isKindOfClass:[NSString class]]) + { + NSString* contactID = notif.object; + + if ([contactID isEqualToString:contact.contactID]) + { + [self refreshContactThumbnail]; + + [self refreshMatrixIdentifiers]; + } + } +} + +#pragma mark - Action + +- (IBAction)onContactThumbnailTap:(id)sender +{ + if (self.delegate) + { + [self.delegate cell:self didRecognizeAction:kMXKContactCellTapOnThumbnailView userInfo:@{kMXKContactCellContactIdKey: contact.contactID}]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.xib b/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.xib new file mode 100644 index 000000000..5173d1b2f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Contact/MXKContactTableCell.xib @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.h b/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.h new file mode 100644 index 000000000..9e69cc1a7 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.h @@ -0,0 +1,85 @@ +/* + Copyright 2016 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +/** + MXKDeviceView class may be used to display the information of a user's device. + The displayed device may be renamed or removed. + */ + +@class MXKDeviceView; +@protocol MXKDeviceViewDelegate + +/** + Tells the delegate that an alert must be presented. + + @param deviceView the device view. + @param alert the alert to present. + */ +- (void)deviceView:(MXKDeviceView*)deviceView presentAlertController:(UIAlertController*)alert; + +@optional + +/** + Tells the delegate to dismiss the device view. + + @param deviceView the device view. + @param isUpdated tell whether the device was updated (renamed, removed...). + */ +- (void)dismissDeviceView:(MXKDeviceView*)deviceView didUpdate:(BOOL)isUpdated; + +@end + +@interface MXKDeviceView : MXKView + +@property (weak, nonatomic) IBOutlet UIView *bgView; +@property (weak, nonatomic) IBOutlet UIView *containerView; +@property (weak, nonatomic) IBOutlet UITextView *textView; +@property (weak, nonatomic) IBOutlet UIButton *cancelButton; +@property (weak, nonatomic) IBOutlet UIButton *renameButton; +@property (weak, nonatomic) IBOutlet UIButton *deleteButton; +@property (weak, nonatomic) IBOutlet UIActivityIndicatorView *activityIndicator; + +/** + Initialize a device view to display the information of a user's device. + + @param device a user's device. + @param session the matrix session. + @return the newly created instance. + */ +- (instancetype)initWithDevice:(MXDevice*)device andMatrixSession:(MXSession*)session; + +/** + The delegate. + */ +@property (nonatomic, weak) id delegate; + +/** + The default text color in the text view. [UIColor blackColor] by default. + */ +@property (nonatomic) UIColor *defaultTextColor; + +/** + Action registered on 'UIControlEventTouchUpInside' event for each UIButton instance. + */ +- (IBAction)onButtonPressed:(id)sender; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.m b/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.m new file mode 100644 index 000000000..3551e08e2 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.m @@ -0,0 +1,481 @@ +/* + Copyright 2016 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKDeviceView.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKConstants.h" + +#import "MXKSwiftHeader.h" + +static NSAttributedString *verticalWhitespace = nil; + +@interface MXKDeviceView () +{ + /** + The displayed device + */ + MXDevice *mxDevice; + + /** + The matrix session. + */ + MXSession *mxSession; + + /** + The current alert + */ + UIAlertController *currentAlert; + + /** + Current request in progress. + */ + MXHTTPOperation *mxCurrentOperation; +} +@end + +@implementation MXKDeviceView + ++ (UINib *)nib +{ + // Check whether a nib file is available + NSBundle *mainBundle = [NSBundle mxk_bundleForClass:self.class]; + + NSString *path = [mainBundle pathForResource:NSStringFromClass([self class]) ofType:@"nib"]; + if (path) + { + return [UINib nibWithNibName:NSStringFromClass([self class]) bundle:mainBundle]; + } + return [UINib nibWithNibName:NSStringFromClass([MXKDeviceView class]) bundle:[NSBundle mxk_bundleForClass:[MXKDeviceView class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + // Add tap recognizer to discard the view on bg view tap + UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onBgViewTap:)]; + [tap setNumberOfTouchesRequired:1]; + [tap setNumberOfTapsRequired:1]; + [tap setDelegate:self]; + [self.bgView addGestureRecognizer:tap]; + + // Localize string + [_cancelButton setTitle:[MatrixKitL10n ok] forState:UIControlStateNormal]; + [_cancelButton setTitle:[MatrixKitL10n ok] forState:UIControlStateHighlighted]; + + [_renameButton setTitle:[MatrixKitL10n rename] forState:UIControlStateNormal]; + [_renameButton setTitle:[MatrixKitL10n rename] forState:UIControlStateHighlighted]; + + [_deleteButton setTitle:[MatrixKitL10n delete] forState:UIControlStateNormal]; + [_deleteButton setTitle:[MatrixKitL10n delete] forState:UIControlStateHighlighted]; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + // Scroll to the top the text view content + self.textView.contentOffset = CGPointZero; +} + +#pragma mark - Override MXKView + +-(void)customizeViewRendering +{ + [super customizeViewRendering]; + + _defaultTextColor = [UIColor blackColor]; + + // Add shadow on added view + _containerView.layer.cornerRadius = 5; + _containerView.layer.shadowOffset = CGSizeMake(0, 1); + _containerView.layer.shadowOpacity = 0.5f; +} + +#pragma mark - + +- (void)removeFromSuperviewDidUpdate:(BOOL)isUpdated +{ + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + currentAlert = nil; + } + + if (mxCurrentOperation) + { + [mxCurrentOperation cancel]; + mxCurrentOperation = nil; + } + + if (self.delegate && [self.delegate respondsToSelector:@selector(dismissDeviceView:didUpdate:)]) + { + [self.delegate dismissDeviceView:self didUpdate:isUpdated]; + } + else + { + [self removeFromSuperview]; + } +} + +- (instancetype)initWithDevice:(MXDevice*)device andMatrixSession:(MXSession*)session +{ + self = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + if (self) + { + mxDevice = device; + mxSession = session; + + [self setTranslatesAutoresizingMaskIntoConstraints: NO]; + + if (mxDevice) + { + // Device information + NSMutableAttributedString *deviceInformationString = [[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n deviceDetailsTitle] + attributes:@{NSForegroundColorAttributeName : _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:15]}]; + [deviceInformationString appendAttributedString:[MXKDeviceView verticalWhitespace]]; + + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n deviceDetailsName] + attributes:@{NSForegroundColorAttributeName : _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:device.displayName.length ? device.displayName : @"" + attributes:@{NSForegroundColorAttributeName : _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[MXKDeviceView verticalWhitespace]]; + + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n deviceDetailsIdentifier] + attributes:@{NSForegroundColorAttributeName : _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:device.deviceId + attributes:@{NSForegroundColorAttributeName : _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[MXKDeviceView verticalWhitespace]]; + + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n deviceDetailsLastSeen] + attributes:@{NSForegroundColorAttributeName : _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + + NSDate *lastSeenDate = [NSDate dateWithTimeIntervalSince1970:device.lastSeenTs/1000]; + + NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; + [dateFormatter setLocale:[[NSLocale alloc] initWithLocaleIdentifier:[[[NSBundle mainBundle] preferredLocalizations] objectAtIndex:0]]]; + [dateFormatter setDateStyle:NSDateFormatterShortStyle]; + [dateFormatter setTimeStyle:NSDateFormatterShortStyle]; + [dateFormatter setFormatterBehavior:NSDateFormatterBehavior10_4]; + + NSString *lastSeen = [MatrixKitL10n deviceDetailsLastSeenFormat:device.lastSeenIp :[dateFormatter stringFromDate:lastSeenDate]]; + + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:lastSeen + attributes:@{NSForegroundColorAttributeName : _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[MXKDeviceView verticalWhitespace]]; + + self.textView.attributedText = deviceInformationString; + } + else + { + _textView.text = nil; + } + + // Hide potential activity indicator + [_activityIndicator stopAnimating]; + } + + return self; +} + +- (void)dealloc +{ + mxDevice = nil; + mxSession = nil; +} + ++ (NSAttributedString *)verticalWhitespace +{ + if (verticalWhitespace == nil) + { + verticalWhitespace = [[NSAttributedString alloc] initWithString:@"\n\n" attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:4]}]; + } + return verticalWhitespace; +} + +#pragma mark - Actions + +- (IBAction)onBgViewTap:(UITapGestureRecognizer*)sender +{ + [self removeFromSuperviewDidUpdate:NO]; +} + +- (IBAction)onButtonPressed:(id)sender +{ + if (sender == _cancelButton) + { + [self removeFromSuperviewDidUpdate:NO]; + } + else if (sender == _renameButton) + { + [self renameDevice]; + } + else if (sender == _deleteButton) + { + [self deleteDevice]; + } +} + +#pragma mark - + +- (void)renameDevice +{ + if (!self.delegate) + { + // Ignore + MXLogDebug(@"[MXKDeviceView] Rename device failed, delegate is missing"); + return; + } + + // Prompt the user to enter a device name. + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + __weak typeof(self) weakSelf = self; + + currentAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n deviceDetailsRenamePromptTitle] + message:[MatrixKitL10n deviceDetailsRenamePromptMessage] preferredStyle:UIAlertControllerStyleAlert]; + + [currentAlert addTextFieldWithConfigurationHandler:^(UITextField *textField) { + + textField.secureTextEntry = NO; + textField.placeholder = nil; + textField.keyboardType = UIKeyboardTypeDefault; + if (weakSelf) + { + typeof(self) self = weakSelf; + textField.text = self->mxDevice.displayName; + } + }]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + } + + }]]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + NSString *text = [self->currentAlert textFields].firstObject.text; + self->currentAlert = nil; + + [self.activityIndicator startAnimating]; + + self->mxCurrentOperation = [self->mxSession.matrixRestClient setDeviceName:text forDeviceId:self->mxDevice.deviceId success:^{ + + if (weakSelf) + { + typeof(self) self = weakSelf; + + self->mxCurrentOperation = nil; + [self.activityIndicator stopAnimating]; + + [self removeFromSuperviewDidUpdate:YES]; + } + + } failure:^(NSError *error) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Notify MatrixKit user + NSString *myUserId = self->mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + self->mxCurrentOperation = nil; + + MXLogDebug(@"[MXKDeviceView] Rename device (%@) failed", self->mxDevice.deviceId); + + [self.activityIndicator stopAnimating]; + + [self removeFromSuperviewDidUpdate:NO]; + } + + }]; + } + + }]]; + + [self.delegate deviceView:self presentAlertController:currentAlert]; +} + +- (void)deleteDevice +{ + if (!self.delegate) + { + // Ignore + MXLogDebug(@"[MXKDeviceView] Delete device failed, delegate is missing"); + return; + } + + // Get an authentication session to prepare device deletion + [self.activityIndicator startAnimating]; + + mxCurrentOperation = [mxSession.matrixRestClient getSessionToDeleteDeviceByDeviceId:mxDevice.deviceId success:^(MXAuthenticationSession *authSession) { + + self->mxCurrentOperation = nil; + + // Check whether the password based type is supported + BOOL isPasswordBasedTypeSupported = NO; + for (MXLoginFlow *loginFlow in authSession.flows) + { + if ([loginFlow.type isEqualToString:kMXLoginFlowTypePassword] || [loginFlow.stages indexOfObject:kMXLoginFlowTypePassword] != NSNotFound) + { + isPasswordBasedTypeSupported = YES; + break; + } + } + + if (isPasswordBasedTypeSupported && authSession.session) + { + // Prompt for a password + [self->currentAlert dismissViewControllerAnimated:NO completion:nil]; + + __weak typeof(self) weakSelf = self; + + // Prompt the user before deleting the device. + self->currentAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n deviceDetailsDeletePromptTitle] message:[MatrixKitL10n deviceDetailsDeletePromptMessage] preferredStyle:UIAlertControllerStyleAlert]; + + + [self->currentAlert addTextFieldWithConfigurationHandler:^(UITextField *textField) { + + textField.secureTextEntry = YES; + textField.placeholder = nil; + textField.keyboardType = UIKeyboardTypeDefault; + }]; + + [self->currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->currentAlert = nil; + [self.activityIndicator stopAnimating]; + } + + }]]; + + [self->currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n submit] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + UITextField *textField = [self->currentAlert textFields].firstObject; + self->currentAlert = nil; + + NSString *userId = self->mxSession.myUser.userId; + NSDictionary *authParams; + + // Sanity check + if (userId) + { + authParams = @{@"session":authSession.session, + @"user": userId, + @"password": textField.text, + @"type": kMXLoginFlowTypePassword}; + + } + + self->mxCurrentOperation = [self->mxSession.matrixRestClient deleteDeviceByDeviceId:self->mxDevice.deviceId authParams:authParams success:^{ + + if (weakSelf) + { + typeof(self) self = weakSelf; + + self->mxCurrentOperation = nil; + [self.activityIndicator stopAnimating]; + + [self removeFromSuperviewDidUpdate:YES]; + } + + } failure:^(NSError *error) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Notify MatrixKit user + NSString *myUserId = self->mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + self->mxCurrentOperation = nil; + + MXLogDebug(@"[MXKDeviceView] Delete device (%@) failed", self->mxDevice.deviceId); + + [self.activityIndicator stopAnimating]; + + [self removeFromSuperviewDidUpdate:NO]; + } + + }]; + } + + }]]; + + [self.delegate deviceView:self presentAlertController:self->currentAlert]; + } + else + { + MXLogDebug(@"[MXKDeviceView] Delete device (%@) failed, auth session flow type is not supported", self->mxDevice.deviceId); + [self.activityIndicator stopAnimating]; + } + + } failure:^(NSError *error) { + + self->mxCurrentOperation = nil; + + MXLogDebug(@"[MXKDeviceView] Delete device (%@) failed, unable to get auth session", self->mxDevice.deviceId); + [self.activityIndicator stopAnimating]; + + // Notify MatrixKit user + NSString *myUserId = self->mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + }]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.xib b/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.xib new file mode 100644 index 000000000..17e9bfa40 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/DeviceView/MXKDeviceView.xib @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.h b/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.h new file mode 100644 index 000000000..80afc81e1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.h @@ -0,0 +1,103 @@ +/* + Copyright 2016 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +@protocol MXKEncryptionInfoViewDelegate; + +/** + MXKEncryptionInfoView class may be used to display the available information on a encrypted event. + The event sender device may be verified, unverified, blocked or unblocked from this view. + */ +@interface MXKEncryptionInfoView : MXKView + +/** + The displayed event + */ +@property (nonatomic, readonly) MXEvent *mxEvent; + +/** + The matrix session. + */ +@property (nonatomic, readonly) MXSession *mxSession; + +/** + The event device info + */ +@property (nonatomic, readonly) MXDeviceInfo *mxDeviceInfo; + +@property (weak, nonatomic) IBOutlet UITextView *textView; +@property (weak, nonatomic) IBOutlet UIButton *cancelButton; +@property (weak, nonatomic) IBOutlet UIButton *verifyButton; +@property (weak, nonatomic) IBOutlet UIButton *blockButton; +@property (weak, nonatomic) IBOutlet UIButton *confirmVerifyButton; + +@property (nonatomic, weak) id delegate; + +/** + Initialise an `MXKEncryptionInfoView` instance based on an encrypted event + + @param event the encrypted event. + @param session the related matrix session. + @return the newly created instance. + */ +- (instancetype)initWithEvent:(MXEvent*)event andMatrixSession:(MXSession*)session; + +/** + Initialise an `MXKEncryptionInfoView` instance based only on a device information. + + @param deviceInfo the device information. + @param session the related matrix session. + @return the newly created instance. + */ +- (instancetype)initWithDeviceInfo:(MXDeviceInfo*)deviceInfo andMatrixSession:(MXSession*)session; + +/** + The default text color in the text view. [UIColor blackColor] by default. + */ +@property (nonatomic) UIColor *defaultTextColor; + +/** + Action registered on 'UIControlEventTouchUpInside' event for each UIButton instance. +*/ +- (IBAction)onButtonPressed:(id)sender; + +@end + + +@protocol MXKEncryptionInfoViewDelegate + +/** + Called when the user changes the verified state of a device. + + @param encryptionInfoView the view. + @param deviceInfo the device that has changed. + */ +- (void)encryptionInfoView:(MXKEncryptionInfoView*)encryptionInfoView didDeviceInfoVerifiedChange:(MXDeviceInfo*)deviceInfo; + +@optional + +/** + Called when the user close the view without changing value. + + @param encryptionInfoView the view. + */ +- (void)encryptionInfoViewDidClose:(MXKEncryptionInfoView*)encryptionInfoView; + +@end diff --git a/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.m b/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.m new file mode 100644 index 000000000..a6537cbd5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.m @@ -0,0 +1,492 @@ +/* + Copyright 2016 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEncryptionInfoView.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKConstants.h" + +#import "MXKSwiftHeader.h" + +static NSAttributedString *verticalWhitespace = nil; + +@interface MXKEncryptionInfoView () +{ + /** + Current request in progress. + */ + MXHTTPOperation *mxCurrentOperation; + +} +@end + +@implementation MXKEncryptionInfoView + ++ (UINib *)nib +{ + // Check whether a nib file is available + NSBundle *mainBundle = [NSBundle mxk_bundleForClass:self.class]; + + NSString *path = [mainBundle pathForResource:NSStringFromClass([self class]) ofType:@"nib"]; + if (path) + { + return [UINib nibWithNibName:NSStringFromClass([self class]) bundle:mainBundle]; + } + return [UINib nibWithNibName:NSStringFromClass([MXKEncryptionInfoView class]) bundle:[NSBundle mxk_bundleForClass:[MXKEncryptionInfoView class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + // Localize string + [_cancelButton setTitle:[MatrixKitL10n ok] forState:UIControlStateNormal]; + [_cancelButton setTitle:[MatrixKitL10n ok] forState:UIControlStateHighlighted]; + + [_confirmVerifyButton setTitle:[MatrixKitL10n roomEventEncryptionVerifyOk] forState:UIControlStateNormal]; + [_confirmVerifyButton setTitle:[MatrixKitL10n roomEventEncryptionVerifyOk] forState:UIControlStateHighlighted]; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + // Scroll to the top the text view content + self.textView.contentOffset = CGPointZero; +} + +- (void)removeFromSuperview +{ + if (mxCurrentOperation) + { + [mxCurrentOperation cancel]; + mxCurrentOperation = nil; + } + + [super removeFromSuperview]; +} + +#pragma mark - Override MXKView + +-(void)customizeViewRendering +{ + [super customizeViewRendering]; + + _defaultTextColor = [UIColor blackColor]; +} + +#pragma mark - + +- (instancetype)initWithEvent:(MXEvent*)event andMatrixSession:(MXSession*)session +{ + self = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + if (self) + { + _mxEvent = event; + _mxSession = session; + _mxDeviceInfo = nil; + + [self setTranslatesAutoresizingMaskIntoConstraints: NO]; + + [self updateTextViewText]; + } + + return self; +} + +- (instancetype)initWithDeviceInfo:(MXDeviceInfo*)deviceInfo andMatrixSession:(MXSession*)session +{ + self = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + if (self) + { + _mxEvent = nil; + _mxDeviceInfo = deviceInfo; + _mxSession = session; + + [self setTranslatesAutoresizingMaskIntoConstraints: NO]; + + [self updateTextViewText]; + } + + return self; +} + +- (void)dealloc +{ + _mxEvent = nil; + _mxSession = nil; + _mxDeviceInfo = nil; +} + +#pragma mark - + +- (void)updateTextViewText +{ + // Prepare the text view content + NSMutableAttributedString *textViewAttributedString = [[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoTitle] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:17]}]; + + if (_mxEvent) + { + NSString *senderId = _mxEvent.sender; + + if (_mxSession && _mxSession.crypto && !_mxDeviceInfo) + { + _mxDeviceInfo = [_mxSession.crypto eventDeviceInfo:_mxEvent]; + + if (!_mxDeviceInfo) + { + // Trigger a server request to get the device information for the event sender + mxCurrentOperation = [_mxSession.crypto downloadKeys:@[senderId] forceDownload:NO success:^(MXUsersDevicesMap *usersDevicesInfoMap, NSDictionary *crossSigningKeysMap) { + + self->mxCurrentOperation = nil; + + // Sanity check: check whether some device information has been retrieved. + self->_mxDeviceInfo = [self.mxSession.crypto eventDeviceInfo:self.mxEvent]; + if (self.mxDeviceInfo) + { + [self updateTextViewText]; + } + + } failure:^(NSError *error) { + + self->mxCurrentOperation = nil; + + MXLogDebug(@"[MXKEncryptionInfoView] Crypto failed to download device info for user: %@", self.mxEvent.sender); + + // Notify MatrixKit user + NSString *myUserId = self.mxSession.myUser.userId; + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + } + + // Event information + NSMutableAttributedString *eventInformationString = [[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoEvent] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:15]}]; + [eventInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + NSString *senderKey = _mxEvent.senderKey; + NSString *claimedKey = _mxEvent.keysClaimed[@"ed25519"]; + NSString *algorithm = _mxEvent.wireContent[@"algorithm"]; + NSString *sessionId = _mxEvent.wireContent[@"session_id"]; + + NSString *decryptionError; + if (_mxEvent.decryptionError) + { + decryptionError = [NSString stringWithFormat:@"** %@ **", _mxEvent.decryptionError.localizedDescription]; + } + + if (!senderKey.length) + { + senderKey = [MatrixKitL10n roomEventEncryptionInfoEventNone]; + } + if (!claimedKey.length) + { + claimedKey = [MatrixKitL10n roomEventEncryptionInfoEventNone]; + } + if (!algorithm.length) + { + algorithm = [MatrixKitL10n roomEventEncryptionInfoEventUnencrypted]; + } + if (!sessionId.length) + { + sessionId = [MatrixKitL10n roomEventEncryptionInfoEventNone]; + } + + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoEventUserId] attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:senderId + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoEventIdentityKey] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:senderKey + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoEventFingerprintKey] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:claimedKey + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoEventAlgorithm] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:algorithm + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + if (decryptionError.length) + { + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoEventDecryptionError] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:decryptionError + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + } + + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoEventSessionId] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:sessionId + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [eventInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + [textViewAttributedString appendAttributedString:eventInformationString]; + } + + // Device information + NSMutableAttributedString *deviceInformationString = [[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDevice] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:15]}]; + [deviceInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + if (_mxDeviceInfo) + { + NSString *name = _mxDeviceInfo.displayName; + NSString *deviceId = _mxDeviceInfo.deviceId; + NSMutableAttributedString *verification; + NSString *fingerprint = _mxDeviceInfo.fingerprint; + + // Display here the Verify and Block buttons except if the device is the current one. + _verifyButton.hidden = _blockButton.hidden = [_mxDeviceInfo.deviceId isEqualToString:_mxSession.matrixRestClient.credentials.deviceId]; + + switch (_mxDeviceInfo.trustLevel.localVerificationStatus) + { + case MXDeviceUnknown: + case MXDeviceUnverified: + { + verification = [[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDeviceNotVerified] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]; + + [_verifyButton setTitle:[MatrixKitL10n roomEventEncryptionInfoVerify] forState:UIControlStateNormal]; + [_verifyButton setTitle:[MatrixKitL10n roomEventEncryptionInfoVerify] forState:UIControlStateHighlighted]; + [_blockButton setTitle:[MatrixKitL10n roomEventEncryptionInfoBlock] forState:UIControlStateNormal]; + [_blockButton setTitle:[MatrixKitL10n roomEventEncryptionInfoBlock] forState:UIControlStateHighlighted]; + break; + } + case MXDeviceVerified: + { + verification = [[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDeviceVerified] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]; + + [_verifyButton setTitle:[MatrixKitL10n roomEventEncryptionInfoUnverify] forState:UIControlStateNormal]; + [_verifyButton setTitle:[MatrixKitL10n roomEventEncryptionInfoUnverify] forState:UIControlStateHighlighted]; + [_blockButton setTitle:[MatrixKitL10n roomEventEncryptionInfoBlock] forState:UIControlStateNormal]; + [_blockButton setTitle:[MatrixKitL10n roomEventEncryptionInfoBlock] forState:UIControlStateHighlighted]; + + break; + } + case MXDeviceBlocked: + { + verification = [[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDeviceBlocked] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]; + + [_verifyButton setTitle:[MatrixKitL10n roomEventEncryptionInfoVerify] forState:UIControlStateNormal]; + [_verifyButton setTitle:[MatrixKitL10n roomEventEncryptionInfoVerify] forState:UIControlStateHighlighted]; + [_blockButton setTitle:[MatrixKitL10n roomEventEncryptionInfoUnblock] forState:UIControlStateNormal]; + [_blockButton setTitle:[MatrixKitL10n roomEventEncryptionInfoUnblock] forState:UIControlStateHighlighted]; + + break; + } + default: + break; + } + + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDeviceName] + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:(name.length ? name : @"") + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDeviceId] attributes:@{NSForegroundColorAttributeName: _defaultTextColor, NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:deviceId + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDeviceVerification] attributes:@{NSForegroundColorAttributeName: _defaultTextColor, NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:verification]; + [deviceInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDeviceFingerprint] attributes:@{NSForegroundColorAttributeName: _defaultTextColor, NSFontAttributeName: [UIFont boldSystemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:fingerprint + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + [deviceInformationString appendAttributedString:[MXKEncryptionInfoView verticalWhitespace]]; + } + else + { + // Unknown device + [deviceInformationString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionInfoDeviceUnknown] attributes:@{NSForegroundColorAttributeName: _defaultTextColor, NSFontAttributeName: [UIFont italicSystemFontOfSize:14]}]]; + } + + [textViewAttributedString appendAttributedString:deviceInformationString]; + + self.textView.attributedText = textViewAttributedString; +} + ++ (NSAttributedString *)verticalWhitespace +{ + if (verticalWhitespace == nil) + { + verticalWhitespace = [[NSAttributedString alloc] initWithString:@"\n\n" attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:4]}]; + } + return verticalWhitespace; +} + +#pragma mark - Actions + +- (IBAction)onButtonPressed:(id)sender +{ + if (sender == _cancelButton) + { + [self removeFromSuperview]; + + if ([_delegate respondsToSelector:@selector(encryptionInfoViewDidClose:)]) + { + [_delegate encryptionInfoViewDidClose:self]; + } + } + // Note: Verify and Block buttons are hidden when the deviceInfo is not available + else if (sender == _confirmVerifyButton && _mxDeviceInfo) + { + [_mxSession.crypto setDeviceVerification:MXDeviceVerified forDevice:_mxDeviceInfo.deviceId ofUser:_mxDeviceInfo.userId success:^{ + + // Refresh data + _mxDeviceInfo = [self.mxSession.crypto eventDeviceInfo:self.mxEvent]; + if (self->_delegate) + { + [self->_delegate encryptionInfoView:self didDeviceInfoVerifiedChange:self.mxDeviceInfo]; + } + [self removeFromSuperview]; + + } failure:^(NSError *error) { + [self removeFromSuperview]; + }]; + } + else if (_mxDeviceInfo) + { + MXDeviceVerification verificationStatus; + + if (sender == _verifyButton) + { + verificationStatus = ((_mxDeviceInfo.trustLevel.localVerificationStatus == MXDeviceVerified) ? MXDeviceUnverified : MXDeviceVerified); + } + else if (sender == _blockButton) + { + verificationStatus = ((_mxDeviceInfo.trustLevel.localVerificationStatus == MXDeviceBlocked) ? MXDeviceUnverified : MXDeviceBlocked); + } + else + { + // Unexpected case + MXLogDebug(@"[MXKEncryptionInfoView] Invalid button pressed."); + return; + } + + if (verificationStatus == MXDeviceVerified) + { + // Prompt user + NSMutableAttributedString *textViewAttributedString = [[NSMutableAttributedString alloc] + initWithString:[MatrixKitL10n roomEventEncryptionVerifyTitle] attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont boldSystemFontOfSize:17]}]; + + NSString *message = [MatrixKitL10n roomEventEncryptionVerifyMessage:_mxDeviceInfo.displayName :_mxDeviceInfo.deviceId :_mxDeviceInfo.fingerprint]; + + [textViewAttributedString appendAttributedString:[[NSMutableAttributedString alloc] + initWithString:message + attributes:@{NSForegroundColorAttributeName: _defaultTextColor, + NSFontAttributeName: [UIFont systemFontOfSize:14]}]]; + + self.textView.attributedText = textViewAttributedString; + + [_cancelButton setTitle:[MatrixKitL10n cancel] forState:UIControlStateNormal]; + [_cancelButton setTitle:[MatrixKitL10n cancel] forState:UIControlStateHighlighted]; + _verifyButton.hidden = _blockButton.hidden = YES; + _confirmVerifyButton.hidden = NO; + } + else + { + [_mxSession.crypto setDeviceVerification:verificationStatus forDevice:_mxDeviceInfo.deviceId ofUser:_mxDeviceInfo.userId success:^{ + + // Refresh data + _mxDeviceInfo = [self.mxSession.crypto eventDeviceInfo:self.mxEvent]; + + if (self->_delegate) + { + [self->_delegate encryptionInfoView:self didDeviceInfoVerifiedChange:self.mxDeviceInfo]; + } + + [self removeFromSuperview]; + + } failure:^(NSError *error) { + [self removeFromSuperview]; + }]; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.xib b/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.xib new file mode 100644 index 000000000..a51c3ee77 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/EncryptionInfoView/MXKEncryptionInfoView.xib @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysExportView.h b/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysExportView.h new file mode 100644 index 000000000..10b6529a6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysExportView.h @@ -0,0 +1,69 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +@class MXSession, MXKViewController, MXKRoomDataSource; + +/** + `MXKEncryptionKeysExportView` is a dialog to export encryption keys from + the user's crypto store. + */ +@interface MXKEncryptionKeysExportView : NSObject + +/** + The UIAlertController instance which handles the dialog. + */ +@property (nonatomic, readonly) UIAlertController *alertController; + +/** + The minimum length of the passphrase. 1 by default. + */ +@property (nonatomic) NSUInteger passphraseMinLength; + +/** + Create the `MXKEncryptionKeysExportView` instance. + + @param mxSession the mxSession to export keys from. + @return the newly created MXKEncryptionKeysExportView instance. + */ +- (instancetype)initWithMatrixSession:(MXSession*)mxSession; + +/** + Show the dialog in a given view controller. + + @param mxkViewController the mxkViewController where to show the dialog. + @param keyFile the path where to export keys to. + @param onComplete a block called when the operation is done. + */ +- (void)showInViewController:(MXKViewController*)mxkViewController toExportKeysToFile:(NSURL*)keyFile onComplete:(void(^)(BOOL success))onComplete; + + +/** + Show the dialog in a given view controller. + + @param viewController the UIViewController where to show the dialog. + @param keyFile the path where to export keys to. + @param onLoading a block called when to show a spinner. + @param onComplete a block called when the operation is done. + */ +- (void)showInUIViewController:(UIViewController*)viewController + toExportKeysToFile:(NSURL*)keyFile + onLoading:(void(^)(BOOL onLoading))onLoading + onComplete:(void(^)(BOOL success))onComplete; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysExportView.m b/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysExportView.m new file mode 100644 index 000000000..8351571dd --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysExportView.m @@ -0,0 +1,192 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEncryptionKeysExportView.h" + +#import "MXKViewController.h" +#import "MXKRoomDataSource.h" +#import "NSBundle+MatrixKit.h" + +#import + +#import "MXKSwiftHeader.h" + +@interface MXKEncryptionKeysExportView () +{ + MXSession *mxSession; +} + +@end + +@implementation MXKEncryptionKeysExportView + +- (instancetype)initWithMatrixSession:(MXSession *)matrixSession +{ + self = [super init]; + if (self) + { + mxSession = matrixSession; + _passphraseMinLength = 1; + + _alertController = [UIAlertController alertControllerWithTitle:[MatrixKitL10n e2eExportRoomKeys] message:[MatrixKitL10n e2eExportPrompt] preferredStyle:UIAlertControllerStyleAlert]; + } + return self; +} + + +- (void)showInViewController:(MXKViewController *)mxkViewController toExportKeysToFile:(NSURL *)keyFile onComplete:(void (^)(BOOL success))onComplete +{ + [self showInUIViewController:mxkViewController toExportKeysToFile:keyFile onLoading:^(BOOL onLoading) { + if (onLoading) + { + [mxkViewController startActivityIndicator]; + } + else + { + [mxkViewController stopActivityIndicator]; + } + } onComplete:onComplete]; +} + +- (void)showInUIViewController:(UIViewController*)viewController + toExportKeysToFile:(NSURL*)keyFile + onLoading:(void(^)(BOOL onLoading))onLoading + onComplete:(void(^)(BOOL success))onComplete +{ + __weak typeof(self) weakSelf = self; + + // Finalise the dialog + [_alertController addTextFieldWithConfigurationHandler:^(UITextField *textField) + { + textField.secureTextEntry = YES; + textField.placeholder = [MatrixKitL10n e2ePassphraseCreate]; + [textField resignFirstResponder]; + }]; + + [_alertController addTextFieldWithConfigurationHandler:^(UITextField *textField) + { + textField.secureTextEntry = YES; + textField.placeholder = [MatrixKitL10n e2ePassphraseConfirm]; + [textField resignFirstResponder]; + }]; + + [_alertController addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + onComplete(NO); + } + + }]]; + + [_alertController addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n e2eExport] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Retrieve the password and confirmation + UITextField *textField = [self.alertController textFields].firstObject; + NSString *password = textField.text; + + textField = [self.alertController textFields][1]; + NSString *confirmation = textField.text; + + // Check they are valid + if (password.length < self.passphraseMinLength || ![password isEqualToString:confirmation]) + { + NSString *error; + if (!password.length) + { + error = [MatrixKitL10n e2ePassphraseEmpty]; + } + else if (password.length < self.passphraseMinLength) + { + error = [MatrixKitL10n e2ePassphraseTooShort:self.passphraseMinLength]; + } + else + { + error = [MatrixKitL10n e2ePassphraseNotMatch]; + } + + UIAlertController *otherAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n error] message:error preferredStyle:UIAlertControllerStyleAlert]; + + [otherAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { + + if (weakSelf) + { + onComplete(NO); + } + + }]]; + + [viewController presentViewController:otherAlert animated:YES completion:nil]; + } + else + { + // Start the export process + onLoading(YES); + + [self->mxSession.crypto exportRoomKeysWithPassword:password success:^(NSData *keyFileData) { + + if (weakSelf) + { + onLoading(NO); + + // Write the result to the passed file + [keyFileData writeToURL:keyFile atomically:YES]; + onComplete(YES); + } + + } failure:^(NSError *error) { + + if (weakSelf) + { + onLoading(NO); + + // TODO: i18n the error + UIAlertController *otherAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n error] message:error.localizedDescription preferredStyle:UIAlertControllerStyleAlert]; + + [otherAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { + + if (weakSelf) + { + onComplete(NO); + } + + }]]; + + [viewController presentViewController:otherAlert animated:YES completion:nil]; + } + }]; + } + } + + }]]; + + + + // And show it + [viewController presentViewController:_alertController animated:YES completion:nil]; +} + + +@end + diff --git a/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysImportView.h b/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysImportView.h new file mode 100644 index 000000000..397edc5ef --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysImportView.h @@ -0,0 +1,49 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +@class MXSession, MXKViewController; + +/** + `MXKEncryptionKeysImportView` is a dialog to import encryption keys into + the user's crypto store. + */ +@interface MXKEncryptionKeysImportView : NSObject + +/** + The UIAlertController instance which handles the dialog. + */ +@property (nonatomic, readonly) UIAlertController *alertController; + +/** + Create the `MXKEncryptionKeysImportView` instance. + + @param mxSession the mxSession to import keys to. + @return the newly created MXKEncryptionKeysImportView instance. + */ +- (instancetype)initWithMatrixSession:(MXSession*)mxSession; + +/** + Show the dialog in a given view controller. + + @param mxkViewController the mxkViewController where to show the dialog. + @param fileURL the url of the keys file. + @param onComplete a block called when the operation is done (whatever it succeeded or failed). + */ +- (void)showInViewController:(MXKViewController*)mxkViewController toImportKeys:(NSURL*)fileURL onComplete:(void(^)(void))onComplete; + +@end diff --git a/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysImportView.m b/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysImportView.m new file mode 100644 index 000000000..154cca7d9 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/EncryptionKeys/MXKEncryptionKeysImportView.m @@ -0,0 +1,122 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEncryptionKeysImportView.h" + +#import "MXKViewController.h" +#import "NSBundle+MatrixKit.h" + +#import + +#import "MXKSwiftHeader.h" + +@interface MXKEncryptionKeysImportView () +{ + MXSession *mxSession; +} + +@end + +@implementation MXKEncryptionKeysImportView + +- (instancetype)initWithMatrixSession:(MXSession *)matrixSession +{ + self = [super init]; + if (self) + { + mxSession = matrixSession; + + _alertController = [UIAlertController alertControllerWithTitle:[MatrixKitL10n e2eImportRoomKeys] message:[MatrixKitL10n e2eImportPrompt] preferredStyle:UIAlertControllerStyleAlert]; + } + return self; +} + +- (void)showInViewController:(MXKViewController*)mxkViewController toImportKeys:(NSURL*)fileURL onComplete:(void(^)(void))onComplete +{ + __weak typeof(self) weakSelf = self; + + // Finalise the dialog + [_alertController addTextFieldWithConfigurationHandler:^(UITextField *textField) + { + textField.secureTextEntry = YES; + textField.placeholder = [MatrixKitL10n e2ePassphraseEnter]; + [textField resignFirstResponder]; + }]; + + [_alertController addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + onComplete(); + } + + }]]; + + [_alertController addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n e2eImport] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Retrieve the password + UITextField *textField = [self.alertController textFields].firstObject; + NSString *password = textField.text; + + // Start the import process + [mxkViewController startActivityIndicator]; + [self->mxSession.crypto importRoomKeys:[NSData dataWithContentsOfURL:fileURL] withPassword:password success:^(NSUInteger total, NSUInteger imported) { + + if (weakSelf) + { + [mxkViewController stopActivityIndicator]; + onComplete(); + } + + } failure:^(NSError *error) { + + if (weakSelf) + { + [mxkViewController stopActivityIndicator]; + + // TODO: i18n the error + UIAlertController *otherAlert = [UIAlertController alertControllerWithTitle:[MatrixKitL10n error] message:error.localizedDescription preferredStyle:UIAlertControllerStyleAlert]; + + [otherAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n ok] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { + + if (weakSelf) + { + onComplete(); + } + + }]]; + + [mxkViewController presentViewController:otherAlert animated:YES completion:nil]; + } + + }]; + } + + }]]; + + // And show it + [mxkViewController presentViewController:_alertController animated:YES completion:nil]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.h b/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.h new file mode 100644 index 000000000..874023ebb --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.h @@ -0,0 +1,39 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +#import "MXKCellRendering.h" + +#import "MXKGroupCellDataStoring.h" + +/** + `MXKGroupTableViewCell` instances display a group. + */ +@interface MXKGroupTableViewCell : MXKTableViewCell +{ +@protected + /** + The current cell data displayed by the table view cell + */ + id groupCellData; +} + +@property (weak, nonatomic) IBOutlet UILabel *groupName; +@property (weak, nonatomic) IBOutlet UILabel *groupDescription; +@property (weak, nonatomic) IBOutlet UILabel *memberCount; + +@end diff --git a/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.m b/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.m new file mode 100644 index 000000000..188a4c493 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.m @@ -0,0 +1,92 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKGroupTableViewCell.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKGroupTableViewCell +@synthesize delegate; + +#pragma mark - Class methods + +- (void)render:(MXKCellData *)cellData +{ + groupCellData = (id)cellData; + if (groupCellData) + { + // Render the current group values. + _groupName.text = groupCellData.groupDisplayname; + _groupDescription.text = groupCellData.group.profile.shortDescription; + + if (_groupDescription.text.length) + { + _groupDescription.hidden = NO; + } + else + { + // Hide and fill the label with a fake description to harmonize the height of all the cells. + // This is a drawback of the self-sizing cell. + _groupDescription.hidden = YES; + _groupDescription.text = @"No description"; + } + + if (_memberCount) + { + if (groupCellData.group.summary.usersSection.totalUserCountEstimate > 1) + { + _memberCount.text = [MatrixKitL10n numMembersOther:@(groupCellData.group.summary.usersSection.totalUserCountEstimate).stringValue]; + } + else if (groupCellData.group.summary.usersSection.totalUserCountEstimate == 1) + { + _memberCount.text = [MatrixKitL10n numMembersOne:@(1).stringValue]; + } + else + { + _memberCount.text = nil; + } + } + } + else + { + _groupName.text = nil; + _groupDescription.text = nil; + _memberCount.text = nil; + } +} + +- (MXKCellData*)renderedCellData +{ + return groupCellData; +} + ++ (CGFloat)heightForCellData:(MXKCellData *)cellData withMaximumWidth:(CGFloat)maxWidth +{ + // The height is fixed + //@TODO: change this to handle dynamic font + return 70; +} + +- (void)prepareForReuse +{ + [super prepareForReuse]; + + groupCellData = nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.xib b/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.xib new file mode 100644 index 000000000..cf6efef01 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Group/MXKGroupTableViewCell.xib @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKCellRendering.h b/Riot/Modules/MatrixKit/Views/MXKCellRendering.h new file mode 100644 index 000000000..60d712910 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKCellRendering.h @@ -0,0 +1,121 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKCellData.h" + +@protocol MXKCellRenderingDelegate; + +/** + `MXKCellRendering` defines a protocol a view must conform to display a cell. + + A cell is a generic term. It can be a UITableViewCell or a UICollectionViewCell or any object + expected by the end view controller. + */ +@protocol MXKCellRendering + +/** + * Returns the `UINib` object initialized for the cell. + * + * @return The initialized `UINib` object or `nil` if there were errors during + * initialization or the nib file could not be located. + */ ++ (UINib *)nib; + +/** + Configure the cell in order to display the passed data. + + The object implementing the `MXKCellRendering` protocol should be able to cast the past object + into its original class. + + @param cellData the data object to render. + */ +- (void)render:(MXKCellData*)cellData; + +/** + Compute the height of the cell to display the passed data. + + @TODO: To support correctly the dynamic fonts, we have to remove this method and + its use by enabling self sizing cells at the table view level. + When we create a self-sizing table view cell, we need to set the property `estimatedRowHeight` of the table view + and use constraints to define the cell’s size. + + @param cellData the data object to render. + @param maxWidth the maximum available width. + @return the cell height + */ ++ (CGFloat)heightForCellData:(MXKCellData*)cellData withMaximumWidth:(CGFloat)maxWidth; + +@optional + +/** + User's actions delegate. + */ +@property (nonatomic, weak) id delegate; + +/** + This optional getter allow to retrieve the data object currently rendered by the cell. + + @return the current rendered data object. + */ +- (MXKCellData*)renderedCellData; + +/** + Reset the cell. + + The cell is no more displayed. This is time to release resources and removing listeners. + In case of UITableViewCell or UIContentViewCell object, the cell must reset in a state + that it can be reusable. + */ +- (void)didEndDisplay; + +@end + +/** +`MXKCellRenderingDelegate` defines a protocol used when the user has interactions with + the cell view. + */ +@protocol MXKCellRenderingDelegate + +/** + Tells the delegate that a user action (button pressed, tap, long press...) has been observed in the cell. + + The action is described by the `actionIdentifier` param. + This identifier is specific and depends to the cell view class implementing MXKCellRendering. + + @param cell the cell in which gesture has been observed. + @param actionIdentifier an identifier indicating the action type (tap, long press...) and which part of the cell is concerned. + @param userInfo a dict containing additional information. It depends on actionIdentifier. May be nil. + */ +- (void)cell:(id)cell didRecognizeAction:(NSString*)actionIdentifier userInfo:(NSDictionary *)userInfo; + +/** + Asks the delegate if a user action (click on a link) can be done. + + The action is described by the `actionIdentifier` param. + This identifier is specific and depends to the cell view class implementing MXKCellRendering. + + @param cell the cell in which gesture has been observed. + @param actionIdentifier an identifier indicating the action type (link click) and which part of the cell is concerned. + @param userInfo a dict containing additional information. It depends on actionIdentifier. May be nil. + @param defaultValue the value to return by default if the action is not handled. + @return a boolean value which depends on actionIdentifier. + */ +- (BOOL)cell:(id)cell shouldDoAction:(NSString*)actionIdentifier userInfo:(NSDictionary *)userInfo defaultValue:(BOOL)defaultValue; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKCollectionViewCell.h b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKCollectionViewCell.h new file mode 100644 index 000000000..3ec4f302f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKCollectionViewCell.h @@ -0,0 +1,47 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +/** + 'MXKCollectionViewCell' class is used to define custom UICollectionViewCell. + Each 'MXKCollectionViewCell-inherited' class has its own 'reuseIdentifier'. + */ +@interface MXKCollectionViewCell : UICollectionViewCell + +/** + Returns the `UINib` object initialized for the cell. + + @return The initialized `UINib` object or `nil` if there were errors during + initialization or the nib file could not be located. + */ ++ (UINib *)nib; + +/** + The default reuseIdentifier of the 'MXKCollectionViewCell-inherited' class. + */ ++ (NSString*)defaultReuseIdentifier; + +/** + Customize the rendering of the collection view cell and its subviews (Do nothing by default). + This method is called when the view is initialized or prepared for reuse. + + Override this method to customize the collection view cell at the application level. + */ +- (void)customizeCollectionViewCellRendering; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKCollectionViewCell.m b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKCollectionViewCell.m new file mode 100644 index 000000000..e18f68927 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKCollectionViewCell.m @@ -0,0 +1,76 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKCollectionViewCell.h" + +@implementation MXKCollectionViewCell + ++ (UINib *)nib +{ + // Check whether a nib file is available + NSBundle *mainBundle = [NSBundle bundleForClass:self.class]; + NSString *path = [mainBundle pathForResource:NSStringFromClass([self class]) ofType:@"nib"]; + if (path) + { + return [UINib nibWithNibName:NSStringFromClass([self class]) bundle:mainBundle]; + } + return nil; +} + ++ (NSString*)defaultReuseIdentifier +{ + return NSStringFromClass([self class]); +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + [self customizeCollectionViewCellRendering]; +} + +- (void)prepareForReuse +{ + [super prepareForReuse]; + + [self customizeCollectionViewCellRendering]; +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + // Check whether a xib is defined + if ([[self class] nib]) + { + self = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + self.frame = frame; + } + else + { + self = [super initWithFrame:frame]; + [self customizeCollectionViewCellRendering]; + } + + return self; +} + +- (void)customizeCollectionViewCellRendering +{ + // Do nothing by default. +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.h b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.h new file mode 100644 index 000000000..932a2b4b7 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.h @@ -0,0 +1,45 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKCollectionViewCell.h" + +#import + +#import "MXKImageView.h" + +/** + 'MXKMediaCollectionViewCell' class is used to display picture or video thumbnail. + */ +@interface MXKMediaCollectionViewCell : MXKCollectionViewCell + +@property (weak, nonatomic) IBOutlet UIView *customView; +@property (weak, nonatomic) IBOutlet MXKImageView *mxkImageView; +@property (weak, nonatomic) IBOutlet UIImageView *centerIcon; +@property (weak, nonatomic) IBOutlet UIImageView *bottomLeftIcon; +@property (weak, nonatomic) IBOutlet UIImageView *bottomRightIcon; +@property (weak, nonatomic) IBOutlet UIImageView *topRightIcon; + +/** + A potential player used in the cell. + */ +@property (nonatomic) AVPlayerViewController *moviePlayer; + +/** + A potential observer used to update cell display. + */ +@property (nonatomic) id notificationObserver; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.m b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.m new file mode 100644 index 000000000..2f19de586 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.m @@ -0,0 +1,73 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKMediaCollectionViewCell.h" + +@implementation MXKMediaCollectionViewCell + +- (void)prepareForReuse +{ + [super prepareForReuse]; + [self.moviePlayer.player pause]; + self.moviePlayer.player = nil; + self.moviePlayer = nil; + + // Restore the cell in reusable state + self.mxkImageView.hidden = NO; + self.mxkImageView.stretchable = NO; + // Cancel potential image download + self.mxkImageView.enableInMemoryCache = NO; + [self.mxkImageView setImageURI:nil + withType:nil + andImageOrientation:UIImageOrientationUp + previewImage:nil + mediaManager:nil]; + + self.customView.hidden = YES; + self.centerIcon.hidden = YES; + + // Remove added view in custon view + NSArray *subViews = self.customView.subviews; + for (UIView *view in subViews) + { + [view removeFromSuperview]; + } + + // Remove potential media download observer + if (self.notificationObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:self.notificationObserver]; + self.notificationObserver = nil; + } + + // Remove all gesture recognizers + while (self.gestureRecognizers.count) + { + [self removeGestureRecognizer:self.gestureRecognizers[0]]; + } + self.tag = -1; +} + +- (void)dealloc +{ + [self.moviePlayer.player pause]; + self.moviePlayer.player = nil; +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.xib b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.xib new file mode 100644 index 000000000..a9934c689 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKCollectionViewCell/MXKMediaCollectionViewCell.xib @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.h b/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.h new file mode 100644 index 000000000..4ab17628c --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.h @@ -0,0 +1,32 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +@interface MXKEventDetailsView : MXKView + +@property (weak, nonatomic) IBOutlet UITextView *textView; +@property (weak, nonatomic) IBOutlet UIButton *redactButton; +@property (weak, nonatomic) IBOutlet UIButton *closeButton; +@property (weak, nonatomic) IBOutlet UIActivityIndicatorView *activityIndicator; + +- (instancetype)initWithEvent:(MXEvent*)event andMatrixSession:(MXSession*)session; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.m b/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.m new file mode 100644 index 000000000..59ed20691 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.m @@ -0,0 +1,204 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEventDetailsView.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKConstants.h" + +#import "MXKSwiftHeader.h" + +@interface MXKEventDetailsView () +{ + /** + The displayed event + */ + MXEvent *mxEvent; + + /** + The matrix session. + */ + MXSession *mxSession; +} +@end + +@implementation MXKEventDetailsView + ++ (UINib *)nib +{ + // Check whether a nib file is available + NSBundle *mainBundle = [NSBundle mxk_bundleForClass:self.class]; + + NSString *path = [mainBundle pathForResource:NSStringFromClass([self class]) ofType:@"nib"]; + if (path) + { + return [UINib nibWithNibName:NSStringFromClass([self class]) bundle:mainBundle]; + } + return [UINib nibWithNibName:NSStringFromClass([MXKEventDetailsView class]) bundle:[NSBundle mxk_bundleForClass:[MXKEventDetailsView class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + // Localize string + [_redactButton setTitle:[MatrixKitL10n redact] forState:UIControlStateNormal]; + [_redactButton setTitle:[MatrixKitL10n redact] forState:UIControlStateHighlighted]; + [_closeButton setTitle:[MatrixKitL10n close] forState:UIControlStateNormal]; + [_closeButton setTitle:[MatrixKitL10n close] forState:UIControlStateHighlighted]; +} + +- (instancetype)initWithEvent:(MXEvent*)event andMatrixSession:(MXSession*)session +{ + self = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + if (self) + { + mxEvent = event; + mxSession = session; + + [self setTranslatesAutoresizingMaskIntoConstraints: NO]; + + // Disable redact button by default + _redactButton.enabled = NO; + + if (mxEvent) + { + NSMutableDictionary *eventDict = [NSMutableDictionary dictionaryWithDictionary:mxEvent.JSONDictionary]; + + // Remove event type added by SDK + [eventDict removeObjectForKey:@"event_type"]; + // Remove null values and empty dictionaries + for (NSString *key in eventDict.allKeys) + { + if ([[eventDict objectForKey:key] isEqual:[NSNull null]]) + { + [eventDict removeObjectForKey:key]; + } + else if ([[eventDict objectForKey:key] isKindOfClass:[NSDictionary class]]) + { + NSDictionary *dict = [eventDict objectForKey:key]; + if (!dict.count) + { + [eventDict removeObjectForKey:key]; + } + else + { + NSMutableDictionary *updatedDict = [NSMutableDictionary dictionaryWithDictionary:dict]; + for (NSString *subKey in dict.allKeys) + { + if ([[dict objectForKey:subKey] isEqual:[NSNull null]]) + { + [updatedDict removeObjectForKey:subKey]; + } + } + [eventDict setObject:updatedDict forKey:key]; + } + } + } + + // Set text view content + NSError *error; + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:eventDict + options:NSJSONWritingPrettyPrinted + error:&error]; + _textView.text = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + + // Check whether the user can redact this event + // Do not allow to redact the event that enabled encryption (m.room.encryption) + // because it breaks everything + if (!mxEvent.isRedactedEvent && mxEvent.eventType != MXEventTypeRoomEncryption) + { + // Here the event has not been already redacted, check the user's power level + MXRoom *mxRoom = [mxSession roomWithRoomId:mxEvent.roomId]; + if (mxRoom) + { + MXWeakify(self); + [mxRoom state:^(MXRoomState *roomState) { + MXStrongifyAndReturnIfNil(self); + + MXRoomPowerLevels *powerLevels = [roomState powerLevels]; + NSInteger userPowerLevel = [powerLevels powerLevelOfUserWithUserID:self->mxSession.myUser.userId]; + if (powerLevels.redact) + { + if (userPowerLevel >= powerLevels.redact) + { + self.redactButton.enabled = YES; + } + } + else if (userPowerLevel >= [powerLevels minimumPowerLevelForSendingEventAsMessage:kMXEventTypeStringRoomRedaction]) + { + self.redactButton.enabled = YES; + } + }]; + } + } + } + else + { + _textView.text = nil; + } + + // Hide potential activity indicator + [_activityIndicator stopAnimating]; + } + + return self; +} + +- (void)dealloc +{ + mxEvent = nil; + mxSession = nil; +} + +#pragma mark - Actions + +- (IBAction)onButtonPressed:(id)sender +{ + if (sender == _redactButton) + { + MXRoom *mxRoom = [mxSession roomWithRoomId:mxEvent.roomId]; + if (mxRoom) + { + [_activityIndicator startAnimating]; + [mxRoom redactEvent:mxEvent.eventId reason:nil success:^{ + + [self->_activityIndicator stopAnimating]; + [self removeFromSuperview]; + + } failure:^(NSError *error) { + + MXLogDebug(@"[MXKEventDetailsView] Redact event (%@) failed", self->mxEvent.eventId); + + // Notify MatrixKit user + NSString *myUserId = mxRoom.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + [self->_activityIndicator stopAnimating]; + + }]; + } + + } + else if (sender == _closeButton) + { + [self removeFromSuperview]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.xib b/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.xib new file mode 100644 index 000000000..71685321f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKEventDetailsView.xib @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKImageView.h b/Riot/Modules/MatrixKit/Views/MXKImageView.h new file mode 100644 index 000000000..ddda448c3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKImageView.h @@ -0,0 +1,126 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import + +#import "MXKView.h" + +@class MXKAttachment; + +/** + Customize UIView in order to display image defined with remote url. Zooming inside the image (Stretching) is supported. + */ +@interface MXKImageView : MXKView + +typedef void (^blockMXKImageView_onClick)(MXKImageView *imageView, NSString* title); + +/** + Load an image by its Matrix Content URI. + The image is loaded from the media cache (if any). If the image is not available yet, + it is downloaded from the Matrix media repository only if a media manager instance is provided. + + The image extension is extracted from the provided mime type (if any). By default 'image/jpeg' is considered. + + @param mxContentURI the Matrix Content URI + @param mimeType the media mime type, it is used to define the file extension (may be nil). + @param orientation the actual orientation of the encoded image (used UIImageOrientationUp by default). + @param previewImage image displayed until the actual image is available. + @param mediaManager the media manager instance used to download the image if it is not already in cache. + */ +- (void)setImageURI:(NSString *)mxContentURI + withType:(NSString *)mimeType +andImageOrientation:(UIImageOrientation)orientation + previewImage:(UIImage*)previewImage + mediaManager:(MXMediaManager*)mediaManager; + +/** + Load an image by its Matrix Content URI to fit a specific view size. + + CAUTION: this method is available only for the unencrypted content. + + The image is loaded from the media cache (if any). If the image is not available yet, + it is downloaded from the Matrix media repository only if a media manager instance is provided. + The image extension is extracted from the provided mime type (if any). By default 'image/jpeg' is considered. + + @param mxContentURI the Matrix Content URI + @param mimeType the media mime type, it is used to define the file extension (may be nil). + @param orientation the actual orientation of the encoded image (used UIImageOrientationUp by default). + @param previewImage image displayed until the actual image is available. + @param mediaManager the media manager instance used to download the image if it is not already in cache. + */ +- (void)setImageURI:(NSString *)mxContentURI + withType:(NSString *)mimeType +andImageOrientation:(UIImageOrientation)orientation + toFitViewSize:(CGSize)viewSize + withMethod:(MXThumbnailingMethod)thumbnailingMethod + previewImage:(UIImage*)previewImage + mediaManager:(MXMediaManager*)mediaManager; + +/** + * Load an image attachment into the image viewer and display the full res image. + * This method must be used to display encrypted attachments + * @param attachment The attachment + */ +- (void)setAttachment:(MXKAttachment *)attachment; + +/** + * Load an attachment into the image viewer and display its thumbnail, if it has one. + * This method must be used to display encrypted attachments + * @param attachment The attachment + */ +- (void)setAttachmentThumb:(MXKAttachment *)attachment; + +/** + Toggle display to fullscreen. + + No change is applied on the status bar here, the caller has to handle it. + */ +- (void)showFullScreen; + +/** + The default background color. + Default is [UIColor blackColor]. + */ +@property (nonatomic) UIColor *defaultBackgroundColor; + +// Use this boolean to hide activity indicator during image downloading +@property (nonatomic) BOOL hideActivityIndicator; + +@property (strong, nonatomic) UIImage *image; +@property (nonatomic, readonly) UIImageView *imageView; + +@property (nonatomic) BOOL stretchable; +@property (nonatomic, readonly) BOOL fullScreen; + +// the image is cached in memory. +// The medias manager uses a LRU cache. +// to avoid loading from the file system. +@property (nonatomic) BOOL enableInMemoryCache; + +// mediaManager folder where the image is stored +@property (nonatomic) NSString* mediaFolder; + +// Let the user defines some custom buttons over the tabbar +- (void)setLeftButtonTitle :leftButtonTitle handler:(blockMXKImageView_onClick)handler; +- (void)setRightButtonTitle:rightButtonTitle handler:(blockMXKImageView_onClick)handler; + +- (void)dismissSelection; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKImageView.m b/Riot/Modules/MatrixKit/Views/MXKImageView.m new file mode 100644 index 000000000..92060afc6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKImageView.m @@ -0,0 +1,925 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKImageView.h" +#import "MXKPieChartView.h" +#import "MXKAttachment.h" + +#import "MXKTools.h" + +@interface MXKImageView () +{ + NSString *mxcURI; + NSString *mimeType; + UIImageOrientation imageOrientation; + + // additional settings used in case of thumbnail. + CGSize thumbnailViewSize; + MXThumbnailingMethod thumbnailMethod; + + UIImage *currentImage; + + // the loading view is composed with the spinner and a pie chart + // the spinner is display until progress > 0 + UIView *loadingView; + UIActivityIndicatorView *waitingDownloadSpinner; + MXKPieChartView *pieChartView; + UILabel *progressInfoLabel; + + // validation buttons + UIButton *leftButton; + UIButton *rightButton; + + NSString *leftButtonTitle; + NSString *rightButtonTitle; + + blockMXKImageView_onClick leftHandler; + blockMXKImageView_onClick rightHandler; + + UIView* bottomBarView; + + // Subviews + UIScrollView *scrollView; + + // Current attachment being displayed in the MXKImageView + MXKAttachment *currentAttachment; +} +@end + +@implementation MXKImageView +@synthesize stretchable, mediaFolder, imageView; + +#define CUSTOM_IMAGE_VIEW_BUTTON_WIDTH 100 + +- (void)dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + [self stopActivityIndicator]; + + if (loadingView) + { + [loadingView removeFromSuperview]; + loadingView = nil; + } + + if (bottomBarView) + { + [bottomBarView removeFromSuperview]; + bottomBarView = nil; + } + + pieChartView = nil; +} + +#pragma mark - Override MXKView + +-(void)customizeViewRendering +{ + [super customizeViewRendering]; + + self.backgroundColor = (_defaultBackgroundColor ? _defaultBackgroundColor : [UIColor blackColor]); + + self.contentMode = UIViewContentModeScaleAspectFit; + self.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleTopMargin; +} + +#pragma mark - + +- (void)startActivityIndicator +{ + // create the views if they don't exist + if (!waitingDownloadSpinner) + { + waitingDownloadSpinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhite]; + + CGRect frame = waitingDownloadSpinner.frame; + frame.size.width += 30; + frame.size.height += 30; + waitingDownloadSpinner.bounds = frame; + [waitingDownloadSpinner.layer setCornerRadius:5]; + } + + if (!loadingView) + { + loadingView = [[UIView alloc] init]; + loadingView.frame = waitingDownloadSpinner.bounds; + waitingDownloadSpinner.frame = waitingDownloadSpinner.bounds; + [loadingView addSubview:waitingDownloadSpinner]; + loadingView.backgroundColor = [UIColor clearColor]; + [self addSubview:loadingView]; + } + + if (!pieChartView) + { + pieChartView = [[MXKPieChartView alloc] init]; + pieChartView.frame = loadingView.bounds; + pieChartView.progress = 0; + pieChartView.progressColor = [UIColor colorWithRed:1 green:1 blue:1 alpha:0.25]; + pieChartView.unprogressColor = [UIColor clearColor]; + + [loadingView addSubview:pieChartView]; + } + + // display the download statistics + if (_fullScreen && !progressInfoLabel) + { + progressInfoLabel = [[UILabel alloc] init]; + progressInfoLabel.backgroundColor = [UIColor whiteColor]; + progressInfoLabel.textColor = [UIColor blackColor]; + progressInfoLabel.font = [UIFont systemFontOfSize:8]; + progressInfoLabel.alpha = 0.25; + progressInfoLabel.text = @""; + progressInfoLabel.numberOfLines = 0; + [progressInfoLabel sizeToFit]; + [self addSubview:progressInfoLabel]; + } + + // initvalue + loadingView.hidden = NO; + pieChartView.progress = 0; + + // Adjust color + if ([self.backgroundColor isEqual:[UIColor blackColor]]) + { + waitingDownloadSpinner.activityIndicatorViewStyle = UIActivityIndicatorViewStyleWhite; + // a preview image could be displayed + // ensure that the white spinner is visible + // it could be drawn on a white area + waitingDownloadSpinner.backgroundColor = [UIColor darkGrayColor]; + + } + else + { + waitingDownloadSpinner.activityIndicatorViewStyle = UIActivityIndicatorViewStyleGray; + } + + // ensure that the spinner is drawn at the top + [loadingView.superview bringSubviewToFront:loadingView]; + + // Adjust position + CGPoint center = CGPointMake(self.frame.size.width / 2, self.frame.size.height / 2); + loadingView.center = center; + + // Start + [waitingDownloadSpinner startAnimating]; +} + +- (void)stopActivityIndicator +{ + if (waitingDownloadSpinner && waitingDownloadSpinner.isAnimating) + { + [waitingDownloadSpinner stopAnimating]; + } + + pieChartView.progress = 0; + loadingView.hidden = YES; + + if (progressInfoLabel) + { + [progressInfoLabel removeFromSuperview]; + progressInfoLabel = nil; + } +} + +#pragma mark - setters/getters + +- (void)setDefaultBackgroundColor:(UIColor *)defaultBackgroundColor +{ + _defaultBackgroundColor = defaultBackgroundColor; + [self customizeViewRendering]; +} + +- (void)setImage:(UIImage *)anImage +{ + // remove the observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + currentImage = anImage; + imageView.image = anImage; + + [self initScrollZoomFactors]; +} + +- (UIImage*)image +{ + return currentImage; +} + +- (void)showFullScreen +{ + // The full screen display mode is supported only if the shared application instance is available. + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + _fullScreen = YES; + + [self initLayout]; + + if (self.superview) + { + [super removeFromSuperview]; + } + + UIWindow *window = [sharedApplication keyWindow]; + + self.frame = window.bounds; + [window addSubview:self]; + } +} + +#pragma mark - +- (IBAction)onButtonToggle:(id)sender +{ + if (sender == leftButton) + { + dispatch_async(dispatch_get_main_queue(), ^{ + self->leftHandler(self, self->leftButtonTitle); + }); + } + else if (sender == rightButton) + { + dispatch_async(dispatch_get_main_queue(), ^{ + self->rightHandler(self, self->rightButtonTitle); + }); + } +} + +// add a generic button to the bottom view +// return the added UIButton +- (UIButton*) addbuttonWithTitle:(NSString*)title +{ + UIButton* button = [[UIButton alloc] init]; + [button setTitle:title forState:UIControlStateNormal]; + [button setTitle:title forState:UIControlStateHighlighted]; + + if (_fullScreen) + { + // use the same text color as the tabbar + [button setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; + [button setTitleColor:[UIColor whiteColor] forState:UIControlStateHighlighted]; + } + // TODO + // else { + // // use the same text color as the tabbar + // [button setTitleColor:[AppDelegate theDelegate].masterTabBarController.tabBar.tintColor forState:UIControlStateNormal]; + // [button setTitleColor:[AppDelegate theDelegate].masterTabBarController.tabBar.tintColor forState:UIControlStateHighlighted]; + // } + + // keep the bottomView background color + button.backgroundColor = [UIColor clearColor]; + + [button addTarget:self action:@selector(onButtonToggle:) forControlEvents:UIControlEventTouchUpInside]; + [bottomBarView addSubview:button]; + + return button; +} + +- (void)initScrollZoomFactors +{ + // check if the image can be zoomed + if (self.image && self.stretchable && imageView.frame.size.width && imageView.frame.size.height) + { + // ensure that the content size is properly initialized + scrollView.contentSize = scrollView.frame.size; + + // compute the appliable zoom factor + // assume that the user does not expect to zoom more than 100% + CGSize imageSize = self.image.size; + + CGFloat scaleX = imageSize.width / imageView.frame.size.width; + CGFloat scaleY = imageSize.height / imageView.frame.size.height; + + if (scaleX < scaleY) + { + scaleX = scaleY; + } + + if (scaleX < 1.0) + { + scaleX = 1.0; + } + + scrollView.zoomScale = 1.0; + scrollView.minimumZoomScale = 1.0; + scrollView.maximumZoomScale = scaleX; + + // update the image frame to ensure that it fits to the scrollview frame + imageView.frame = scrollView.bounds; + } +} + +- (void)removeFromSuperview +{ + [super removeFromSuperview]; + + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + if (pieChartView) + { + [self stopActivityIndicator]; + } +} + +- (void)initLayout +{ + // create the subviews if they don't exist + if (!scrollView) + { + scrollView = [[UIScrollView alloc] init]; + scrollView.delegate = self; + scrollView.backgroundColor = [UIColor clearColor]; + [self addSubview:scrollView]; + + imageView = [[UIImageView alloc] init]; + imageView.backgroundColor = [UIColor clearColor]; + imageView.userInteractionEnabled = YES; + imageView.contentMode = self.contentMode; + [scrollView addSubview:imageView]; + } +} + +- (void)layoutSubviews +{ + // call upper layer + [super layoutSubviews]; + + [self initLayout]; + + // the image has been updated + if (imageView.image != self.image) + { + imageView.image = self.image; + } + + CGRect tabBarFrame = CGRectZero; + UITabBarController *tabBarController = nil; + UIEdgeInsets safeAreaInsets = UIEdgeInsetsZero; + + if (leftButtonTitle || rightButtonTitle) + { + UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)]; + if (sharedApplication) + { + safeAreaInsets = [sharedApplication keyWindow].safeAreaInsets; + + UIViewController *rootViewController = [sharedApplication keyWindow].rootViewController; + tabBarController = rootViewController.tabBarController; + if (!tabBarController && [rootViewController isKindOfClass:[UITabBarController class]]) + { + tabBarController = (UITabBarController*)rootViewController; + } + } + + if (tabBarController) + { + tabBarFrame = tabBarController.tabBar.frame; + } + else + { + // Define a default tabBar frame + tabBarFrame = CGRectMake(0, 0, self.frame.size.width, 44 + safeAreaInsets.bottom); + } + } + + // update the scrollview frame + CGRect oneSelfFrame = self.frame; + CGRect scrollViewFrame = CGRectIntegral(scrollView.frame); + + if (leftButtonTitle || rightButtonTitle) + { + oneSelfFrame.size.height -= tabBarFrame.size.height; + } + + oneSelfFrame = CGRectIntegral(oneSelfFrame); + oneSelfFrame.origin = scrollViewFrame.origin = CGPointZero; + + // use integral rect to avoid rounded value issue (float precision) + if (!CGRectEqualToRect(oneSelfFrame, scrollViewFrame)) + { + scrollView.frame = oneSelfFrame; + imageView.frame = oneSelfFrame; + + [self initScrollZoomFactors]; + } + + // check if the dedicated buttons are already added + if (leftButtonTitle || rightButtonTitle) + { + + if (!bottomBarView) + { + bottomBarView = [[UIView alloc] init]; + + if (leftButtonTitle) + { + leftButton = [self addbuttonWithTitle:leftButtonTitle]; + } + + rightButton = [[UIButton alloc] init]; + + if (rightButtonTitle) + { + rightButton = [self addbuttonWithTitle:rightButtonTitle]; + } + + // in fullscreen, display both buttons above the view (do the same if there is no tab bar) + if (_fullScreen || tabBarController == nil) + { + bottomBarView.backgroundColor = [UIColor blackColor]; + [self addSubview:bottomBarView]; + } + else + { + // default tabbar background color + CGFloat base = 248.0 / 255.0f; + bottomBarView.backgroundColor = [UIColor colorWithRed:base green:base blue:base alpha:1.0]; + + // Display them over the tabbar + [tabBarController.tabBar addSubview:bottomBarView]; + } + } + + if (_fullScreen) + { + tabBarFrame.origin.y = self.frame.size.height - tabBarFrame.size.height; + } + else + { + tabBarFrame.origin.y = 0; + } + bottomBarView.frame = tabBarFrame; + + if (leftButton) + { + leftButton.frame = CGRectMake(safeAreaInsets.left, 0, CUSTOM_IMAGE_VIEW_BUTTON_WIDTH, bottomBarView.frame.size.height - safeAreaInsets.bottom); + } + + if (rightButton) + { + rightButton.frame = CGRectMake(bottomBarView.frame.size.width - CUSTOM_IMAGE_VIEW_BUTTON_WIDTH - safeAreaInsets.right, 0, CUSTOM_IMAGE_VIEW_BUTTON_WIDTH, bottomBarView.frame.size.height - safeAreaInsets.bottom); + } + } + + if (!loadingView.hidden) + { + // ensure that the spinner is drawn at the top + [loadingView.superview bringSubviewToFront:loadingView]; + + // Adjust positions + CGPoint center = CGPointMake(self.frame.size.width / 2, self.frame.size.height / 2); + loadingView.center = center; + + CGRect progressInfoLabelFrame = progressInfoLabel.frame; + progressInfoLabelFrame.origin.x = center.x - (progressInfoLabelFrame.size.width / 2); + progressInfoLabelFrame.origin.y = 10 + loadingView.frame.origin.y + loadingView.frame.size.height; + progressInfoLabel.frame = progressInfoLabelFrame; + } +} + +- (void)setHideActivityIndicator:(BOOL)hideActivityIndicator +{ + _hideActivityIndicator = hideActivityIndicator; + if (hideActivityIndicator) + { + [self stopActivityIndicator]; + } + else if (mxcURI) + { + NSString *downloadId = [MXMediaManager downloadIdForMatrixContentURI:mxcURI inFolder:mediaFolder]; + if ([MXMediaManager existingDownloaderWithIdentifier:downloadId]) + { + // Loading is in progress, start activity indicator + [self startActivityIndicator]; + } + } +} + +- (void)setImageURI:(NSString *)mxContentURI + withType:(NSString *)mimeType +andImageOrientation:(UIImageOrientation)orientation + previewImage:(UIImage*)previewImage + mediaManager:(MXMediaManager*)mediaManager +{ + [self setImageURI:mxContentURI + withType:mimeType + andImageOrientation:orientation + isThumbnail:NO + previewImage:previewImage + mediaManager:mediaManager]; +} + +- (void)setImageURI:(NSString *)mxContentURI + withType:(NSString *)mimeType +andImageOrientation:(UIImageOrientation)orientation + toFitViewSize:(CGSize)viewSize + withMethod:(MXThumbnailingMethod)thumbnailingMethod + previewImage:(UIImage*)previewImage + mediaManager:(MXMediaManager*)mediaManager +{ + // Store the thumbnail settings + thumbnailViewSize = viewSize; + thumbnailMethod = thumbnailingMethod; + + [self setImageURI:mxContentURI + withType:mimeType + andImageOrientation:orientation + isThumbnail:YES + previewImage:previewImage + mediaManager:mediaManager]; +} + +- (void)setImageURI:(NSString *)mxContentURI + withType:(NSString *)mimeType +andImageOrientation:(UIImageOrientation)orientation + isThumbnail:(BOOL)isThumbnail + previewImage:(UIImage*)previewImage + mediaManager:(MXMediaManager*)mediaManager +{ + // Remove any pending observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + // Reset other data + currentAttachment = nil; + + mxcURI = mxContentURI; + if (!mxcURI) + { + // Set preview by default + self.image = previewImage; + return; + } + + // Store image orientation + imageOrientation = orientation; + + // Store the mime type used to define the cache path of the image. + mimeType = mimeType; + if (!mimeType.length) + { + // Set default mime type if no information is available + mimeType = @"image/jpeg"; + } + + // Retrieve the image from cache if any + NSString *cacheFilePath; + if (isThumbnail) + { + cacheFilePath = [MXMediaManager thumbnailCachePathForMatrixContentURI:mxcURI + andType:mimeType + inFolder:mediaFolder + toFitViewSize:thumbnailViewSize + withMethod:thumbnailMethod]; + } + else + { + cacheFilePath = [MXMediaManager cachePathForMatrixContentURI:mxcURI + andType:mimeType + inFolder:mediaFolder]; + } + + UIImage* image = _enableInMemoryCache ? [MXMediaManager loadThroughCacheWithFilePath:cacheFilePath] : [MXMediaManager loadPictureFromFilePath:cacheFilePath]; + if (image) + { + if (imageOrientation != UIImageOrientationUp) + { + self.image = [UIImage imageWithCGImage:image.CGImage scale:1.0 orientation:imageOrientation]; + } + else + { + self.image = image; + } + + [self stopActivityIndicator]; + } + else + { + // Set preview until the image is loaded + self.image = previewImage; + + // Check whether the image download is in progress + NSString *downloadId; + if (isThumbnail) + { + downloadId = [MXMediaManager thumbnailDownloadIdForMatrixContentURI:mxcURI + inFolder:mediaFolder + toFitViewSize:thumbnailViewSize + withMethod:thumbnailMethod]; + } + else + { + downloadId = [MXMediaManager downloadIdForMatrixContentURI:mxcURI inFolder:mediaFolder]; + } + + MXMediaLoader* loader = [MXMediaManager existingDownloaderWithIdentifier:downloadId]; + if (!loader && mediaManager) + { + // Trigger the download + if (isThumbnail) + { + loader = [mediaManager downloadThumbnailFromMatrixContentURI:mxcURI + withType:mimeType + inFolder:mediaFolder + toFitViewSize:thumbnailViewSize + withMethod:thumbnailMethod + success:nil + failure:nil]; + } + else + { + loader = [mediaManager downloadMediaFromMatrixContentURI:mxcURI + withType:mimeType + inFolder:mediaFolder]; + } + } + + if (loader) + { + // update the progress UI with the current info + if (!_hideActivityIndicator) + { + [self startActivityIndicator]; + } + [self updateProgressUI:loader.statisticsDict]; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:loader]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMediaLoaderStateDidChange:) name:kMXMediaLoaderStateDidChangeNotification object:loader]; + } + } +} + +- (void)setAttachment:(MXKAttachment *)attachment +{ + // Remove any pending observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + // Set default orientation + imageOrientation = UIImageOrientationUp; + + mediaFolder = attachment.eventRoomId; + mxcURI = attachment.contentURL; + mimeType = attachment.contentInfo[@"mimetype"]; + if (!mimeType.length) + { + // Set default mime type if no information is available + mimeType = @"image/jpeg"; + } + + // while we wait for the content to download + self.image = [attachment getCachedThumbnail]; + + if (!_hideActivityIndicator) + { + [self startActivityIndicator]; + } + + currentAttachment = attachment; + + MXWeakify(self); + [attachment getImage:^(MXKAttachment *attachment2, UIImage *img) { + MXStrongifyAndReturnIfNil(self); + + if (self->currentAttachment != attachment2) + { + return; + } + + self.image = img; + [self stopActivityIndicator]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + } failure:^(MXKAttachment *attachment2, NSError *error) { + MXLogDebug(@"Unable to fetch image attachment! %@", error); + MXStrongifyAndReturnIfNil(self); + + if (self->currentAttachment != attachment2) + { + return; + } + + [self stopActivityIndicator]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + }]; + + // Check whether the image download is in progress + NSString *downloadId = attachment.downloadId; + MXMediaLoader* loader = [MXMediaManager existingDownloaderWithIdentifier:downloadId]; + if (loader) + { + // Observer this loader to display progress + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(checkProgressOnMediaLoaderStateChange:) + name:kMXMediaLoaderStateDidChangeNotification + object:loader]; + } +} + +- (void)setAttachmentThumb:(MXKAttachment *)attachment +{ + // Remove any pending observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + // Store image orientation + imageOrientation = attachment.thumbnailOrientation; + + mediaFolder = attachment.eventRoomId; + + // Remove the existing image (if any) by using the potential preview. + self.image = attachment.previewImage; + + if (!_hideActivityIndicator) + { + [self startActivityIndicator]; + } + + currentAttachment = attachment; + + MXWeakify(self); + [attachment getThumbnail:^(MXKAttachment *attachment2, UIImage *img) { + MXStrongifyAndReturnIfNil(self); + + dispatch_async(dispatch_get_main_queue(), ^{ + + if (self->currentAttachment != attachment2) + { + return; + } + + if (img && self->imageOrientation != UIImageOrientationUp) + { + self.image = [UIImage imageWithCGImage:img.CGImage scale:1.0 orientation:self->imageOrientation]; + } + else + { + self.image = img; + } + [self stopActivityIndicator]; + }); + + } failure:^(MXKAttachment *attachment2, NSError *error) { + MXStrongifyAndReturnIfNil(self); + + dispatch_async(dispatch_get_main_queue(), ^{ + if (self->currentAttachment != attachment2) + { + return; + } + + [self stopActivityIndicator]; + }); + }]; +} + +- (void)updateProgressUI:(NSDictionary*)downloadStatsDict +{ + // Sanity check: updateProgressUI may be called while there is no stats available + // This happens when the download failed at the very beginning. + if (nil == downloadStatsDict) + { + return; + } + + NSNumber* progressNumber = [downloadStatsDict valueForKey:kMXMediaLoaderProgressValueKey]; + + if (progressNumber) + { + pieChartView.progress = progressNumber.floatValue; + waitingDownloadSpinner.hidden = YES; + } + + if (progressInfoLabel) + { + NSNumber* downloadRate = [downloadStatsDict valueForKey:kMXMediaLoaderCurrentDataRateKey]; + + NSNumber* completedBytesCount = [downloadStatsDict valueForKey:kMXMediaLoaderCompletedBytesCountKey]; + NSNumber* totalBytesCount = [downloadStatsDict valueForKey:kMXMediaLoaderTotalBytesCountKey]; + + NSMutableString* text = [[NSMutableString alloc] init]; + + if (completedBytesCount && totalBytesCount) + { + NSString* progressString = [NSString stringWithFormat:@"%@ / %@", [NSByteCountFormatter stringFromByteCount:completedBytesCount.longLongValue countStyle:NSByteCountFormatterCountStyleFile], [NSByteCountFormatter stringFromByteCount:totalBytesCount.longLongValue countStyle:NSByteCountFormatterCountStyleFile]]; + + [text appendString:progressString]; + } + + if (downloadRate) + { + if (completedBytesCount && totalBytesCount) + { + CGFloat remainimgTime = ((totalBytesCount.floatValue - completedBytesCount.floatValue)) / downloadRate.floatValue; + [text appendFormat:@" (%@)", [MXKTools formatSecondsInterval:remainimgTime]]; + } + + [text appendFormat:@"\n %@/s", [NSByteCountFormatter stringFromByteCount:downloadRate.longLongValue countStyle:NSByteCountFormatterCountStyleFile]]; + } + + progressInfoLabel.text = text; + + // on multilines, sizeToFit uses the current width + // so reset it + progressInfoLabel.frame = CGRectZero; + + [progressInfoLabel sizeToFit]; + + // + CGRect progressInfoLabelFrame = progressInfoLabel.frame; + progressInfoLabelFrame.origin.x = self.center.x - (progressInfoLabelFrame.size.width / 2); + progressInfoLabelFrame.origin.y = 10 + loadingView.frame.origin.y + loadingView.frame.size.height; + progressInfoLabel.frame = progressInfoLabelFrame; + } +} + +- (void)onMediaLoaderStateDidChange:(NSNotification *)notif +{ + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + switch (loader.state) { + case MXMediaLoaderStateDownloadInProgress: + [self updateProgressUI:loader.statisticsDict]; + break; + case MXMediaLoaderStateDownloadCompleted: + { + [self stopActivityIndicator]; + // update the image + UIImage* image = [MXMediaManager loadPictureFromFilePath:loader.downloadOutputFilePath]; + if (image) + { + if (imageOrientation != UIImageOrientationUp) + { + self.image = [UIImage imageWithCGImage:image.CGImage scale:1.0 orientation:imageOrientation]; + } + else + { + self.image = image; + } + } + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:loader]; + break; + } + case MXMediaLoaderStateDownloadFailed: + case MXMediaLoaderStateCancelled: + [self stopActivityIndicator]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:loader]; + break; + default: + break; + } +} + +- (void)checkProgressOnMediaLoaderStateChange:(NSNotification *)notif +{ + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + switch (loader.state) { + case MXMediaLoaderStateDownloadInProgress: + [self updateProgressUI:loader.statisticsDict]; + break; + default: + break; + } +} + +#pragma mark - buttons management + +- (void)setLeftButtonTitle: aLeftButtonTitle handler:(blockMXKImageView_onClick)handler +{ + leftButtonTitle = aLeftButtonTitle; + leftHandler = handler; +} + +- (void)setRightButtonTitle:aRightButtonTitle handler:(blockMXKImageView_onClick)handler +{ + rightButtonTitle = aRightButtonTitle; + rightHandler = handler; +} + +- (void)dismissSelection +{ + if (bottomBarView) + { + [bottomBarView removeFromSuperview]; + bottomBarView = nil; + } +} + +#pragma mark - UIScrollViewDelegate +// require to be able to zoom an image +- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView +{ + return self.stretchable ? imageView : nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKMessageTextView.h b/Riot/Modules/MatrixKit/Views/MXKMessageTextView.h new file mode 100644 index 000000000..12adffc9f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKMessageTextView.h @@ -0,0 +1,31 @@ +/* + Copyright 2019 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + MXKMessageTextView is a UITextView subclass with link detection without text selection. + */ +@interface MXKMessageTextView : UITextView + +// The last hit test location received by the view. +@property (nonatomic, readonly) CGPoint lastHitTestLocation; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/MatrixKit/Views/MXKMessageTextView.m b/Riot/Modules/MatrixKit/Views/MXKMessageTextView.m new file mode 100644 index 000000000..d68ff2cba --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKMessageTextView.m @@ -0,0 +1,57 @@ +/* + Copyright 2019 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKMessageTextView.h" +#import "UITextView+MatrixKit.h" + +@interface MXKMessageTextView() + +@property (nonatomic, readwrite) CGPoint lastHitTestLocation; + +@end + + +@implementation MXKMessageTextView + +- (BOOL)canBecomeFirstResponder +{ + return NO; +} + +- (BOOL)canPerformAction:(SEL)action withSender:(id)sender +{ + return NO; +} + +- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event +{ + self.lastHitTestLocation = point; + return [super hitTest:point withEvent:event]; +} + +// Indicate to receive a touch event only if a link is hitted. +// Otherwise it means that the touch event will pass through and could be received by a view below. +- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event +{ + if (![super pointInside:point withEvent:event]) + { + return NO; + } + + return [self isThereALinkNearPoint:point]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.h b/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.h new file mode 100644 index 000000000..8a6d22d53 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.h @@ -0,0 +1,26 @@ +/* + Copyright 2017 Aram Sargsyan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import "MXKView.h" + +@interface MXKPieChartHUD : MXKView + ++ (MXKPieChartHUD *)showLoadingHudOnView:(UIView *)view WithMessage:(NSString *)message; + +- (void)setProgress:(CGFloat)progress; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.m b/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.m new file mode 100644 index 000000000..7a7718837 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.m @@ -0,0 +1,115 @@ +/* + Copyright 2017 Aram Sargsyan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKPieChartHUD.h" +#import "NSBundle+MatrixKit.h" +#import "MXKPieChartView.h" + +@interface MXKPieChartHUD () + +@property (weak, nonatomic) IBOutlet UIView *hudView; +@property (weak, nonatomic) IBOutlet MXKPieChartView *pieChartView; +@property (weak, nonatomic) IBOutlet UILabel *titleLabel; + + +@end + +@implementation MXKPieChartHUD + +#pragma mark - Lifecycle + +- (instancetype)initWithFrame:(CGRect)frame +{ + self = [super initWithFrame:frame]; + if (self) + { + [self configureFromNib]; + } + return self; +} + +- (instancetype)initWithCoder:(NSCoder *)aDecoder +{ + self = [super initWithCoder:aDecoder]; + if (self) + { + [self configureFromNib]; + } + return self; +} + +- (void)configureFromNib +{ + NSBundle *bundle = [NSBundle mxk_bundleForClass:self.class]; + [bundle loadNibNamed:NSStringFromClass(self.class) owner:self options:nil]; + [self customizeViewRendering]; + + self.hudView.frame = self.bounds; + + self.clipsToBounds = YES; + self.layer.cornerRadius = 10.0; + + [self addSubview:self.hudView]; +} + +- (void)customizeViewRendering +{ + [super customizeViewRendering]; + + self.pieChartView.backgroundColor = [UIColor clearColor]; + self.pieChartView.progressColor = [UIColor whiteColor]; + self.pieChartView.unprogressColor = [UIColor clearColor]; + self.pieChartView.tintColor = [UIColor whiteColor]; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + [self.pieChartView.layer setCornerRadius:self.pieChartView.frame.size.width / 2]; +} + +#pragma mark - Public + ++ (MXKPieChartHUD *)showLoadingHudOnView:(UIView *)view WithMessage:(NSString *)message +{ + MXKPieChartHUD *hud = [[MXKPieChartHUD alloc] init]; + [view addSubview:hud]; + + hud.translatesAutoresizingMaskIntoConstraints = NO; + + NSLayoutConstraint *centerXConstraint = [NSLayoutConstraint constraintWithItem:hud attribute:NSLayoutAttributeCenterX relatedBy:NSLayoutRelationEqual toItem:view attribute:NSLayoutAttributeCenterX multiplier:1 constant:0]; + NSLayoutConstraint *centerYConstraint = [NSLayoutConstraint constraintWithItem:hud attribute:NSLayoutAttributeCenterY relatedBy:NSLayoutRelationEqual toItem:view attribute:NSLayoutAttributeCenterY multiplier:1 constant:0]; + NSLayoutConstraint *widthConstraint = [NSLayoutConstraint constraintWithItem:hud attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1 constant:160]; + NSLayoutConstraint *heightConstraint = [NSLayoutConstraint constraintWithItem:hud attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1 constant:105]; + [NSLayoutConstraint activateConstraints:@[centerXConstraint, centerYConstraint, widthConstraint, heightConstraint]]; + + hud.titleLabel.text = message; + + return hud; +} + +- (void)setProgress:(CGFloat)progress +{ + [UIView animateWithDuration:0.2 animations:^{ + [self.pieChartView setProgress:progress]; + }]; + +} + + + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.xib b/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.xib new file mode 100644 index 000000000..6ceac397b --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKPieChartHUD.xib @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKPieChartView.h b/Riot/Modules/MatrixKit/Views/MXKPieChartView.h new file mode 100644 index 000000000..7eeb2d2fb --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKPieChartView.h @@ -0,0 +1,35 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +@interface MXKPieChartView : MXKView + +/** + The current progress level in [0, 1] range. + The pie chart is automatically hidden if progress <= 0. + It is shown for other progress values. + */ +@property (nonatomic) CGFloat progress; + +@property (strong, nonatomic) UIColor* progressColor; +@property (strong, nonatomic) UIColor* unprogressColor; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKPieChartView.m b/Riot/Modules/MatrixKit/Views/MXKPieChartView.m new file mode 100644 index 000000000..fb2d56e2b --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKPieChartView.m @@ -0,0 +1,139 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKPieChartView.h" + +@interface MXKPieChartView () +{ + // graphical items + CAShapeLayer* backgroundContainerLayer; + CAShapeLayer* powerContainerLayer; +} +@end + +@implementation MXKPieChartView + +- (void)setProgress:(CGFloat)progress +{ + // Consider only positive progress value + if (progress <= 0) + { + _progress = 0; + self.hidden = YES; + } + else + { + // Ensure that the progress value does not excceed 1.0 + _progress = MIN(progress, 1.0); + self.hidden = NO; + } + + // defines the view settings + CGFloat radius = self.frame.size.width / 2; + + // draw a rounded view + [self.layer setCornerRadius:radius]; + self.backgroundColor = [UIColor clearColor]; + + // draw the pie + CALayer* layer = [self layer]; + + // remove any previous drawn layer + if (powerContainerLayer) + { + [powerContainerLayer removeFromSuperlayer]; + powerContainerLayer = nil; + } + + // define default colors + if (!_progressColor) + { + _progressColor = [UIColor redColor]; + } + + if (!_unprogressColor) + { + _unprogressColor = [UIColor lightGrayColor]; + } + + // the background cell color is hidden the cell is selected. + // so put in grey the cell background triggers a weird display (the background grey is hidden but not the red part). + // add an other layer fixes the UX. + if (!backgroundContainerLayer) + { + backgroundContainerLayer = [CAShapeLayer layer]; + [backgroundContainerLayer setZPosition:0]; + [backgroundContainerLayer setStrokeColor:NULL]; + backgroundContainerLayer.fillColor = _unprogressColor.CGColor; + + // build the path + CGMutablePathRef path = CGPathCreateMutable(); + CGPathMoveToPoint(path, NULL, radius, radius); + + CGPathAddArc(path, NULL, radius, radius, radius, 0 , 2 * M_PI, 0); + CGPathCloseSubpath(path); + + [backgroundContainerLayer setPath:path]; + CFRelease(path); + + // add the sub layer + [layer addSublayer:backgroundContainerLayer]; + } + + if (_progress) + { + // create the filled layer + powerContainerLayer = [CAShapeLayer layer]; + [powerContainerLayer setZPosition:0]; + [powerContainerLayer setStrokeColor:NULL]; + + // power level is drawn in red + powerContainerLayer.fillColor = _progressColor.CGColor; + + // build the path + CGMutablePathRef path = CGPathCreateMutable(); + CGPathMoveToPoint(path, NULL, radius, radius); + + CGPathAddArc(path, NULL, radius, radius, radius, -M_PI / 2, (_progress * 2 * M_PI) - (M_PI / 2), 0); + CGPathCloseSubpath(path); + + [powerContainerLayer setPath:path]; + CFRelease(path); + + // add the sub layer + [layer addSublayer:powerContainerLayer]; + } +} + +- (void)setProgressColor:(UIColor *)progressColor +{ + _progressColor = progressColor; + self.progress = _progress; +} + +- (void)setUnprogressColor:(UIColor *)unprogressColor +{ + _unprogressColor = unprogressColor; + + if (backgroundContainerLayer) + { + [backgroundContainerLayer removeFromSuperlayer]; + backgroundContainerLayer = nil; + } + self.progress = _progress; +} + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/MXKReceiptSendersContainer.h b/Riot/Modules/MatrixKit/Views/MXKReceiptSendersContainer.h new file mode 100644 index 000000000..26a0c19e7 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKReceiptSendersContainer.h @@ -0,0 +1,99 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +typedef NS_ENUM(NSInteger, ReadReceiptsAlignment) +{ + /** + The latest receipt is displayed on left + */ + ReadReceiptAlignmentLeft = 0, + + /** + The latest receipt is displayed on right + */ + ReadReceiptAlignmentRight = 1, +}; + +/** + `MXKReceiptSendersContainer` is a view dedicated to display receipt senders by using their avatars. + + This container handles automatically the number of visible avatars. A label is added when avatars are not all visible (see 'moreLabel' property). + */ +@interface MXKReceiptSendersContainer : MXKView + +/** + The maximum number of avatars displayed in the container. 3 by default. + */ +@property (nonatomic) NSInteger maxDisplayedAvatars; + +/** + The space between avatars. 2.0 points by default. + */ +@property (nonatomic) CGFloat avatarMargin; + +/** + The label added beside avatars when avatars are not all visible. + */ +@property (nonatomic) UILabel* moreLabel; + +/** + The more label text color (If set to nil `moreLabel.textColor` use `UIColor.blackColor` as default color). + */ +@property (nonatomic) UIColor* moreLabelTextColor; + +/* + The read receipt objects for details required in the details view + */ +@property (nonatomic) NSArray *readReceipts; + +/* + The array of the room members that will be displayed in the container + */ +@property (nonatomic, readonly) NSArray *roomMembers; + +/* + The placeholders of the room members that will be shown if the users don't have avatars + */ +@property (nonatomic, readonly) NSArray *placeholders; + +/** + Initializes an `MXKReceiptSendersContainer` object with a frame and a media manager. + + This is the designated initializer. + + @param frame the container frame. Note that avatar will be displayed in full height in this container. + @param mediaManager the media manager used to download the matrix user's avatar. + @return The newly-initialized MXKReceiptSendersContainer instance + */ +- (instancetype)initWithFrame:(CGRect)frame andMediaManager:(MXMediaManager*)mediaManager; + +/** + Refresh the container content by using the provided room members. + + @param roomMembers list of room members sorted from the latest receipt to the oldest receipt. + @param placeHolders list of placeholders, one by room member. Used when url is nil, or during avatar download. + @param alignment (see ReadReceiptsAlignment). + */ +- (void)refreshReceiptSenders:(NSArray*)roomMembers withPlaceHolders:(NSArray*)placeHolders andAlignment:(ReadReceiptsAlignment)alignment; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKReceiptSendersContainer.m b/Riot/Modules/MatrixKit/Views/MXKReceiptSendersContainer.m new file mode 100644 index 000000000..05a8acb81 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKReceiptSendersContainer.m @@ -0,0 +1,174 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKReceiptSendersContainer.h" + +#import "MXKImageView.h" + +static UIColor* kMoreLabelDefaultcolor; + +@interface MXKReceiptSendersContainer () + +@property (nonatomic, readwrite) NSArray *roomMembers; +@property (nonatomic, readwrite) NSArray *placeholders; +@property (nonatomic) MXMediaManager *mediaManager; + +@end + + +@implementation MXKReceiptSendersContainer + ++ (void)initialize +{ + if (self == [MXKReceiptSendersContainer class]) + { + kMoreLabelDefaultcolor = [UIColor blackColor]; + } +} + +- (instancetype)initWithFrame:(CGRect)frame andMediaManager:(MXMediaManager*)mediaManager +{ + self = [super initWithFrame:frame]; + if (self) + { + _mediaManager = mediaManager; + _maxDisplayedAvatars = 3; + _avatarMargin = 2.0; + _moreLabel = nil; + _moreLabelTextColor = kMoreLabelDefaultcolor; + } + return self; +} + +- (void)refreshReceiptSenders:(NSArray*)roomMembers withPlaceHolders:(NSArray*)placeHolders andAlignment:(ReadReceiptsAlignment)alignment +{ + // Store the room members and placeholders for showing in the details view controller + self.roomMembers = roomMembers; + self.placeholders = placeHolders; + + // Remove all previous content + for (UIView* view in self.subviews) + { + [view removeFromSuperview]; + } + if (_moreLabel) + { + [_moreLabel removeFromSuperview]; + _moreLabel = nil; + } + + CGRect globalFrame = self.frame; + CGFloat side = globalFrame.size.height; + CGFloat defaultMoreLabelWidth = side < 20 ? 20 : side; + unsigned long count; + unsigned long maxDisplayableItems = (int)((globalFrame.size.width - defaultMoreLabelWidth - _avatarMargin) / (side + _avatarMargin)); + + maxDisplayableItems = MIN(maxDisplayableItems, _maxDisplayedAvatars); + count = MIN(roomMembers.count, maxDisplayableItems); + + int index; + + CGFloat xOff = 0; + + if (alignment == ReadReceiptAlignmentRight) + { + xOff = globalFrame.size.width - (side + _avatarMargin); + } + + for (index = 0; index < count; index++) + { + MXRoomMember *roomMember = [roomMembers objectAtIndex:index]; + UIImage *preview = index < placeHolders.count ? placeHolders[index] : nil; + + MXKImageView *imageView = [[MXKImageView alloc] initWithFrame:CGRectMake(xOff, 0, side, side)]; + imageView.defaultBackgroundColor = [UIColor clearColor]; + imageView.autoresizingMask = UIViewAutoresizingNone; + + if (alignment == ReadReceiptAlignmentRight) + { + xOff -= side + _avatarMargin; + } + else + { + xOff += side + _avatarMargin; + } + + [self addSubview:imageView]; + imageView.enableInMemoryCache = YES; + + [imageView setImageURI:roomMember.avatarUrl + withType:nil + andImageOrientation:UIImageOrientationUp + toFitViewSize:CGSizeMake(side, side) + withMethod:MXThumbnailingMethodCrop + previewImage:preview + mediaManager:_mediaManager]; + + [imageView.layer setCornerRadius:imageView.frame.size.width / 2]; + imageView.clipsToBounds = YES; + } + + // Check whether there are more than expected read receipts + if (roomMembers.count > maxDisplayableItems) + { + // Add a more indicator + + // In case of right alignment, adjust the current position by considering the default label width + if (alignment == ReadReceiptAlignmentRight && side < defaultMoreLabelWidth) + { + xOff -= (defaultMoreLabelWidth - side); + } + + _moreLabel = [[UILabel alloc] initWithFrame:CGRectMake(xOff, 0, defaultMoreLabelWidth, side)]; + _moreLabel.text = [NSString stringWithFormat:(alignment == ReadReceiptAlignmentRight) ? @"%tu+" : @"+%tu", roomMembers.count - maxDisplayableItems]; + _moreLabel.font = [UIFont systemFontOfSize:11]; + _moreLabel.adjustsFontSizeToFitWidth = YES; + _moreLabel.minimumScaleFactor = 0.6; + + // In case of right alignment, adjust the horizontal position according to the actual label width + if (alignment == ReadReceiptAlignmentRight) + { + [_moreLabel sizeToFit]; + CGRect frame = _moreLabel.frame; + if (frame.size.width < defaultMoreLabelWidth) + { + frame.origin.x += (defaultMoreLabelWidth - frame.size.width); + _moreLabel.frame = frame; + } + } + + _moreLabel.textColor = self.moreLabelTextColor ?: kMoreLabelDefaultcolor; + [self addSubview:_moreLabel]; + } +} + +- (void)dealloc +{ + NSArray* subviews = self.subviews; + for (UIView* view in subviews) + { + [view removeFromSuperview]; + } + + if (_moreLabel) + { + [_moreLabel removeFromSuperview]; + _moreLabel = nil; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKRoomActivitiesView.h b/Riot/Modules/MatrixKit/Views/MXKRoomActivitiesView.h new file mode 100644 index 000000000..e6a73e00e --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKRoomActivitiesView.h @@ -0,0 +1,71 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +@protocol MXKRoomActivitiesViewDelegate; + +/** + Customize UIView to display some extra info above the RoomInputToolBar + */ +@interface MXKRoomActivitiesView : MXKView + +@property (nonatomic) CGFloat height; + +@property (weak, nonatomic) id delegate; + +/** + Returns the `UINib` object initialized for a `MXKRoomActivitiesView`. + + @return The initialized `UINib` object or `nil` if there were errors during initialization + or the nib file could not be located. + + @discussion You may override this method to provide a customized nib. If you do, + you should also override `roomActivitiesView` to return your + view controller loaded from your custom nib. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRoomActivitiesView-inherited` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRoomActivitiesView-inherited` object if successful, `nil` otherwise. + */ ++ (instancetype)roomActivitiesView; + +/** + Dispose any resources and listener. + */ +- (void)destroy; + +@end + +@protocol MXKRoomActivitiesViewDelegate + +/** + Called when the activities view height changes. + + @param roomActivitiesView the MXKRoomActivitiesView instance. + @param oldHeight its previous height. + @param newHeight its new height. + */ +- (void)didChangeHeight:(MXKRoomActivitiesView*)roomActivitiesView oldHeight:(CGFloat)oldHeight newHeight:(CGFloat)newHeight; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKRoomActivitiesView.m b/Riot/Modules/MatrixKit/Views/MXKRoomActivitiesView.m new file mode 100644 index 000000000..98991cdb4 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKRoomActivitiesView.m @@ -0,0 +1,58 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomActivitiesView.h" + +@implementation MXKRoomActivitiesView + ++ (UINib *)nib +{ + // No 'MXKRoomActivitiesView.xib' has been defined yet + return nil; +} + ++ (instancetype)roomActivitiesView +{ + id instance = nil; + + if ([[self class] nib]) + { + @try { + instance = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + } + @catch (NSException *exception) { + } + } + + if (!instance) + { + instance = [[self alloc] initWithFrame:CGRectZero]; + } + + return instance; +} + +- (void)destroy +{ + _delegate = nil; +} + +- (CGFloat)height +{ + return self.frame.size.height; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.h b/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.h new file mode 100644 index 000000000..f269dbe87 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.h @@ -0,0 +1,140 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +@class MXKRoomCreationView; +@protocol MXKRoomCreationViewDelegate + +/** + Tells the delegate that an alert must be presented. + + @param creationView the view. + @param alertController the alert to present. + */ +- (void)roomCreationView:(MXKRoomCreationView*)creationView presentAlertController:(UIAlertController*)alertController; + +/** + Tells the delegate to open the room with the provided identifier in a specific matrix session. + + @param creationView the view. + @param roomId the room identifier. + @param mxSession the matrix session in which the room should be available. + */ +- (void)roomCreationView:(MXKRoomCreationView*)creationView showRoom:(NSString*)roomId withMatrixSession:(MXSession*)mxSession; +@end + +/** + MXKRoomCreationView instance is a cell dedicated to room creation. + Add this view in your app to offer room creation option. + */ +@interface MXKRoomCreationView : MXKView { +@protected + UIView *inputAccessoryView; +} + +/** + * Returns the `UINib` object initialized for the tool bar view. + * + * @return The initialized `UINib` object or `nil` if there were errors during + * initialization or the nib file could not be located. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRoomCreationView-inherited` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRoomCreationView-inherited` object if successful, `nil` otherwise. + */ ++ (instancetype)roomCreationView; + +/** + The delegate. + */ +@property (nonatomic, weak) id delegate; + +/** + Hide room name field (NO by default). + Set YES this property to disable room name edition and hide the related items. + */ +@property (nonatomic, getter=isRoomNameFieldHidden) BOOL roomNameFieldHidden; + +/** + Hide room alias field (NO by default). + Set YES this property to disable room alias edition and hide the related items. + */ +@property (nonatomic, getter=isRoomAliasFieldHidden) BOOL roomAliasFieldHidden; + +/** + Hide room participants field (NO by default). + Set YES this property to disable room participants edition and hide the related items. + */ +@property (nonatomic, getter=isParticipantsFieldHidden) BOOL participantsFieldHidden; + +/** + The view height which takes into account potential hidden fields + */ +@property (nonatomic) CGFloat actualFrameHeight; + +/** + */ +@property (nonatomic) NSArray* mxSessions; + +/** + The custom accessory view associated to all text field of this 'MXKRoomCreationView' instance. + This view is actually used to retrieve the keyboard view. Indeed the keyboard view is the superview of + this accessory view when a text field become the first responder. + */ +@property (readonly) UIView *inputAccessoryView; + +/** + UI items + */ +@property (weak, nonatomic) IBOutlet UILabel *roomNameLabel; +@property (weak, nonatomic) IBOutlet UILabel *roomAliasLabel; +@property (weak, nonatomic) IBOutlet UILabel *participantsLabel; +@property (weak, nonatomic) IBOutlet UITextField *roomNameTextField; +@property (weak, nonatomic) IBOutlet UITextField *roomAliasTextField; +@property (weak, nonatomic) IBOutlet UITextField *participantsTextField; +@property (weak, nonatomic) IBOutlet UISegmentedControl *roomVisibilityControl; +@property (weak, nonatomic) IBOutlet UIButton *createRoomBtn; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *roomNameFieldTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *roomAliasFieldTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *participantsFieldTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *textFieldLeftConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *createRoomBtnTopConstraint; + +/** + Action registered to handle text field editing change (UIControlEventEditingChanged). + */ +- (IBAction)textFieldEditingChanged:(id)sender; + +/** + Force dismiss keyboard. + */ +- (void)dismissKeyboard; + +/** + Dispose any resources and listener. + */ +- (void)destroy; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.m b/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.m new file mode 100644 index 000000000..d132e8e00 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.m @@ -0,0 +1,578 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomCreationView.h" + +#import "MXKConstants.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@interface MXKRoomCreationView () +{ + UIAlertController *mxSessionPicker; + + // Array of homeserver suffix (NSString instance) + NSMutableArray *homeServerSuffixArray; +} + +@end + +@implementation MXKRoomCreationView +@synthesize inputAccessoryView; + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomCreationView class]) + bundle:[NSBundle bundleForClass:[MXKRoomCreationView class]]]; +} + ++ (instancetype)roomCreationView +{ + if ([[self class] nib]) + { + return [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + } + else + { + return [[self alloc] init]; + } +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + // Add observer to keep align text fields + [_roomNameLabel addObserver:self forKeyPath:@"text" options:0 context:nil]; + [_roomAliasLabel addObserver:self forKeyPath:@"text" options:0 context:nil]; + [_participantsLabel addObserver:self forKeyPath:@"text" options:0 context:nil]; + [self alignTextFields]; + + // Finalize setup + [self setTranslatesAutoresizingMaskIntoConstraints: NO]; + + // Add an accessory view to the text views in order to retrieve keyboard view. + inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + _roomNameTextField.inputAccessoryView = inputAccessoryView; + _roomAliasTextField.inputAccessoryView = inputAccessoryView; + _participantsTextField.inputAccessoryView = inputAccessoryView; + + // Localize strings + _roomNameLabel.text = [MatrixKitL10n roomCreationNameTitle]; + _roomNameTextField.placeholder = [MatrixKitL10n roomCreationNamePlaceholder]; + _roomAliasLabel.text = [MatrixKitL10n roomCreationAliasTitle]; + _roomAliasTextField.placeholder = [MatrixKitL10n roomCreationAliasPlaceholder]; + _participantsLabel.text = [MatrixKitL10n roomCreationParticipantsTitle]; + _participantsTextField.placeholder = [MatrixKitL10n roomCreationParticipantsPlaceholder]; + + [_roomVisibilityControl setTitle:[MatrixKitL10n public] forSegmentAtIndex:0]; + [_roomVisibilityControl setTitle:[MatrixKitL10n private] forSegmentAtIndex:1]; + + [_createRoomBtn setTitle:[MatrixKitL10n createRoom] forState:UIControlStateNormal]; + [_createRoomBtn setTitle:[MatrixKitL10n createRoom] forState:UIControlStateHighlighted]; +} + +- (void)dealloc +{ + [self destroy]; + + inputAccessoryView = nil; +} + +- (void)setRoomNameFieldHidden:(BOOL)roomNameFieldHidden +{ + _roomNameFieldHidden = _roomNameTextField.hidden = _roomNameLabel.hidden = roomNameFieldHidden; + + if (roomNameFieldHidden) + { + _roomAliasFieldTopConstraint.constant -= _roomNameTextField.frame.size.height + 8; + _participantsFieldTopConstraint.constant -= _roomNameTextField.frame.size.height + 8; + _createRoomBtnTopConstraint.constant -= _roomNameTextField.frame.size.height + 8; + } + else + { + _roomAliasFieldTopConstraint.constant += _roomNameTextField.frame.size.height + 8; + _participantsFieldTopConstraint.constant += _roomNameTextField.frame.size.height + 8; + _createRoomBtnTopConstraint.constant += _roomNameTextField.frame.size.height + 8; + } + + [self alignTextFields]; +} + +- (void)setRoomAliasFieldHidden:(BOOL)roomAliasFieldHidden +{ + _roomAliasFieldHidden = _roomAliasTextField.hidden = _roomAliasLabel.hidden = roomAliasFieldHidden; + + if (roomAliasFieldHidden) + { + _participantsFieldTopConstraint.constant -= _roomAliasTextField.frame.size.height + 8; + _createRoomBtnTopConstraint.constant -= _roomAliasTextField.frame.size.height + 8; + } + else + { + _participantsFieldTopConstraint.constant += _roomAliasTextField.frame.size.height + 8; + _createRoomBtnTopConstraint.constant += _roomAliasTextField.frame.size.height + 8; + } + + [self alignTextFields]; +} + +- (void)setParticipantsFieldHidden:(BOOL)participantsFieldHidden +{ + _participantsFieldHidden = _participantsTextField.hidden = _participantsLabel.hidden = participantsFieldHidden; + + if (participantsFieldHidden) + { + _createRoomBtnTopConstraint.constant -= _participantsTextField.frame.size.height + 8; + } + else + { + _createRoomBtnTopConstraint.constant += _participantsTextField.frame.size.height + 8; + } + + [self alignTextFields]; +} + +- (CGFloat)actualFrameHeight +{ + return (_createRoomBtnTopConstraint.constant + _createRoomBtn.frame.size.height + 8); +} + +- (void)setMxSessions:(NSArray *)mxSessions +{ + _mxSessions = mxSessions; + + if (mxSessions.count) + { + homeServerSuffixArray = [NSMutableArray array]; + + for (MXSession *mxSession in mxSessions) + { + NSString *homeserverSuffix = mxSession.matrixRestClient.homeserverSuffix; + if (homeserverSuffix && [homeServerSuffixArray indexOfObject:homeserverSuffix] == NSNotFound) + { + [homeServerSuffixArray addObject:homeserverSuffix]; + } + } + } + else + { + homeServerSuffixArray = nil; + } + + // Update alias placeholder in room creation section + if (homeServerSuffixArray.count == 1) + { + _roomAliasTextField.placeholder = [MatrixKitL10n roomCreationAliasPlaceholderWithHomeserver:homeServerSuffixArray.firstObject]; + } + else + { + _roomAliasTextField.placeholder = [MatrixKitL10n roomCreationAliasPlaceholder]; + } +} + +- (void)dismissKeyboard +{ + // Hide the keyboard + [_roomNameTextField resignFirstResponder]; + [_roomAliasTextField resignFirstResponder]; + [_participantsTextField resignFirstResponder]; +} + +- (void)destroy +{ + self.mxSessions = nil; + + // Remove observers + [_roomNameLabel removeObserver:self forKeyPath:@"text"]; + [_roomAliasLabel removeObserver:self forKeyPath:@"text"]; + [_participantsLabel removeObserver:self forKeyPath:@"text"]; +} + +- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event +{ + // UIView will be "transparent" for touch events if we return NO + return YES; +} + +#pragma mark - Internal methods + +- (void)alignTextFields +{ + CGFloat maxLabelLenght = 0; + + if (!_roomNameLabel.hidden) + { + maxLabelLenght = _roomNameLabel.frame.size.width; + } + if (!_roomAliasLabel.hidden && maxLabelLenght < _roomAliasLabel.frame.size.width) + { + maxLabelLenght = _roomAliasLabel.frame.size.width; + } + if (!_participantsLabel.hidden && maxLabelLenght < _participantsLabel.frame.size.width) + { + maxLabelLenght = _participantsLabel.frame.size.width; + } + + // Update textField left constraint by adding marging + _textFieldLeftConstraint.constant = maxLabelLenght + (2 * 8); + + [self layoutIfNeeded]; +} + +- (NSString*)alias +{ + // Extract alias name from alias text field + NSString *alias = _roomAliasTextField.text; + if (alias.length) + { + // Remove '#' character + alias = [alias substringFromIndex:1]; + + NSString *actualAlias = nil; + for (NSString *homeServerSuffix in homeServerSuffixArray) + { + // Remove homeserver suffix + NSRange range = [alias rangeOfString:homeServerSuffix]; + if (range.location != NSNotFound) + { + actualAlias = [alias stringByReplacingCharactersInRange:range withString:@""]; + break; + } + } + + if (actualAlias) + { + alias = actualAlias; + } + else + { + MXLogDebug(@"[MXKRoomCreationView] Wrong room alias has been set (%@)", _roomAliasTextField.text); + alias = nil; + } + } + + if (! alias.length) + { + alias = nil; + } + + return alias; +} + +- (NSArray*)participantsList +{ + NSMutableArray *participants = [NSMutableArray array]; + + if (_participantsTextField.text.length) + { + NSArray *components = [_participantsTextField.text componentsSeparatedByString:@";"]; + + for (NSString *component in components) + { + // Remove white space from both ends + NSString *user = [component stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (user.length > 1 && [user hasPrefix:@"@"]) + { + [participants addObject:user]; + } + } + } + + if (participants.count == 0) + { + participants = nil; + } + + return participants; +} + +- (void)selectMatrixSession:(void (^)(MXSession *selectedSession))onSelection +{ + if (_mxSessions.count == 1) + { + if (onSelection) + { + onSelection(_mxSessions.firstObject); + } + } + else if (_mxSessions.count > 1) + { + if (mxSessionPicker) + { + [mxSessionPicker dismissViewControllerAnimated:NO completion:nil]; + } + + mxSessionPicker = [UIAlertController alertControllerWithTitle:[MatrixKitL10n selectAccount] message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + + __weak typeof(self) weakSelf = self; + + for(MXSession *mxSession in _mxSessions) + { + [mxSessionPicker addAction:[UIAlertAction actionWithTitle:mxSession.myUser.userId + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->mxSessionPicker = nil; + + if (onSelection) + { + onSelection(mxSession); + } + } + + }]]; + } + + [mxSessionPicker addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->mxSessionPicker = nil; + } + + }]]; + + [mxSessionPicker popoverPresentationController].sourceView = self; + [mxSessionPicker popoverPresentationController].sourceRect = self.bounds; + + if (self.delegate) + { + [self.delegate roomCreationView:self presentAlertController:mxSessionPicker]; + } + } +} + +#pragma mark - UITextField delegate + +- (IBAction)textFieldEditingChanged:(id)sender +{ + // Update Create Room button + NSString *roomName = _roomNameTextField.text; + NSString *roomAlias = _roomAliasTextField.text; + NSString *participants = _participantsTextField.text; + + // Room alias is required to create public room + _createRoomBtn.enabled = ((_roomVisibilityControl.selectedSegmentIndex == 0) ? roomAlias.length : (roomName.length || roomAlias.length || participants.length)); +} + +- (void)textFieldDidBeginEditing:(UITextField *)textField +{ + if (textField == _participantsTextField) + { + if (textField.text.length == 0) + { + textField.text = @"@"; + } + } + else if (textField == _roomAliasTextField) + { + if (textField.text.length == 0) + { + textField.text = @"#"; + } + } +} + +- (void)textFieldDidEndEditing:(UITextField *)textField +{ + if (textField == _roomAliasTextField) + { + if (homeServerSuffixArray.count == 1) + { + // Check whether homeserver suffix should be added + NSRange range = [textField.text rangeOfString:@":"]; + if (range.location == NSNotFound) + { + textField.text = [textField.text stringByAppendingString:homeServerSuffixArray.firstObject]; + } + } + + // Check whether the alias is valid + if (!self.alias) + { + // reset text field + textField.text = nil; + + // Update Create button status + _createRoomBtn.enabled = ((_roomVisibilityControl.selectedSegmentIndex == 1) && (_roomNameTextField.text.length || _participantsTextField.text.length)); + } + } + else if (textField == _participantsTextField) + { + NSArray *participants = self.participantsList; + textField.text = [participants componentsJoinedByString:@"; "]; + + // Update Create button status + _createRoomBtn.enabled = ((_roomVisibilityControl.selectedSegmentIndex == 0) ? _roomAliasTextField.text.length : (_roomNameTextField.text.length || _roomAliasTextField.text.length || _participantsTextField.text.length)); + } +} + +- (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string +{ + // Auto complete participant IDs + if (textField == _participantsTextField) + { + // Add @ if none + if (!textField.text.length || textField.text.length == range.length) + { + if ([string hasPrefix:@"@"] == NO) + { + textField.text = [NSString stringWithFormat:@"@%@",string]; + return NO; + } + } + else if (range.location == textField.text.length) + { + if ([string isEqualToString:@";"]) + { + // Add '@' character + textField.text = [textField.text stringByAppendingString:@"; @"]; + return NO; + } + } + } + else if (textField == _roomAliasTextField) + { + // Add # if none + if (!textField.text.length || textField.text.length == range.length) + { + if ([string hasPrefix:@"#"] == NO) + { + if ([string isEqualToString:@":"] && homeServerSuffixArray.count == 1) + { + textField.text = [NSString stringWithFormat:@"#%@",homeServerSuffixArray.firstObject]; + } + else + { + textField.text = [NSString stringWithFormat:@"#%@",string]; + } + return NO; + } + } + else if (homeServerSuffixArray.count == 1) + { + // Add homeserver automatically when user adds ':' at the end + if (range.location == textField.text.length && [string isEqualToString:@":"]) + { + textField.text = [textField.text stringByAppendingString:homeServerSuffixArray.firstObject]; + return NO; + } + } + } + return YES; +} + +- (BOOL)textFieldShouldReturn:(UITextField*) textField +{ + // "Done" key has been pressed + [textField resignFirstResponder]; + return YES; +} + +#pragma mark - Actions + +- (IBAction)onButtonPressed:(id)sender +{ + [self dismissKeyboard]; + + // Handle multi-sessions here + [self selectMatrixSession:^(MXSession *selectedSession) + { + if (sender == self->_createRoomBtn) + { + // Disable button to prevent multiple request + self->_createRoomBtn.enabled = NO; + + MXRoomCreationParameters *roomCreationParameters = [MXRoomCreationParameters new]; + + roomCreationParameters.name = self->_roomNameTextField.text; + if (!roomCreationParameters.name.length) + { + roomCreationParameters.name = nil; + } + + // Check whether some users must be invited + roomCreationParameters.inviteArray = self.participantsList; + + // Prepare room settings + + if (self->_roomVisibilityControl.selectedSegmentIndex == 0) + { + roomCreationParameters.visibility = kMXRoomDirectoryVisibilityPublic; + } + else + { + roomCreationParameters.visibility = kMXRoomDirectoryVisibilityPrivate; + roomCreationParameters.isDirect = (roomCreationParameters.inviteArray.count == 1); + } + + // Ensure direct chat are created with equal ops on both sides (the trusted_private_chat preset) + roomCreationParameters.preset = (roomCreationParameters.isDirect ? kMXRoomPresetTrustedPrivateChat : nil); + + // Create new room + [selectedSession createRoomWithParameters:roomCreationParameters success:^(MXRoom *room) { + + // Reset text fields + self->_roomNameTextField.text = nil; + self->_roomAliasTextField.text = nil; + self->_participantsTextField.text = nil; + + if (self.delegate) + { + // Open created room + [self.delegate roomCreationView:self showRoom:room.roomId withMatrixSession:selectedSession]; + } + + } failure:^(NSError *error) { + + self->_createRoomBtn.enabled = YES; + + MXLogDebug(@"[MXKRoomCreationView] Create room (%@ %@ (%@)) failed", self->_roomNameTextField.text, self.alias, (self->_roomVisibilityControl.selectedSegmentIndex == 0) ? @"Public":@"Private"); + + // Notify MatrixKit user + NSString *myUserId = selectedSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + + }]; + } + }]; +} + +#pragma mark - KVO + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +{ + // Check whether one label has been updated + if ([@"text" isEqualToString:keyPath] && (object == _roomNameLabel || object == _roomAliasLabel || object == _participantsLabel)) + { + // Update left constraint of the text fields + [object sizeToFit]; + [self alignTextFields]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.xib b/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.xib new file mode 100644 index 000000000..c70ef4790 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKRoomCreationView.xib @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCell.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCell.h new file mode 100644 index 000000000..a05455e31 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCell.h @@ -0,0 +1,82 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKCellData.h" + +/** + List the display box types for the cell subviews. + */ +typedef enum : NSUInteger { + /** + By default the view display box is unchanged. + */ + MXKTableViewCellDisplayBoxTypeDefault, + /** + Define a circle box based on the smaller size of the view frame, some portion of content may be clipped. + */ + MXKTableViewCellDisplayBoxTypeCircle, + /** + Round the corner of the display box of the view. + */ + MXKTableViewCellDisplayBoxTypeRoundedCorner + +} MXKTableViewCellDisplayBoxType; + +/** + 'MXKTableViewCell' class is used to define custom UITableViewCell. + Each 'MXKTableViewCell-inherited' class has its own 'reuseIdentifier'. + */ +@interface MXKTableViewCell : UITableViewCell +{ +@protected + NSString *mxkReuseIdentifier; +} + +/** + Returns the `UINib` object initialized for the cell. + + @return The initialized `UINib` object or `nil` if there were errors during + initialization or the nib file could not be located. + */ ++ (UINib *)nib; + +/** + The default reuseIdentifier of the 'MXKTableViewCell-inherited' class. + */ ++ (NSString*)defaultReuseIdentifier; + +/** + Override [UITableViewCell initWithStyle:reuseIdentifier:] to load cell content from nib file (if any), + and handle reuse identifier. + */ +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier; + +/** + Customize the rendering of the table view cell and its subviews (Do nothing by default). + This method is called when the view is initialized or prepared for reuse. + + Override this method to customize the table view cell at the application level. + */ +- (void)customizeTableViewCellRendering; + +/** + The current cell data displayed by the table view cell + */ +@property (weak, nonatomic, readonly) MXKCellData *mxkCellData; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCell.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCell.m new file mode 100644 index 000000000..081bef6bc --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCell.m @@ -0,0 +1,100 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" +#import "NSBundle+MatrixKit.h" + +@implementation MXKTableViewCell + ++ (UINib *)nib +{ + // Check whether a nib file is available + NSBundle *mainBundle = [NSBundle mxk_bundleForClass:self.class]; + + NSString *path = [mainBundle pathForResource:NSStringFromClass([self class]) ofType:@"nib"]; + if (path) + { + return [UINib nibWithNibName:NSStringFromClass([self class]) bundle:mainBundle]; + } + return nil; +} + ++ (NSString*)defaultReuseIdentifier +{ + return NSStringFromClass([self class]); +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + [self customizeTableViewCellRendering]; +} + +- (void)prepareForReuse +{ + [super prepareForReuse]; + + [self customizeTableViewCellRendering]; +} + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier +{ + // Check whether a xib is defined + if ([[self class] nib]) + { + self = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + } + else + { + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + [self customizeTableViewCellRendering]; + } + + if (reuseIdentifier.length) + { + // The provided identifier is not always conserved in the new created cell. + // This depends how the method [initWithStyle:reuseIdentifier:] is trigerred. + // Trick: we store a copy of this identifier. + mxkReuseIdentifier = reuseIdentifier; + } + else + { + mxkReuseIdentifier = [[self class] defaultReuseIdentifier]; + } + + return self; +} + +- (NSString*)reuseIdentifier +{ + NSString *identifier = super.reuseIdentifier; + + if (!identifier.length) + { + identifier = mxkReuseIdentifier; + } + + return identifier; +} + +- (void)customizeTableViewCellRendering +{ + // Do nothing by default. +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.h new file mode 100644 index 000000000..daa880d3d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.h @@ -0,0 +1,27 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithButton' inherits 'MXKTableViewCell' class. + It constains a 'UIButton' centered in cell content view. + */ +@interface MXKTableViewCellWithButton : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UIButton *mxkButton; + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.m new file mode 100644 index 000000000..25cfc0245 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.m @@ -0,0 +1,34 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithButton.h" + +@implementation MXKTableViewCellWithButton + +- (void)prepareForReuse +{ + [super prepareForReuse]; + + // TODO: Code commented for a quick fix for https://github.com/vector-im/riot-ios/issues/1323 + // This line was a fix for https://github.com/vector-im/riot-ios/issues/1354 + // but it creates a regression that is worse than the bug it fixes. + // self.mxkButton.titleLabel.text = nil; + + [self.mxkButton removeTarget:nil action:nil forControlEvents:UIControlEventAllEvents]; + self.mxkButton.accessibilityIdentifier = nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.xib new file mode 100644 index 000000000..7df7dbb6d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButton.xib @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButtons.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButtons.h new file mode 100644 index 000000000..17f5df957 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButtons.h @@ -0,0 +1,36 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithButtons' inherits 'MXKTableViewCell' class. + It displays several buttons with the system style in a UITableViewCell. All buttons have the same width and they are horizontally aligned. + They are vertically centered. + */ +@interface MXKTableViewCellWithButtons : MXKTableViewCell + +/** + The number of buttons + */ +@property (nonatomic) NSUInteger mxkButtonNumber; + +/** + The current array of buttons + */ +@property (nonatomic) NSArray *mxkButtons; + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButtons.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButtons.m new file mode 100644 index 000000000..ac5db6c4f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithButtons.m @@ -0,0 +1,156 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithButtons.h" + +@interface MXKTableViewCellWithButtons () +{ + NSMutableArray *buttonArray; +} +@end + +@implementation MXKTableViewCellWithButtons + +- (void)setMxkButtonNumber:(NSUInteger)buttonNumber +{ + if (_mxkButtonNumber == buttonNumber) + { + return; + } + + _mxkButtonNumber = buttonNumber; + buttonArray = [NSMutableArray arrayWithCapacity:buttonNumber]; + + CGFloat containerWidth = self.contentView.frame.size.width / buttonNumber; + UIView *previousContainer = nil; + NSLayoutConstraint *leftConstraint; + NSLayoutConstraint *rightConstraint; + NSLayoutConstraint *widthConstraint; + NSLayoutConstraint *topConstraint; + NSLayoutConstraint *bottomConstraint; + + for (NSInteger index = 0; index < buttonNumber; index++) + { + UIView *buttonContainer = [[UIView alloc] initWithFrame:CGRectMake(index * containerWidth, 0, containerWidth, self.contentView.frame.size.height)]; + buttonContainer.backgroundColor = [UIColor clearColor]; + [self.contentView addSubview:buttonContainer]; + + // Add container constraints + buttonContainer.translatesAutoresizingMaskIntoConstraints = NO; + if (!previousContainer) + { + leftConstraint = [NSLayoutConstraint constraintWithItem:buttonContainer + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:self.contentView + attribute:NSLayoutAttributeLeading + multiplier:1 + constant:0]; + widthConstraint = [NSLayoutConstraint constraintWithItem:buttonContainer + attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationEqual + toItem:self.contentView + attribute:NSLayoutAttributeWidth + multiplier:(1.0 / buttonNumber) + constant:0]; + } + else + { + leftConstraint = [NSLayoutConstraint constraintWithItem:buttonContainer + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:previousContainer + attribute:NSLayoutAttributeTrailing + multiplier:1 + constant:0]; + widthConstraint = [NSLayoutConstraint constraintWithItem:buttonContainer + attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationEqual + toItem:previousContainer + attribute:NSLayoutAttributeWidth + multiplier:1 + constant:0]; + } + + topConstraint = [NSLayoutConstraint constraintWithItem:buttonContainer + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.contentView + attribute:NSLayoutAttributeTop + multiplier:1 + constant:0]; + + bottomConstraint = [NSLayoutConstraint constraintWithItem:buttonContainer + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self.contentView + attribute:NSLayoutAttributeBottom + multiplier:1 + constant:0]; + + [NSLayoutConstraint activateConstraints:@[leftConstraint, widthConstraint, topConstraint, bottomConstraint]]; + previousContainer = buttonContainer; + + // Add Button + UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem]; + button.frame = CGRectMake(10, 8, containerWidth - 20, buttonContainer.frame.size.height - 16); + [buttonContainer addSubview:button]; + [buttonArray addObject:button]; + + // Add button constraints + button.translatesAutoresizingMaskIntoConstraints = NO; + leftConstraint = [NSLayoutConstraint constraintWithItem:button + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:buttonContainer + attribute:NSLayoutAttributeLeading + multiplier:1 + constant:10]; + rightConstraint = [NSLayoutConstraint constraintWithItem:button + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:buttonContainer + attribute:NSLayoutAttributeTrailing + multiplier:1 + constant:-10]; + + topConstraint = [NSLayoutConstraint constraintWithItem:button + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:buttonContainer + attribute:NSLayoutAttributeTop + multiplier:1 + constant:8]; + + bottomConstraint = [NSLayoutConstraint constraintWithItem:button + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:buttonContainer + attribute:NSLayoutAttributeBottom + multiplier:1 + constant:-8]; + + [NSLayoutConstraint activateConstraints:@[leftConstraint, rightConstraint, topConstraint, bottomConstraint]]; + } +} + +- (NSArray*)mxkButtons +{ + return [NSArray arrayWithArray:buttonArray]; +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.h new file mode 100644 index 000000000..89076126a --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.h @@ -0,0 +1,38 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithLabelAndButton' inherits 'MXKTableViewCell' class. + It constains a 'UILabel' and a 'UIButton' vertically centered. + */ +@interface MXKTableViewCellWithLabelAndButton : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UILabel *mxkLabel; +@property (strong, nonatomic) IBOutlet UIButton *mxkButton; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonTrailingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonProportionalWidthToMxkButtonConstraint; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.m new file mode 100644 index 000000000..a34c8ba80 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.m @@ -0,0 +1,22 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithLabelAndButton.h" + +@implementation MXKTableViewCellWithLabelAndButton + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.xib new file mode 100644 index 000000000..83f6075d5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndButton.xib @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.h new file mode 100644 index 000000000..c985c0f7d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.h @@ -0,0 +1,44 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithLabelAndImageView' inherits 'MXKTableViewCell' class. + It constains a 'UILabel' and a 'UIImageView' vertically centered. + */ +@interface MXKTableViewCellWithLabelAndImageView : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UILabel *mxkLabel; +@property (strong, nonatomic) IBOutlet UIImageView *mxkImageView; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkImageViewLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkImageViewTrailingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkImageViewWidthConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkImageViewHeightConstraint; + +/** + The image view display box type ('MXKTableViewCellDisplayBoxTypeDefault' by default) + */ +@property (nonatomic) MXKTableViewCellDisplayBoxType mxkImageViewDisplayBoxType; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.m new file mode 100644 index 000000000..5c89806e6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.m @@ -0,0 +1,44 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithLabelAndImageView.h" + +@implementation MXKTableViewCellWithLabelAndImageView + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + if (self.mxkImageViewDisplayBoxType == MXKTableViewCellDisplayBoxTypeCircle) + { + // Round image view for thumbnail + _mxkImageView.layer.cornerRadius = _mxkImageView.frame.size.width / 2; + _mxkImageView.clipsToBounds = YES; + } + else if (self.mxkImageViewDisplayBoxType == MXKTableViewCellDisplayBoxTypeRoundedCorner) + { + _mxkImageView.layer.cornerRadius = 5; + _mxkImageView.clipsToBounds = YES; + } + else + { + _mxkImageView.layer.cornerRadius = 0; + _mxkImageView.clipsToBounds = NO; + } +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.xib new file mode 100644 index 000000000..249476e98 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndImageView.xib @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.h new file mode 100644 index 000000000..6119dfbb0 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.h @@ -0,0 +1,46 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +#import "MXKImageView.h" + +/** + 'MXKTableViewCellWithLabelAndMXKImageView' inherits 'MXKTableViewCell' class. + It constains a 'UILabel' and a 'MXKImageView' vertically centered. + */ +@interface MXKTableViewCellWithLabelAndMXKImageView : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UILabel *mxkLabel; +@property (strong, nonatomic) IBOutlet MXKImageView *mxkImageView; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkImageViewLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkImageViewTrailingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkImageViewWidthConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkImageViewHeightConstraint; + +/** + The MXKImageView display box type ('MXKTableViewCellDisplayBoxTypeDefault' by default) + */ +@property (nonatomic) MXKTableViewCellDisplayBoxType mxkImageViewDisplayBoxType; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.m new file mode 100644 index 000000000..bfffb2d38 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.m @@ -0,0 +1,44 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithLabelAndMXKImageView.h" + +@implementation MXKTableViewCellWithLabelAndMXKImageView + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + if (self.mxkImageViewDisplayBoxType == MXKTableViewCellDisplayBoxTypeCircle) + { + // Round image view for thumbnail + _mxkImageView.layer.cornerRadius = _mxkImageView.frame.size.width / 2; + _mxkImageView.clipsToBounds = YES; + } + else if (self.mxkImageViewDisplayBoxType == MXKTableViewCellDisplayBoxTypeRoundedCorner) + { + _mxkImageView.layer.cornerRadius = 5; + _mxkImageView.clipsToBounds = YES; + } + else + { + _mxkImageView.layer.cornerRadius = 0; + _mxkImageView.clipsToBounds = NO; + } +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.xib new file mode 100644 index 000000000..92d745d55 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndMXKImageView.xib @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.h new file mode 100644 index 000000000..cc10e1bb5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.h @@ -0,0 +1,42 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithLabelAndSlider' inherits 'MXKTableViewCell' class. + It constains a 'UILabel' and a 'UISlider'. + */ +@interface MXKTableViewCellWithLabelAndSlider : MXKTableViewCell + +@property (nonatomic) IBOutlet UILabel *mxkLabel; +@property (nonatomic) IBOutlet UISlider *mxkSlider; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelTrailingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSliderTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSliderLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSliderTrailingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSliderBottomConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSliderHeightConstraint; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.m new file mode 100644 index 000000000..171f8d86c --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.m @@ -0,0 +1,22 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithLabelAndSlider.h" + +@implementation MXKTableViewCellWithLabelAndSlider + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.xib new file mode 100644 index 000000000..4c5f752e1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSlider.xib @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.h new file mode 100644 index 000000000..5dd17657c --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.h @@ -0,0 +1,39 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithLabelAndSubLabel' inherits 'MXKTableViewCell' class. + It constains two 'UILabel' instances. + */ +@interface MXKTableViewCellWithLabelAndSubLabel : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UILabel* mxkLabel; +@property (strong, nonatomic) IBOutlet UILabel* mxkSublabel; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelTrailingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSublabelLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSublabelTrailingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSublabelBottomConstraint; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.m new file mode 100644 index 000000000..6b52aabf0 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithLabelAndSubLabel.h" + +@implementation MXKTableViewCellWithLabelAndSubLabel + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.xib new file mode 100644 index 000000000..725ef0db4 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSubLabel.xib @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.h new file mode 100644 index 000000000..7c8a45b00 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.h @@ -0,0 +1,35 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithLabelAndSwitch' inherits 'MXKTableViewCell' class. + It constains a 'UILabel' and a 'UISwitch' vertically centered. + */ +@interface MXKTableViewCellWithLabelAndSwitch : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UILabel *mxkLabel; +@property (strong, nonatomic) IBOutlet UISwitch *mxkSwitch; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSwitchLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSwitchTrailingConstraint; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.m new file mode 100644 index 000000000..a321d8385 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.m @@ -0,0 +1,22 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithLabelAndSwitch.h" + +@implementation MXKTableViewCellWithLabelAndSwitch + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.xib new file mode 100644 index 000000000..1bf5c96de --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndSwitch.xib @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.h new file mode 100644 index 000000000..516f0dc61 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.h @@ -0,0 +1,47 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithLabelAndTextField' inherits 'MXKTableViewCell' class. + It constains a 'UILabel' and a 'UITextField' vertically centered. + */ +@interface MXKTableViewCellWithLabelAndTextField : MXKTableViewCell +{ +@protected + UIView *inputAccessoryView; +} + +@property (strong, nonatomic) IBOutlet UILabel *mxkLabel; +@property (strong, nonatomic) IBOutlet UITextField *mxkTextField; + +/** + The custom accessory view associated with the text field. This view is + actually used to retrieve the keyboard view. Indeed the keyboard view is the superview of + the accessory view when the text field become the first responder. + */ +@property (readonly) UIView *inputAccessoryView; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelMinWidthConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextFieldLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextFieldTrailingConstraint; + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.m new file mode 100644 index 000000000..23911441f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.m @@ -0,0 +1,58 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithLabelAndTextField.h" + +@implementation MXKTableViewCellWithLabelAndTextField +@synthesize inputAccessoryView; + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier +{ + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + if (self) + { + // Add an accessory view to the text view in order to retrieve keyboard view. + inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + _mxkTextField.inputAccessoryView = inputAccessoryView; + } + + return self; +} + +- (void)dealloc +{ + inputAccessoryView = nil; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + // Fix the minimum width of the label in order to keep it visible when the textfield width is increasing. + [_mxkLabel sizeToFit]; + _mxkLabelMinWidthConstraint.constant = _mxkLabel.frame.size.width; +} + +#pragma mark - UITextField delegate + +- (BOOL)textFieldShouldReturn:(UITextField*)textField +{ + // "Done" key has been pressed + [self.mxkTextField resignFirstResponder]; + return YES; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.xib new file mode 100644 index 000000000..285424726 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelAndTextField.xib @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.h new file mode 100644 index 000000000..332c37013 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.h @@ -0,0 +1,58 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithLabelTextFieldAndButton' inherits 'MXKTableViewCell' class. + It constains a 'UILabel' on the first line. The second line is composed with a 'UITextField' and a 'UIButton' + vertically aligned. + */ +@interface MXKTableViewCellWithLabelTextFieldAndButton : MXKTableViewCell +{ +@protected + UIView *inputAccessoryView; +} + +@property (strong, nonatomic) IBOutlet UILabel *mxkLabel; +@property (strong, nonatomic) IBOutlet UITextField *mxkTextField; +@property (strong, nonatomic) IBOutlet UIButton *mxkButton; + +/** + The custom accessory view associated with the text field. This view is + actually used to retrieve the keyboard view. Indeed the keyboard view is the superview of + the accessory view when the text field become the first responder. + */ +@property (readonly) UIView *inputAccessoryView; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelTrailingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextFieldLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextFieldTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextFieldBottomConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonTrailingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonMinWidthConstraint; + +- (IBAction)textFieldEditingChanged:(id)sender; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.m new file mode 100644 index 000000000..6a0d9f7fd --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.m @@ -0,0 +1,57 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithLabelTextFieldAndButton.h" + +@implementation MXKTableViewCellWithLabelTextFieldAndButton +@synthesize inputAccessoryView; + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier +{ + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + if (self) + { + // Add an accessory view to the text view in order to retrieve keyboard view. + inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + _mxkTextField.inputAccessoryView = inputAccessoryView; + } + + return self; +} + +- (void)dealloc +{ + inputAccessoryView = nil; +} + +#pragma mark - UITextField delegate + +- (BOOL)textFieldShouldReturn:(UITextField*)textField +{ + // "Done" key has been pressed + [self.mxkTextField resignFirstResponder]; + return YES; +} + +#pragma mark - Action + +- (IBAction)textFieldEditingChanged:(id)sender +{ + self.mxkButton.enabled = (self.mxkTextField.text.length != 0); +} + + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.xib new file mode 100644 index 000000000..0042c591f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithLabelTextFieldAndButton.xib @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.h new file mode 100644 index 000000000..d9224a404 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.h @@ -0,0 +1,35 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithPicker' inherits 'MXKTableViewCell' class. + It constains a 'UIPickerView' vertically centered. + */ +@interface MXKTableViewCellWithPicker : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UIPickerView* mxkPickerView; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkPickerViewLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkPickerViewTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkPickerViewBottomConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkPickerViewTrailingConstraint; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.m new file mode 100644 index 000000000..79800813d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.m @@ -0,0 +1,22 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithPicker.h" + +@implementation MXKTableViewCellWithPicker + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.xib new file mode 100644 index 000000000..081fe0e3a --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithPicker.xib @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.h new file mode 100644 index 000000000..f7e399637 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.h @@ -0,0 +1,35 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithSearchBar' inherits 'MXKTableViewCell' class. + It constains a 'UISearchBar' vertically centered. + */ +@interface MXKTableViewCellWithSearchBar : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UISearchBar *mxkSearchBar; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSearchBarLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSearchBarTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSearchBarBottomConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkSearchBarTrailingConstraint; + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.m new file mode 100644 index 000000000..24fc295eb --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.m @@ -0,0 +1,22 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithSearchBar.h" + +@implementation MXKTableViewCellWithSearchBar + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.xib new file mode 100644 index 000000000..d068674c5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithSearchBar.xib @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.h new file mode 100644 index 000000000..3d9d195fe --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.h @@ -0,0 +1,50 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithTextFieldAndButton' inherits 'MXKTableViewCell' class. + It constains a 'UITextField' and a 'UIButton' vertically centered. + */ +@interface MXKTableViewCellWithTextFieldAndButton : MXKTableViewCell +{ +@protected + UIView *inputAccessoryView; +} + +@property (strong, nonatomic) IBOutlet UITextField *mxkTextField; +@property (strong, nonatomic) IBOutlet UIButton *mxkButton; + +/** + The custom accessory view associated with the text field. This view is + actually used to retrieve the keyboard view. Indeed the keyboard view is the superview of + the accessory view when the text field become the first responder. + */ +@property (readonly) UIView *inputAccessoryView; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextFieldLeadingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonTrailingConstraint; + +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkButtonMinWidthConstraint; + +- (IBAction)textFieldEditingChanged:(id)sender; +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.m new file mode 100644 index 000000000..29ef52a56 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.m @@ -0,0 +1,57 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithTextFieldAndButton.h" + +@implementation MXKTableViewCellWithTextFieldAndButton +@synthesize inputAccessoryView; + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier +{ + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + if (self) + { + // Add an accessory view to the text view in order to retrieve keyboard view. + inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + _mxkTextField.inputAccessoryView = inputAccessoryView; + } + + return self; +} + +- (void)dealloc +{ + inputAccessoryView = nil; +} + +#pragma mark - UITextField delegate + +- (BOOL)textFieldShouldReturn:(UITextField*)textField +{ + // "Done" key has been pressed + [self.mxkTextField resignFirstResponder]; + return YES; +} + +#pragma mark - Action + +- (IBAction)textFieldEditingChanged:(id)sender +{ + self.mxkButton.enabled = (self.mxkTextField.text.length != 0); +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.xib new file mode 100644 index 000000000..ca70d76e6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextFieldAndButton.xib @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.h b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.h new file mode 100644 index 000000000..e3ef4f366 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.h @@ -0,0 +1,35 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +/** + 'MXKTableViewCellWithTextView' inherits 'MXKTableViewCell' class. + It constains a 'UITextView' vertically centered. + */ +@interface MXKTableViewCellWithTextView : MXKTableViewCell + +@property (strong, nonatomic) IBOutlet UITextView *mxkTextView; + +/** + Leading/Trailing constraints define here spacing to nearest neighbor (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextViewLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextViewTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextViewBottomConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkTextViewTrailingConstraint; + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.m b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.m new file mode 100644 index 000000000..5f306b073 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.m @@ -0,0 +1,22 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCellWithTextView.h" + +@implementation MXKTableViewCellWithTextView + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.xib new file mode 100644 index 000000000..6095702d8 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewCell/MXKTableViewCellWithTextView.xib @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterView.h b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterView.h new file mode 100644 index 000000000..d19ce5ce6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterView.h @@ -0,0 +1,50 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +/** + 'MXKTableViewHeaderFooterView' class is used to define custom UITableViewHeaderFooterView (Either the header or footer for a section). + Each 'MXKTableViewHeaderFooterView-inherited' class has its own 'reuseIdentifier'. + */ +@interface MXKTableViewHeaderFooterView : UITableViewHeaderFooterView +{ +@protected + NSString *mxkReuseIdentifier; +} + +/** + Returns the `UINib` object initialized for the header/footer view. + + @return The initialized `UINib` object or `nil` if there were errors during + initialization or the nib file could not be located. + */ ++ (UINib *)nib; + +/** + The default reuseIdentifier of the 'MXKTableViewHeaderFooterView-inherited' class. + */ ++ (NSString*)defaultReuseIdentifier; + +/** + Customize the rendering of the header/footer view and its subviews (Do nothing by default). + This method is called when the view is initialized or prepared for reuse. + + Override this method to customize the view at the application level. + */ +- (void)customizeTableViewHeaderFooterViewRendering; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterView.m b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterView.m new file mode 100644 index 000000000..c1b128be0 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterView.m @@ -0,0 +1,100 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewHeaderFooterView.h" +#import "NSBundle+MatrixKit.h" + +@implementation MXKTableViewHeaderFooterView + ++ (UINib *)nib +{ + // Check whether a nib file is available + NSBundle *mainBundle = [NSBundle mxk_bundleForClass:self.class]; + + NSString *path = [mainBundle pathForResource:NSStringFromClass([self class]) ofType:@"nib"]; + if (path) + { + return [UINib nibWithNibName:NSStringFromClass([self class]) bundle:mainBundle]; + } + return nil; +} + ++ (NSString*)defaultReuseIdentifier +{ + return NSStringFromClass([self class]); +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + [self customizeTableViewHeaderFooterViewRendering]; +} + +- (void)prepareForReuse +{ + [super prepareForReuse]; + + [self customizeTableViewHeaderFooterViewRendering]; +} + +- (instancetype)initWithReuseIdentifier:(NSString *)reuseIdentifier +{ + // Check whether a xib is defined + if ([[self class] nib]) + { + self = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + } + else + { + self = [super initWithReuseIdentifier:reuseIdentifier]; + [self customizeTableViewHeaderFooterViewRendering]; + } + + if (reuseIdentifier.length) + { + // The provided identifier is not always conserved in the new created view. + // This depends how the method [initWithStyle:reuseIdentifier:] is trigerred. + // Trick: we store a copy of this identifier. + mxkReuseIdentifier = reuseIdentifier; + } + else + { + mxkReuseIdentifier = [[self class] defaultReuseIdentifier]; + } + + return self; +} + +- (NSString*)reuseIdentifier +{ + NSString *identifier = super.reuseIdentifier; + + if (!identifier.length) + { + identifier = mxkReuseIdentifier; + } + + return identifier; +} + +- (void)customizeTableViewHeaderFooterViewRendering +{ + // Do nothing by default. +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.h b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.h new file mode 100644 index 000000000..c5b5f71c1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.h @@ -0,0 +1,37 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewHeaderFooterView.h" + +/** + 'MXKTableViewHeaderFooterWithLabel' inherits 'MXKTableViewHeaderFooterView' class. + It constains a 'UILabel' vertically centered in which the dymanic fonts is enabled. + The height of this header is dynamically adapted to its content. + */ +@interface MXKTableViewHeaderFooterWithLabel : MXKTableViewHeaderFooterView + +@property (strong, nonatomic) IBOutlet UIView *mxkContentView; +@property (strong, nonatomic) IBOutlet UILabel *mxkLabel; + +/** + The following constraints are defined between the label and the content view (no relative to margin) + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelTrailingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *mxkLabelBottomConstraint; + +@end diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.m b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.m new file mode 100644 index 000000000..302617ad3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.m @@ -0,0 +1,22 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewHeaderFooterWithLabel.h" + +@implementation MXKTableViewHeaderFooterWithLabel + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.xib b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.xib new file mode 100644 index 000000000..1a3c4428c --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKTableViewHeaderFooterView/MXKTableViewHeaderFooterWithLabel.xib @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/MXKView.h b/Riot/Modules/MatrixKit/Views/MXKView.h new file mode 100644 index 000000000..1531a7923 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKView.h @@ -0,0 +1,34 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +/** + `MXKView` is a base class used to add some functionalities to the UIView class. + */ +@interface MXKView : UIView + +/** + Customize the rendering of the view and its subviews (Do nothing by default). + This method is called automatically when the view is initialized or loaded from an Interface Builder archive (or nib file). + + Override this method to customize the view instance at the application level. + It may be used to handle different rendering themes. In this case this method should be called whenever the theme has changed. + */ +- (void)customizeViewRendering; + +@end + diff --git a/Riot/Modules/MatrixKit/Views/MXKView.m b/Riot/Modules/MatrixKit/Views/MXKView.m new file mode 100644 index 000000000..52e43cff8 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/MXKView.m @@ -0,0 +1,53 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKView.h" + +@implementation MXKView + +- (instancetype)initWithFrame:(CGRect)frame +{ + self = [super initWithFrame:frame]; + if (self) + { + [self customizeViewRendering]; + } + return self; +} + +- (instancetype)initWithCoder:(NSCoder *)aDecoder +{ + self = [super initWithCoder:aDecoder]; + if (self) + { + [self customizeViewRendering]; + } + return self; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + [self customizeViewRendering]; +} + +- (void)customizeViewRendering +{ + // Do nothing by default. +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.h b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.h new file mode 100644 index 000000000..20e9f453b --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.h @@ -0,0 +1,67 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKTableViewCell.h" + +/** + MXPushRuleCreationTableViewCell instance is a table view cell used to create a new push rule. + */ +@interface MXKPushRuleCreationTableViewCell : MXKTableViewCell + +/** + The category the created push rule will belongs to (MXPushRuleKindContent by default). + */ +@property (nonatomic) MXPushRuleKind mxPushRuleKind; + +/** + The related matrix session + */ +@property (nonatomic) MXSession* mxSession; + +/** + The graphics items + */ +@property (strong, nonatomic) IBOutlet UITextField* inputTextField; + +@property (unsafe_unretained, nonatomic) IBOutlet UISegmentedControl *actionSegmentedControl; +@property (unsafe_unretained, nonatomic) IBOutlet UISwitch *soundSwitch; +@property (unsafe_unretained, nonatomic) IBOutlet UISwitch *highlightSwitch; + +@property (strong, nonatomic) IBOutlet UIButton* addButton; + +@property (strong, nonatomic) IBOutlet UIPickerView* roomPicker; +@property (unsafe_unretained, nonatomic) IBOutlet UIButton *roomPickerDoneButton; + +/** + Force dismiss keyboard. + */ +- (void)dismissKeyboard; + +/** + Action registered to handle text field editing change (UIControlEventEditingChanged). + */ +- (IBAction)textFieldEditingChanged:(id)sender; + +/** + Action registered on the following events: + - 'UIControlEventTouchUpInside' for UIButton instances. + - 'UIControlEventValueChanged' for UISwitch and UISegmentedControl instances. + */ +- (IBAction)onButtonPressed:(id)sender; + +@end diff --git a/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.m b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.m new file mode 100644 index 000000000..b0929edc9 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.m @@ -0,0 +1,208 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKPushRuleCreationTableViewCell.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@interface MXKPushRuleCreationTableViewCell () +{ + /** + Snapshot of matrix session rooms used in room picker (in case of MXPushRuleKindRoom) + */ + NSArray* rooms; +} +@end + +@implementation MXKPushRuleCreationTableViewCell + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + self.mxPushRuleKind = MXPushRuleKindContent; +} + +- (void)setMxPushRuleKind:(MXPushRuleKind)mxPushRuleKind +{ + switch (mxPushRuleKind) + { + case MXPushRuleKindContent: + _inputTextField.placeholder = [MatrixKitL10n notificationSettingsWordToMatch]; + _inputTextField.autocorrectionType = UITextAutocorrectionTypeDefault; + break; + case MXPushRuleKindRoom: + _inputTextField.placeholder = [MatrixKitL10n notificationSettingsSelectRoom]; + break; + case MXPushRuleKindSender: + _inputTextField.placeholder = [MatrixKitL10n notificationSettingsSenderHint]; + _inputTextField.autocorrectionType = UITextAutocorrectionTypeNo; + break; + default: + break; + } + + _inputTextField.hidden = NO; + _roomPicker.hidden = YES; + _roomPickerDoneButton.hidden = YES; + + _mxPushRuleKind = mxPushRuleKind; +} + +- (void)dismissKeyboard +{ + [_inputTextField resignFirstResponder]; +} + +#pragma mark - UITextField delegate + +- (IBAction)textFieldEditingChanged:(id)sender +{ + // Update Add Room button + if (_inputTextField.text.length) + { + _addButton.enabled = YES; + } + else + { + _addButton.enabled = NO; + } +} + +- (BOOL)textFieldShouldBeginEditing:(UITextField *)textField +{ + if (textField == _inputTextField && _mxPushRuleKind == MXPushRuleKindRoom) + { + _inputTextField.hidden = YES; + _roomPicker.hidden = NO; + _roomPickerDoneButton.hidden = NO; + return NO; + } + + return YES; +} + +- (void)textFieldDidBeginEditing:(UITextField *)textField +{ + if (textField == _inputTextField && _mxPushRuleKind == MXPushRuleKindSender) + { + if (textField.text.length == 0) + { + textField.text = @"@"; + } + } +} + +- (BOOL)textFieldShouldReturn:(UITextField*) textField +{ + // "Done" key has been pressed + [textField resignFirstResponder]; + return YES; +} + +#pragma mark - Actions + +- (IBAction)onButtonPressed:(id)sender +{ + [self dismissKeyboard]; + + if (sender == _addButton) + { + // Disable button to prevent multiple request + _addButton.enabled = NO; + + if (_mxPushRuleKind == MXPushRuleKindContent) + { + [_mxSession.notificationCenter addContentRule:_inputTextField.text + notify:(_actionSegmentedControl.selectedSegmentIndex == 0) + sound:_soundSwitch.on + highlight:_highlightSwitch.on]; + } + else if (_mxPushRuleKind == MXPushRuleKindRoom) + { + MXRoom* room; + NSInteger row = [_roomPicker selectedRowInComponent:0]; + if ((row >= 0) && (row < rooms.count)) + { + room = [rooms objectAtIndex:row]; + } + + if (room) + { + [_mxSession.notificationCenter addRoomRule:room.roomId + notify:(_actionSegmentedControl.selectedSegmentIndex == 0) + sound:_soundSwitch.on + highlight:_highlightSwitch.on]; + } + + } + else if (_mxPushRuleKind == MXPushRuleKindSender) + { + [_mxSession.notificationCenter addSenderRule:_inputTextField.text + notify:(_actionSegmentedControl.selectedSegmentIndex == 0) + sound:_soundSwitch.on + highlight:_highlightSwitch.on]; + } + + + _inputTextField.text = nil; + } + else if (sender == _roomPickerDoneButton) + { + NSInteger row = [_roomPicker selectedRowInComponent:0]; + // sanity check + if ((row >= 0) && (row < rooms.count)) + { + MXRoom* room = [rooms objectAtIndex:row]; + _inputTextField.text = room.summary.displayname; + _addButton.enabled = YES; + } + + _inputTextField.hidden = NO; + _roomPicker.hidden = YES; + _roomPickerDoneButton.hidden = YES; + } +} + +#pragma mark - UIPickerViewDataSource + +- (NSInteger)numberOfComponentsInPickerView:(UIPickerView *)pickerView +{ + return 1; +} + +- (NSInteger)pickerView:(UIPickerView *)pickerView numberOfRowsInComponent:(NSInteger)component +{ + rooms = [_mxSession.rooms sortedArrayUsingComparator:^NSComparisonResult(MXRoom* firstRoom, MXRoom* secondRoom) { + + // Alphabetic order + return [firstRoom.summary.displayname compare:secondRoom.summary.displayname options:NSCaseInsensitiveSearch]; + }]; + + return rooms.count; +} + +#pragma mark - UIPickerViewDelegate + +- (NSString *)pickerView:(UIPickerView *)pickerView titleForRow:(NSInteger)row forComponent:(NSInteger)component +{ + MXRoom* room = [rooms objectAtIndex:row]; + return room.summary.displayname; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.xib b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.xib new file mode 100644 index 000000000..a3b574312 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleCreationTableViewCell.xib @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.h b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.h new file mode 100644 index 000000000..b569cb7c3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.h @@ -0,0 +1,57 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKTableViewCell.h" + +/** + MKPushRuleTableViewCell instance is a table view cell used to display a notification rule. + */ +@interface MXKPushRuleTableViewCell : MXKTableViewCell + +/** + The displayed rule + */ +@property (nonatomic) MXPushRule* mxPushRule; + +/** + The related matrix session + */ +@property (nonatomic) MXSession* mxSession; + +/** + The graphics items + */ +@property (strong, nonatomic) IBOutlet UIButton* controlButton; + +@property (strong, nonatomic) IBOutlet UIButton* deleteButton; +@property (unsafe_unretained, nonatomic) IBOutlet NSLayoutConstraint *deleteButtonWidthConstraint; + +@property (strong, nonatomic) IBOutlet UILabel* ruleDescription; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *ruleDescriptionBottomConstraint; +@property (unsafe_unretained, nonatomic) IBOutlet NSLayoutConstraint *ruleDescriptionLeftConstraint; + + +@property (strong, nonatomic) IBOutlet UILabel* ruleActions; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *ruleActionsHeightConstraint; + +/** + Action registered on `UIControlEventTouchUpInside` event for both buttons. + */ +- (IBAction)onButtonPressed:(id)sender; + +@end diff --git a/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.m b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.m new file mode 100644 index 000000000..221c16c2c --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.m @@ -0,0 +1,174 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKPushRuleTableViewCell.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKPushRuleTableViewCell + +- (void)awakeFromNib +{ + [super awakeFromNib]; + [_controlButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_pause"] forState:UIControlStateNormal]; + [_controlButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_pause"] forState:UIControlStateHighlighted]; + + [_deleteButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_minus"] forState:UIControlStateNormal]; + [_deleteButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_minus"] forState:UIControlStateHighlighted]; +} + +- (void)customizeTableViewCellRendering +{ + [super customizeTableViewCellRendering]; + + _controlButton.backgroundColor = [UIColor clearColor]; + + _deleteButton.backgroundColor = [UIColor clearColor]; + + _ruleDescription.numberOfLines = 0; +} + +- (void)setMxPushRule:(MXPushRule *)mxPushRule +{ + // Set the right control icon + if (mxPushRule.enabled) + { + [_controlButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_pause"] forState:UIControlStateNormal]; + [_controlButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_pause"] forState:UIControlStateHighlighted]; + } + else + { + [_controlButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_play"] forState:UIControlStateNormal]; + [_controlButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_play"] forState:UIControlStateHighlighted]; + } + + // Prepare rule description (use rule id by default) + NSString *description = mxPushRule.ruleId; + + switch (mxPushRule.kind) + { + case MXPushRuleKindContent: + description = mxPushRule.pattern; + break; + case MXPushRuleKindRoom: + { + MXRoom *room = [_mxSession roomWithRoomId:mxPushRule.ruleId]; + if (room) + { + description = [MatrixKitL10n notificationSettingsRoomRuleTitle:room.summary.displayname]; + } + break; + } + default: + break; + } + + _ruleDescription.text = description; + + // Delete button and rule actions are hidden for predefined rules + if (mxPushRule.isDefault) + { + if (!_deleteButton.hidden) + { + _deleteButton.hidden = YES; + // Adjust layout by updating constraint + _ruleDescriptionLeftConstraint.constant -= _deleteButtonWidthConstraint.constant; + } + + if (!_ruleActions.isHidden) + { + _ruleActions.hidden = YES; + // Adjust layout by updating constraint + _ruleDescriptionBottomConstraint.constant -= _ruleActionsHeightConstraint.constant; + } + } + else + { + if (_deleteButton.hidden) + { + _deleteButton.hidden = NO; + // Adjust layout by updating constraint + _ruleDescriptionLeftConstraint.constant += _deleteButtonWidthConstraint.constant; + } + + // Prepare rule actions description + NSString *notify; + NSString *sound = @""; + NSString *highlight = @""; + for (MXPushRuleAction *ruleAction in mxPushRule.actions) + { + if (ruleAction.actionType == MXPushRuleActionTypeDontNotify) + { + notify = [MatrixKitL10n notificationSettingsNeverNotify]; + sound = @""; + highlight = @""; + break; + } + else if (ruleAction.actionType == MXPushRuleActionTypeNotify || ruleAction.actionType == MXPushRuleActionTypeCoalesce) + { + notify = [MatrixKitL10n notificationSettingsAlwaysNotify]; + } + else if (ruleAction.actionType == MXPushRuleActionTypeSetTweak) + { + if ([ruleAction.parameters[@"set_tweak"] isEqualToString:@"sound"]) + { + sound = [NSString stringWithFormat:@", %@", [MatrixKitL10n notificationSettingsCustomSound]]; + } + else if ([ruleAction.parameters[@"set_tweak"] isEqualToString:@"highlight"]) + { + // Check the highlight tweak "value" + // If not present, highlight. Else check its value before highlighting + if (nil == ruleAction.parameters[@"value"] || YES == [ruleAction.parameters[@"value"] boolValue]) + { + highlight = [NSString stringWithFormat:@", %@", [MatrixKitL10n notificationSettingsHighlight]]; + } + } + } + } + + if (notify.length) + { + _ruleActions.text = [NSString stringWithFormat:@"%@%@%@", notify, sound, highlight]; + } + + if (_ruleActions.isHidden) + { + _ruleActions.hidden = NO; + // Adjust layout by updating constraint + _ruleDescriptionBottomConstraint.constant += _ruleActionsHeightConstraint.constant; + } + } + + _mxPushRule = mxPushRule; +} + +- (IBAction)onButtonPressed:(id)sender +{ + if (sender == _controlButton) + { + // Swap enable state + [_mxSession.notificationCenter enableRule:_mxPushRule isEnabled:!_mxPushRule.enabled]; + } + else if (sender == _deleteButton) + { + [_mxSession.notificationCenter removeRule:_mxPushRule]; + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.xib b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.xib new file mode 100644 index 000000000..b78c75b9a --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/PushRule/MXKPushRuleTableViewCell.xib @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.h b/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.h new file mode 100644 index 000000000..cc7c3708d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.h @@ -0,0 +1,29 @@ +/* + Copyright 2017 Aram Sargsyan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import +#import "MXKTableViewCell.h" + +@class MXKImageView; + + +@interface MXKReadReceiptTableViewCell : MXKTableViewCell + +@property (weak, nonatomic) IBOutlet MXKImageView *avatarImageView; +@property (weak, nonatomic) IBOutlet UILabel *displayNameLabel; +@property (weak, nonatomic) IBOutlet UILabel *receiptDescriptionLabel; + +@end diff --git a/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.m b/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.m new file mode 100644 index 000000000..56216a1ce --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.m @@ -0,0 +1,44 @@ +/* + Copyright 2017 Aram Sargsyan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKReadReceiptTableViewCell.h" +#import "MXKImageView.h" + +@implementation MXKReadReceiptTableViewCell + +- (void)awakeFromNib +{ + [super awakeFromNib]; + self.avatarImageView.enableInMemoryCache = YES; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + if (self.avatarImageView) { + //Make imageView round + self.avatarImageView.layer.cornerRadius = CGRectGetWidth(self.avatarImageView.frame)/2; + self.avatarImageView.clipsToBounds = YES; + } +} + +- (void)setSelected:(BOOL)selected animated:(BOOL)animated +{ + [super setSelected:selected animated:animated]; + // Configure the view for the selected state +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.xib b/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.xib new file mode 100644 index 000000000..f006664aa --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/ReadReceipts/MXKReadReceiptTableViewCell.xib @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomBubbleTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomBubbleTableViewCell.h new file mode 100644 index 000000000..60cad3248 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomBubbleTableViewCell.h @@ -0,0 +1,328 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" +#import "MXKCellRendering.h" +#import "MXKReceiptSendersContainer.h" + +#import + +@class MXKImageView; +@class MXKPieChartView; +@class MXKRoomBubbleCellData; + +#pragma mark - MXKCellRenderingDelegate cell tap locations + +/** + Action identifier used when the user tapped on message text view. + + The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the tapped event. + */ +extern NSString *const kMXKRoomBubbleCellTapOnMessageTextView; + +/** + Action identifier used when the user tapped on user name label. + + The `userInfo` dictionary contains an `NSString` object under the `kMXKRoomBubbleCellUserIdKey` key, representing the user id of the tapped name label. + */ +extern NSString *const kMXKRoomBubbleCellTapOnSenderNameLabel; + +/** + Action identifier used when the user tapped on avatar view. + + The `userInfo` dictionary contains an `NSString` object under the `kMXKRoomBubbleCellUserIdKey` key, representing the user id of the tapped avatar. + */ +extern NSString *const kMXKRoomBubbleCellTapOnAvatarView; + +/** + Action identifier used when the user tapped on date/time container. + + The `userInfo` is nil. + */ +extern NSString *const kMXKRoomBubbleCellTapOnDateTimeContainer; + +/** + Action identifier used when the user tapped on attachment view. + + The `userInfo` is nil. The attachment can be retrieved via MXKRoomBubbleTableViewCell.attachmentView. + */ +extern NSString *const kMXKRoomBubbleCellTapOnAttachmentView; + +/** + Action identifier used when the user tapped on overlay container. + + The `userInfo` is nil + */ +extern NSString *const kMXKRoomBubbleCellTapOnOverlayContainer; + +/** + Action identifier used when the user tapped on content view. + + The `userInfo` dictionary may contain an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the event displayed at the level of the tapped line. This dictionary is empty if no event correspond to the tapped position. + */ +extern NSString *const kMXKRoomBubbleCellTapOnContentView; + +/** + Action identifier used when the user pressed unsent button displayed in front of an unsent event. + + The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the unsent event. + */ +extern NSString *const kMXKRoomBubbleCellUnsentButtonPressed; + +/** + Action identifier used when the user long pressed on a displayed event. + + The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the selected event. + */ +extern NSString *const kMXKRoomBubbleCellLongPressOnEvent; + +/** + Action identifier used when the user long pressed on progress view. + + The `userInfo` is nil. The progress view can be retrieved via MXKRoomBubbleTableViewCell.progressView. + */ +extern NSString *const kMXKRoomBubbleCellLongPressOnProgressView; + +/** + Action identifier used when the user long pressed on avatar view. + + The `userInfo` dictionary contains an `NSString` object under the `kMXKRoomBubbleCellUserIdKey` key, representing the user id of the concerned avatar. + */ +extern NSString *const kMXKRoomBubbleCellLongPressOnAvatarView; + +/** + Action identifier used when the user clicked on a link. + + This action is sent via the MXKCellRenderingDelegate `shouldDoAction` operation. + + The `userInfo` dictionary contains a `NSURL` object under the `kMXKRoomBubbleCellUrl` key, representing the url the user wants to open. And a NSNumber wrapping `UITextItemInteraction` raw value, representing the type of interaction expected with the URL, under the `kMXKRoomBubbleCellUrlItemInteraction` key. + + The shouldDoAction implementation must return NO to prevent the system (safari) from opening the link. + + @discussion: If the link refers to a room alias/id, a user id or an event id, the non-ASCII characters (like '#' in room alias) has been + escaped to be able to convert it into a legal URL string. + */ +extern NSString *const kMXKRoomBubbleCellShouldInteractWithURL; + +/** + Notifications `userInfo` keys + */ +extern NSString *const kMXKRoomBubbleCellUserIdKey; +extern NSString *const kMXKRoomBubbleCellEventKey; +extern NSString *const kMXKRoomBubbleCellEventIdKey; +extern NSString *const kMXKRoomBubbleCellReceiptsContainerKey; +extern NSString *const kMXKRoomBubbleCellUrl; +extern NSString *const kMXKRoomBubbleCellUrlItemInteraction; + +#pragma mark - MXKRoomBubbleTableViewCell + +/** + `MXKRoomBubbleTableViewCell` is a base class for displaying a room bubble. + + This class is used to handle a maximum of items which may be present in bubbles display (like the user's picture view, the message text view...). + To optimize bubbles rendering, we advise to define a .xib for each kind of bubble layout (with or without sender's information, with or without attachment...). + Each inherited class should define only the actual displayed items. + */ +@interface MXKRoomBubbleTableViewCell : MXKTableViewCell +{ +@protected + /** + The current bubble data displayed by the table view cell + */ + MXKRoomBubbleCellData *bubbleData; +} + +/** + The current bubble data displayed by the table view cell + */ +@property (strong, nonatomic, readonly) MXKRoomBubbleCellData *bubbleData; + +/** + Option to highlight or not the content of message text view (May be used in case of text selection). + NO by default. + */ +@property (nonatomic) BOOL allTextHighlighted; + +/** + Tell whether the animation should start automatically in case of animated gif (NO by default). + */ +@property (nonatomic) BOOL isAutoAnimatedGif; + +/** + The default picture displayed when no picture is available. + */ +@property (nonatomic) UIImage *picturePlaceholder; + +/** + The list of the temporary subviews that should be removed before reusing the cell (nil by default). + */ +@property (nonatomic) NSMutableArray *tmpSubviews; + +/** + The read receipts alignment. + By default, they are left aligned. + */ +@property (nonatomic) ReadReceiptsAlignment readReceiptsAlignment; + +@property (weak, nonatomic) IBOutlet UILabel *userNameLabel; +@property (weak, nonatomic) IBOutlet UIView *userNameTapGestureMaskView; +@property (strong, nonatomic) IBOutlet MXKImageView *pictureView; +@property (weak, nonatomic) IBOutlet UITextView *messageTextView; +@property (strong, nonatomic) IBOutlet MXKImageView *attachmentView; +@property (strong, nonatomic) IBOutlet UIImageView *playIconView; +@property (strong, nonatomic) IBOutlet UIImageView *fileTypeIconView; +@property (weak, nonatomic) IBOutlet UIView *bubbleInfoContainer; +@property (weak, nonatomic) IBOutlet UIView *bubbleOverlayContainer; + +/** + The container view in which the encryption information may be displayed + */ +@property (weak, nonatomic) IBOutlet UIView *encryptionStatusContainerView; + +@property (weak, nonatomic) IBOutlet UIView *progressView; +@property (weak, nonatomic) IBOutlet UILabel *statsLabel; +@property (weak, nonatomic) IBOutlet MXKPieChartView *progressChartView; + +/** + The constraints which defines the relationship between messageTextView and its superview. + The defined constant are supposed >= 0. + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *msgTextViewTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *msgTextViewBottomConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *msgTextViewLeadingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *msgTextViewTrailingConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *msgTextViewWidthConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *msgTextViewMinHeightConstraint; + +/** + The constraints which defines the relationship between attachmentView and its superview + The defined constant are supposed >= 0. + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *attachViewWidthConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *attachViewMinHeightConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *attachViewTopConstraint; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *attachViewBottomConstraint; + +/** + The constraints which defines the relationship between bubbleInfoContainer and its superview + The defined constant are supposed >= 0. + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *bubbleInfoContainerTopConstraint; + +/** + The read marker view and its layout constraints (nil by default). + */ +@property (nonatomic) UIView *readMarkerView; +@property (nonatomic) NSLayoutConstraint *readMarkerViewTopConstraint; +@property (nonatomic) NSLayoutConstraint *readMarkerViewLeadingConstraint; +@property (nonatomic) NSLayoutConstraint *readMarkerViewTrailingConstraint; +@property (nonatomic) NSLayoutConstraint *readMarkerViewHeightConstraint; + +/** + The potential webview used to render an attachment (for example an animated gif). + */ +@property (nonatomic) WKWebView *attachmentWebView; + +/** + Called during the designated initializer of the UITableViewCell class to set the default + properties values. + + You should not call this method directly. + + Subclasses can override this method as needed to customize the initialization. + */ +- (void)finalizeInit; + +/** + Handle progressView display. + */ +- (void)startProgressUI; +- (void)updateProgressUI:(NSDictionary*)statisticsDict; + +#pragma mark - Original Xib values + +/** + Get an original instance of the `MXKRoomBubbleTableViewCell` child class. + + @return an instance of the child class caller which has the original Xib values. + */ ++ (MXKRoomBubbleTableViewCell*)cellWithOriginalXib; + +/** + Disable the handling of the long press on event (see kMXKRoomBubbleCellLongPressOnEvent). NO by default. + + CAUTION: Changing this flag only impact the new created cells (existing 'MXKRoomBubbleTableViewCell' instances are unchanged). + */ ++ (void)disableLongPressGestureOnEvent:(BOOL)disable; + +/** + Method used during [MXKCellRendering render:] to check the provided `cellData` + and prepare the protected `bubbleData`. + Do not override it. + + @param cellData the data object to render. + */ +- (void)prepareRender:(MXKCellData*)cellData; + +/** + Refresh the flair information added to the sender display name. + */ +- (void)renderSenderFlair; + +/** + Highlight text message related to a specific event in the displayed message. + + @param eventId the id of the event to highlight (use nil to cancel highlighting). + */ +- (void)highlightTextMessageForEvent:(NSString*)eventId; + +/** + The top position of an event in the cell. + + A cell can display several events. The method returns the vertical position of a given + event in the cell. + + @return the y position (in pixel) of the event in the cell. + */ +- (CGFloat)topPositionOfEvent:(NSString*)eventId; + +/** + The bottom position of an event in the cell. + + A cell can display several events. The method returns the vertical position of the bottom part + of a given event in the cell. + + @return the y position (in pixel) of the bottom part of the event in the cell. + */ +- (CGFloat)bottomPositionOfEvent:(NSString*)eventId; + +/** + Restore `attachViewBottomConstraint` constant to default value. + */ +- (void)resetAttachmentViewBottomConstraintConstant; + +/** + Redeclare heightForCellData:withMaximumWidth: method from MXKCellRendering to use it as a class method in Swift and not a static method. + */ ++ (CGFloat)heightForCellData:(MXKCellData*)cellData withMaximumWidth:(CGFloat)maxWidth; + +/** + Setup outlets views. Useful to call when cell subclass does not use a xib otherwise this method is called automatically in `awakeFromNib`. + */ +- (void)setupViews; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomBubbleTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomBubbleTableViewCell.m new file mode 100644 index 000000000..b550a1ef1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomBubbleTableViewCell.m @@ -0,0 +1,1563 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleTableViewCell.h" + +#import "MXKImageView.h" +#import "MXKPieChartView.h" +#import "MXKRoomBubbleCellData.h" +#import "MXKTools.h" + +#import "MXKConstants.h" + +#import "NSBundle+MatrixKit.h" +#import "MXRoom+Sync.h" +#import "MXKMessageTextView.h" +#import "UITextView+MatrixKit.h" + +#pragma mark - Constant definitions +NSString *const kMXKRoomBubbleCellTapOnMessageTextView = @"kMXKRoomBubbleCellTapOnMessageTextView"; +NSString *const kMXKRoomBubbleCellTapOnSenderNameLabel = @"kMXKRoomBubbleCellTapOnSenderNameLabel"; +NSString *const kMXKRoomBubbleCellTapOnAvatarView = @"kMXKRoomBubbleCellTapOnAvatarView"; +NSString *const kMXKRoomBubbleCellTapOnDateTimeContainer = @"kMXKRoomBubbleCellTapOnDateTimeContainer"; +NSString *const kMXKRoomBubbleCellTapOnAttachmentView = @"kMXKRoomBubbleCellTapOnAttachmentView"; +NSString *const kMXKRoomBubbleCellTapOnOverlayContainer = @"kMXKRoomBubbleCellTapOnOverlayContainer"; +NSString *const kMXKRoomBubbleCellTapOnContentView = @"kMXKRoomBubbleCellTapOnContentView"; + +NSString *const kMXKRoomBubbleCellUnsentButtonPressed = @"kMXKRoomBubbleCellUnsentButtonPressed"; + +NSString *const kMXKRoomBubbleCellLongPressOnEvent = @"kMXKRoomBubbleCellLongPressOnEvent"; +NSString *const kMXKRoomBubbleCellLongPressOnProgressView = @"kMXKRoomBubbleCellLongPressOnProgressView"; +NSString *const kMXKRoomBubbleCellLongPressOnAvatarView = @"kMXKRoomBubbleCellLongPressOnAvatarView"; +NSString *const kMXKRoomBubbleCellShouldInteractWithURL = @"kMXKRoomBubbleCellShouldInteractWithURL"; + +NSString *const kMXKRoomBubbleCellUserIdKey = @"kMXKRoomBubbleCellUserIdKey"; +NSString *const kMXKRoomBubbleCellEventKey = @"kMXKRoomBubbleCellEventKey"; +NSString *const kMXKRoomBubbleCellEventIdKey = @"kMXKRoomBubbleCellEventIdKey"; +NSString *const kMXKRoomBubbleCellReceiptsContainerKey = @"kMXKRoomBubbleCellReceiptsContainerKey"; +NSString *const kMXKRoomBubbleCellUrl = @"kMXKRoomBubbleCellUrl"; +NSString *const kMXKRoomBubbleCellUrlItemInteraction = @"kMXKRoomBubbleCellUrlItemInteraction"; + +static BOOL _disableLongPressGestureOnEvent; + +@interface MXKRoomBubbleTableViewCell () +{ + // The list of UIViews used to fix the display of side borders for HTML blockquotes + NSMutableArray *htmlBlockquoteSideBorderViews; +} + +@property (nonatomic, weak) UIView *messageTextBackgroundView; +@property (nonatomic) double attachmentViewBottomConstraintDefaultConstant; + +@end + +@implementation MXKRoomBubbleTableViewCell +@synthesize delegate, bubbleData, readReceiptsAlignment; +@synthesize mxkCellData; + ++ (instancetype)roomBubbleTableViewCell +{ + MXKRoomBubbleTableViewCell *instance = nil; + + // Check whether a xib is defined + if ([[self class] nib]) + { + @try { + instance = [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + } + @catch (NSException *exception) { + } + } + + if (!instance) + { + instance = [[self alloc] init]; + } + + return instance; +} + ++ (void)disableLongPressGestureOnEvent:(BOOL)disable +{ + _disableLongPressGestureOnEvent = disable; +} + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(nullable NSString *)reuseIdentifier +{ + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + if (self) + { + [self finalizeInit]; + } + return self; +} +- (nullable instancetype)initWithCoder:(NSCoder *)aDecoder +{ + self = [super initWithCoder:aDecoder]; + if (self) + { + [self finalizeInit]; + } + return self; +} + +- (void)finalizeInit +{ + self.readReceiptsAlignment = ReadReceiptAlignmentLeft; + _allTextHighlighted = NO; + _isAutoAnimatedGif = NO; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + [self setupViews]; +} + +- (void)setupViews +{ + if (self.userNameLabel) + { + // Listen to name tap + UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onSenderNameTap:)]; + [tapGesture setNumberOfTouchesRequired:1]; + [tapGesture setNumberOfTapsRequired:1]; + [tapGesture setDelegate:self]; + + if (self.userNameTapGestureMaskView) + { + [self.userNameTapGestureMaskView addGestureRecognizer:tapGesture]; + } + else + { + [self.userNameLabel addGestureRecognizer:tapGesture]; + self.userNameLabel.userInteractionEnabled = YES; + } + } + + if (self.pictureView) + { + self.pictureView.mediaFolder = kMXMediaManagerAvatarThumbnailFolder; + + // Listen to avatar tap + UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onAvatarTap:)]; + [tapGesture setNumberOfTouchesRequired:1]; + [tapGesture setNumberOfTapsRequired:1]; + [tapGesture setDelegate:self]; + [self.pictureView addGestureRecognizer:tapGesture]; + self.pictureView.userInteractionEnabled = YES; + + // Add a long gesture recognizer on avatar (in order to display for example the member details) + UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onLongPressGesture:)]; + [self.pictureView addGestureRecognizer:longPress]; + } + + if (self.messageTextView) + { + // Listen to textView tap + UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onMessageTap:)]; + [tapGesture setNumberOfTouchesRequired:1]; + [tapGesture setNumberOfTapsRequired:1]; + [tapGesture setDelegate:self]; + [self.messageTextView addGestureRecognizer:tapGesture]; + self.messageTextView.userInteractionEnabled = YES; + + // Recognise and make tappable phone numbers, address, etc. + self.messageTextView.dataDetectorTypes = UIDataDetectorTypeAll; + + // Listen to link click + self.messageTextView.delegate = self; + + if (_disableLongPressGestureOnEvent == NO) + { + // Add a long gesture recognizer on text view (in order to display for example the event details) + UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onLongPressGesture:)]; + longPress.delegate = self; + + // MXKMessageTextView does not catch touches outside of links. Add a background view to handle long touch. + if ([self.messageTextView isKindOfClass:[MXKMessageTextView class]]) + { + UIView *messageTextBackgroundView = [[UIView alloc] initWithFrame:self.messageTextView.frame]; + messageTextBackgroundView.backgroundColor = [UIColor clearColor]; + [self.contentView insertSubview:messageTextBackgroundView belowSubview:self.messageTextView]; + messageTextBackgroundView.translatesAutoresizingMaskIntoConstraints = NO; + [messageTextBackgroundView.leftAnchor constraintEqualToAnchor:self.messageTextView.leftAnchor].active = YES; + [messageTextBackgroundView.rightAnchor constraintEqualToAnchor:self.messageTextView.rightAnchor].active = YES; + [messageTextBackgroundView.topAnchor constraintEqualToAnchor:self.messageTextView.topAnchor].active = YES; + [messageTextBackgroundView.bottomAnchor constraintEqualToAnchor:self.messageTextView.bottomAnchor].active = YES; + + [messageTextBackgroundView addGestureRecognizer:longPress]; + + self.messageTextBackgroundView = messageTextBackgroundView; + } + else + { + [self.messageTextView addGestureRecognizer:longPress]; + } + } + } + + if (self.playIconView) + { + self.playIconView.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"play"]; + } + + if (self.bubbleOverlayContainer) + { + // Add tap recognizer on overlay container + UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onOverlayTap:)]; + [tapGesture setNumberOfTouchesRequired:1]; + [tapGesture setNumberOfTapsRequired:1]; + [tapGesture setDelegate:self]; + [self.bubbleOverlayContainer addGestureRecognizer:tapGesture]; + } + + // Listen to content view tap by default + UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onContentViewTap:)]; + [tapGesture setNumberOfTouchesRequired:1]; + [tapGesture setNumberOfTapsRequired:1]; + [tapGesture setDelegate:self]; + [self.contentView addGestureRecognizer:tapGesture]; + + if (_disableLongPressGestureOnEvent == NO) + { + // Add a long gesture recognizer on text view (in order to display for example the event details) + UILongPressGestureRecognizer *longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onLongPressGesture:)]; + longPressGestureRecognizer.delegate = self; + [self.contentView addGestureRecognizer:longPressGestureRecognizer]; + } + + [self setupConstraintsConstantDefaultValues]; +} + +- (void)customizeTableViewCellRendering +{ + [super customizeTableViewCellRendering]; + + // Clear the default background color of a MXKImageView instance + self.pictureView.defaultBackgroundColor = [UIColor clearColor]; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + if (self.pictureView) + { + // Round image view + [self.pictureView.layer setCornerRadius:self.pictureView.frame.size.width / 2]; + self.pictureView.clipsToBounds = YES; + } +} + +/** + Manually add a side border for HTML blockquotes. + + @discussion + `NSAttributedString` and `UITextView` classes do not support it natively. This + method add an `UIView` to the `UITextView` that implements this border. + + @param canRetry YES if the method can retry later if the UI is not yet ready. + */ +- (void)fixHTMLBlockQuoteRendering:(BOOL)canRetry +{ + if (self.messageTextView && htmlBlockquoteSideBorderViews.count == 0) + { + __weak typeof(self) weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + + if (weakSelf) + { + typeof(self) self = weakSelf; + [MXKTools enumerateMarkedBlockquotesInAttributedString:self.messageTextView.attributedText + usingBlock:^(NSRange range, BOOL *stop) + { + // Compute the UITextRange of the blockquote + UITextPosition *beginning = self.messageTextView.beginningOfDocument; + UITextPosition *start = [self.messageTextView positionFromPosition:beginning offset:range.location]; + UITextPosition *end = [self.messageTextView positionFromPosition:start offset:range.length]; + UITextRange *textRange = [self.messageTextView textRangeFromPosition:start toPosition:end]; + + // Get the rect area of this blockquote within the cell + // There can be several rects in case of multilines. Hence, the merge + NSArray *array = [self.messageTextView selectionRectsForRange:textRange]; + CGRect textRect = CGRectNull; + for (UITextSelectionRect *rect in array) + { + if (rect.rect.size.width) + { + textRect = CGRectUnion(textRect, rect.rect); + } + } + + if (!CGRectIsNull(textRect)) + { + // Add a left border with a height that covers all the blockquote block height + // TODO: Manage RTL language + UIView *sideBorderView = [[UIView alloc] initWithFrame:CGRectMake(5, textRect.origin.y, 4, textRect.size.height)]; + sideBorderView.backgroundColor = self.bubbleData.eventFormatter.htmlBlockquoteBorderColor; + [sideBorderView setTranslatesAutoresizingMaskIntoConstraints:NO]; + + [self.messageTextView addSubview:sideBorderView]; + + if (!self->htmlBlockquoteSideBorderViews) + { + self->htmlBlockquoteSideBorderViews = [NSMutableArray array]; + } + + [self->htmlBlockquoteSideBorderViews addObject:sideBorderView]; + } + else if (canRetry) + { + // Have not found rect area that corresponds to the blockquote + // Try again later when the UI is more ready. Try it only once + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self fixHTMLBlockQuoteRendering:NO]; + }); + } + }]; + } + }); + } +} + +- (void)dealloc +{ + // remove any pending observers + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + delegate = nil; +} + +- (UIImage*)picturePlaceholder +{ + return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"]; +} + +- (void)setIsAutoAnimatedGif:(BOOL)isAutoAnimatedGif +{ + _isAutoAnimatedGif = isAutoAnimatedGif; + + [self renderGif]; +} + +- (void)setAllTextHighlighted:(BOOL)allTextHighlighted +{ + _allTextHighlighted = allTextHighlighted; + + if (self.messageTextView && bubbleData.textMessage.length != 0) + { + if (_allTextHighlighted) + { + NSMutableAttributedString *highlightedString = [[NSMutableAttributedString alloc] initWithAttributedString:bubbleData.attributedTextMessage]; + UIColor *color = self.tintColor ? self.tintColor : [UIColor lightGrayColor]; + [highlightedString addAttribute:NSBackgroundColorAttributeName value:color range:NSMakeRange(0, highlightedString.length)]; + self.messageTextView.attributedText = highlightedString; + } + else + { + self.messageTextView.attributedText = bubbleData.attributedTextMessage; + } + } +} + +- (void)highlightTextMessageForEvent:(NSString*)eventId +{ + if (self.messageTextView) + { + if (eventId.length) + { + self.messageTextView.attributedText = [bubbleData attributedTextMessageWithHighlightedEvent:eventId tintColor:self.tintColor]; + } + else + { + // Restore original string + self.messageTextView.attributedText = bubbleData.attributedTextMessage; + } + } +} + +- (CGFloat)topPositionOfEvent:(NSString*)eventId +{ + CGFloat topPositionOfEvent = 0; + + // Retrieve the component that hosts the event + MXKRoomBubbleComponent *theComponent; + for (MXKRoomBubbleComponent *component in bubbleData.bubbleComponents) + { + if ([component.event.eventId isEqualToString:eventId]) + { + theComponent = component; + break; + } + } + + if (theComponent) + { + topPositionOfEvent = theComponent.position.y + self.msgTextViewTopConstraint.constant; + } + return topPositionOfEvent; +} + +- (CGFloat)bottomPositionOfEvent:(NSString*)eventId +{ + CGFloat bottomPositionOfEvent = self.frame.size.height - self.msgTextViewBottomConstraint.constant; + + // Parse each component by the end of the array in order to compute the bottom position. + NSArray *bubbleComponents = bubbleData.bubbleComponents; + NSInteger index = bubbleComponents.count; + + while (index --) + { + MXKRoomBubbleComponent *component = bubbleComponents[index]; + if ([component.event.eventId isEqualToString:eventId]) + { + break; + } + else + { + // Update the bottom position + bottomPositionOfEvent = component.position.y + self.msgTextViewTopConstraint.constant; + } + } + return bottomPositionOfEvent; +} + +- (void)setSelected:(BOOL)selected animated:(BOOL)animated +{ + [super setSelected:selected animated:animated]; + + // Configure the view for the selected state +} + +- (void)render:(MXKCellData *)cellData +{ + [self prepareRender:cellData]; + + if (bubbleData) + { + // Check conditions to display the message sender name + if (self.userNameLabel) + { + // Display sender's name except if the name appears in the displayed text (see emote and membership events) + if (bubbleData.shouldHideSenderName == NO) + { + if (bubbleData.senderFlair) + { + [self renderSenderFlair]; + } + else + { + self.userNameLabel.text = bubbleData.senderDisplayName; + } + + + self.userNameLabel.hidden = NO; + self.userNameTapGestureMaskView.userInteractionEnabled = YES; + } + else + { + self.userNameLabel.hidden = YES; + self.userNameTapGestureMaskView.userInteractionEnabled = NO; + } + } + + // Check whether the sender's picture is actually displayed before loading it. + if (self.pictureView) + { + self.pictureView.enableInMemoryCache = YES; + // Consider here the sender avatar is stored unencrypted on Matrix media repo + [self.pictureView setImageURI:bubbleData.senderAvatarUrl + withType:nil + andImageOrientation:UIImageOrientationUp + toFitViewSize:self.pictureView.frame.size + withMethod:MXThumbnailingMethodCrop + previewImage:bubbleData.senderAvatarPlaceholder ? bubbleData.senderAvatarPlaceholder : self.picturePlaceholder + mediaManager:bubbleData.mxSession.mediaManager]; + } + + if (self.attachmentView && bubbleData.isAttachmentWithThumbnail) + { + // Set attached media folders + self.attachmentView.mediaFolder = bubbleData.roomId; + + self.attachmentView.backgroundColor = [UIColor clearColor]; + + // Retrieve the suitable content size for the attachment thumbnail + CGSize contentSize = bubbleData.contentSize; + + // Update image view frame in order to center loading wheel (if any) + CGRect frame = self.attachmentView.frame; + frame.size.width = contentSize.width; + frame.size.height = contentSize.height; + self.attachmentView.frame = frame; + + // Set play icon visibility + self.playIconView.hidden = (bubbleData.attachment.type != MXKAttachmentTypeVideo); + + // Hide by default file type icon + self.fileTypeIconView.hidden = YES; + + // Display the attachment thumbnail + [self.attachmentView setAttachmentThumb:bubbleData.attachment]; + + if (bubbleData.attachment.contentURL) + { + // Add tap recognizer to open attachment + UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onAttachmentTap:)]; + [tap setNumberOfTouchesRequired:1]; + [tap setNumberOfTapsRequired:1]; + [tap setDelegate:self]; + [self.attachmentView addGestureRecognizer:tap]; + } + + [self startProgressUI]; + + // Adjust Attachment width constant + self.attachViewWidthConstraint.constant = contentSize.width; + + // Add a long gesture recognizer on progressView to cancel the current operation (Note: only the download can be cancelled). + UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onLongPressGesture:)]; + [self.progressView addGestureRecognizer:longPress]; + + if (_disableLongPressGestureOnEvent == NO) + { + // Add a long gesture recognizer on attachment view in order to display for example the event details + longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onLongPressGesture:)]; + [self.attachmentView addGestureRecognizer:longPress]; + } + + // Handle here the case of the attached gif + [self renderGif]; + } + else if (self.messageTextView) + { + // Compute message content size + bubbleData.maxTextViewWidth = self.frame.size.width - (self.msgTextViewLeadingConstraint.constant + self.msgTextViewTrailingConstraint.constant); + CGSize contentSize = bubbleData.contentSize; + + // Prepare displayed text message + NSAttributedString* newText = nil; + + // Underline attached file name + if (self.isBubbleDataContainsFileAttachment) + { + NSMutableAttributedString *updatedText = [[NSMutableAttributedString alloc] initWithAttributedString:bubbleData.attributedTextMessage]; + [updatedText addAttribute:NSUnderlineStyleAttributeName value:[NSNumber numberWithInteger:NSUnderlineStyleSingle] range:NSMakeRange(0, updatedText.length)]; + + newText = updatedText; + } + else + { + newText = bubbleData.attributedTextMessage; + } + + // update the text only if it is required + // updating a text is quite long (even with the same text). + if (![self.messageTextView.attributedText isEqualToAttributedString:newText]) + { + self.messageTextView.attributedText = newText; + + if (bubbleData.displayFix & MXKRoomBubbleComponentDisplayFixHtmlBlockquote) + { + [self fixHTMLBlockQuoteRendering:YES]; + } + } + + // Update msgTextView width constraint to align correctly the text + if (self.msgTextViewWidthConstraint.constant != contentSize.width) + { + self.msgTextViewWidthConstraint.constant = contentSize.width; + } + } + + // Check and update each component position (used to align timestamps label in front of events, and to handle tap gesture on events) + [bubbleData prepareBubbleComponentsPosition]; + + // Handle here timestamp display (only if a container has been defined) + if (self.bubbleInfoContainer) + { + if ((bubbleData.showBubbleDateTime && !bubbleData.useCustomDateTimeLabel) + || (bubbleData.showBubbleReceipts && !bubbleData.useCustomReceipts)) + { + // Add datetime label for each component + self.bubbleInfoContainer.hidden = NO; + + // ensure that older subviews are removed + // They should be (they are removed when the is not anymore used). + // But, it seems that is not always true. + NSArray* views = [self.bubbleInfoContainer subviews]; + for(UIView* view in views) + { + [view removeFromSuperview]; + } + + for (MXKRoomBubbleComponent *component in bubbleData.bubbleComponents) + { + if (component.event.sentState != MXEventSentStateFailed) + { + CGFloat timeLabelOffset = 0; + + if (component.date && bubbleData.showBubbleDateTime && !bubbleData.useCustomDateTimeLabel) + { + UILabel *dateTimeLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, component.position.y, self.bubbleInfoContainer.frame.size.width , 15)]; + + dateTimeLabel.text = [bubbleData.eventFormatter dateStringFromDate:component.date withTime:YES]; + if (bubbleData.isIncoming) + { + dateTimeLabel.textAlignment = NSTextAlignmentRight; + } + else + { + dateTimeLabel.textAlignment = NSTextAlignmentLeft; + } + dateTimeLabel.textColor = [UIColor lightGrayColor]; + dateTimeLabel.font = [UIFont systemFontOfSize:11]; + dateTimeLabel.adjustsFontSizeToFitWidth = YES; + dateTimeLabel.minimumScaleFactor = 0.6; + + [dateTimeLabel setTranslatesAutoresizingMaskIntoConstraints:NO]; + [self.bubbleInfoContainer addSubview:dateTimeLabel]; + // Force dateTimeLabel in full width (to handle auto-layout in case of screen rotation) + NSLayoutConstraint *leftConstraint = [NSLayoutConstraint constraintWithItem:dateTimeLabel + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:self.bubbleInfoContainer + attribute:NSLayoutAttributeLeading + multiplier:1.0 + constant:0]; + NSLayoutConstraint *rightConstraint = [NSLayoutConstraint constraintWithItem:dateTimeLabel + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:self.bubbleInfoContainer + attribute:NSLayoutAttributeTrailing + multiplier:1.0 + constant:0]; + // Vertical constraints are required for iOS > 8 + NSLayoutConstraint *topConstraint = [NSLayoutConstraint constraintWithItem:dateTimeLabel + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.bubbleInfoContainer + attribute:NSLayoutAttributeTop + multiplier:1.0 + constant:component.position.y]; + NSLayoutConstraint *heightConstraint = [NSLayoutConstraint constraintWithItem:dateTimeLabel + attribute:NSLayoutAttributeHeight + relatedBy:NSLayoutRelationEqual + toItem:nil + attribute:NSLayoutAttributeNotAnAttribute + multiplier:1.0 + constant:15]; + [NSLayoutConstraint activateConstraints:@[leftConstraint, rightConstraint, topConstraint, heightConstraint]]; + + timeLabelOffset += 15; + } + + if (bubbleData.showBubbleReceipts && !bubbleData.useCustomReceipts) + { + NSMutableArray* roomMembers = nil; + NSMutableArray* placeholders = nil; + NSArray *receipts = bubbleData.readReceipts[component.event.eventId]; + + // Check whether some receipts are found + if (receipts.count) + { + MXRoom* room = [bubbleData.mxSession roomWithRoomId:bubbleData.roomId]; + if (room) + { + // Retrieve the corresponding room members + roomMembers = [[NSMutableArray alloc] initWithCapacity:receipts.count]; + placeholders = [[NSMutableArray alloc] initWithCapacity:receipts.count]; + + MXRoomMembers *stateRoomMembers = room.dangerousSyncState.members; + for (MXReceiptData* data in receipts) + { + MXRoomMember * roomMember = [stateRoomMembers memberWithUserId:data.userId]; + if (roomMember) + { + [roomMembers addObject:roomMember]; + [placeholders addObject:self.picturePlaceholder]; + } + } + } + } + + if (roomMembers.count) + { + MXKReceiptSendersContainer* avatarsContainer = [[MXKReceiptSendersContainer alloc] initWithFrame:CGRectMake(0, component.position.y + timeLabelOffset, self.bubbleInfoContainer.frame.size.width , 15) andMediaManager:bubbleData.mxSession.mediaManager]; + + [avatarsContainer refreshReceiptSenders:roomMembers withPlaceHolders:placeholders andAlignment:self.readReceiptsAlignment]; + + [self.bubbleInfoContainer addSubview:avatarsContainer]; + + // Force dateTimeLabel in full width (to handle auto-layout in case of screen rotation) + NSLayoutConstraint *leftConstraint = [NSLayoutConstraint constraintWithItem:avatarsContainer + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:self.bubbleInfoContainer + attribute:NSLayoutAttributeLeading + multiplier:1.0 + constant:0]; + NSLayoutConstraint *rightConstraint = [NSLayoutConstraint constraintWithItem:avatarsContainer + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:self.bubbleInfoContainer + attribute:NSLayoutAttributeTrailing + multiplier:1.0 + constant:0]; + // Vertical constraints are required for iOS > 8 + NSLayoutConstraint *topConstraint = [NSLayoutConstraint constraintWithItem:avatarsContainer + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self.bubbleInfoContainer + attribute:NSLayoutAttributeTop + multiplier:1.0 + constant:(component.position.y + timeLabelOffset)]; + + NSLayoutConstraint *heightConstraint = [NSLayoutConstraint constraintWithItem:avatarsContainer + attribute:NSLayoutAttributeHeight + relatedBy:NSLayoutRelationEqual + toItem:nil + attribute:NSLayoutAttributeNotAnAttribute + multiplier:1.0 + constant:15]; + + [NSLayoutConstraint activateConstraints:@[leftConstraint, rightConstraint, topConstraint, heightConstraint]]; + } + } + } + } + } + else + { + self.bubbleInfoContainer.hidden = YES; + } + } + } +} + +- (void)prepareRender:(MXKCellData *)cellData +{ + // Sanity check: accept only object of MXKRoomBubbleCellData classes or sub-classes + NSParameterAssert([cellData isKindOfClass:[MXKRoomBubbleCellData class]]); + + bubbleData = (MXKRoomBubbleCellData*)cellData; + mxkCellData = cellData; +} + +- (void)renderSenderFlair +{ + NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithFormat:@"%@ ", bubbleData.senderDisplayName]]; + + NSUInteger index = 0; + + for (MXGroup *group in bubbleData.senderFlair) + { + NSString *mxcAvatarURI = group.profile.avatarUrl; + NSString *cacheFilePath = [MXMediaManager thumbnailCachePathForMatrixContentURI:mxcAvatarURI andType:@"image/jpeg" inFolder:kMXMediaManagerDefaultCacheFolder toFitViewSize:CGSizeMake(12, 12) withMethod:MXThumbnailingMethodCrop]; + + // Check whether the avatar url is valid + if (cacheFilePath) + { + UIImage *image = [MXMediaManager loadThroughCacheWithFilePath:cacheFilePath]; + if (image) + { + NSTextAttachment *textAttachment = [[NSTextAttachment alloc] init]; + textAttachment.image = [MXKTools resizeImageWithRoundedCorners:image toSize:CGSizeMake(12, 12)]; + NSAttributedString *attrStringWithImage = [NSAttributedString attributedStringWithAttachment:textAttachment]; + [attributedString appendAttributedString:attrStringWithImage]; + [attributedString appendAttributedString:[[NSAttributedString alloc] initWithString:@" "]]; + } + else + { + NSString *downloadId = [MXMediaManager thumbnailDownloadIdForMatrixContentURI:mxcAvatarURI + inFolder:kMXMediaManagerDefaultCacheFolder + toFitViewSize:CGSizeMake(12, 12) + withMethod:MXThumbnailingMethodCrop]; + // Check whether the download is in progress. + MXMediaLoader* loader = [MXMediaManager existingDownloaderWithIdentifier:downloadId]; + if (loader) + { + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:loader]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onFlairDownloadStateChange:) name:kMXMediaLoaderStateDidChangeNotification object:loader]; + } + else + { + MXWeakify(self); + [bubbleData.mxSession.mediaManager downloadThumbnailFromMatrixContentURI:mxcAvatarURI + withType:@"image/jpeg" + inFolder:kMXMediaManagerDefaultCacheFolder + toFitViewSize:CGSizeMake(12, 12) + withMethod:MXThumbnailingMethodCrop + success:^(NSString *outputFilePath) { + // Refresh sender flair + MXStrongifyAndReturnIfNil(self); + [self renderSenderFlair]; + } + failure:nil]; + } + } + + index++; + if (index == 3) + { + if (bubbleData.senderFlair.count > 3) + { + NSAttributedString *more = [[NSAttributedString alloc] initWithString:[NSString stringWithFormat:@"+%tu", (bubbleData.senderFlair.count - 3)] attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:11.0], NSBaselineOffsetAttributeName:@(+2)}]; + [attributedString appendAttributedString:more]; + } + break; + } + } + } + + self.userNameLabel.attributedText = attributedString; +} + +- (void)renderGif +{ + if (self.attachmentView && bubbleData.attachment) + { + NSString *mimetype = nil; + if (bubbleData.attachment.thumbnailInfo) + { + mimetype = bubbleData.attachment.thumbnailInfo[@"mimetype"]; + } + else if (bubbleData.attachment.contentInfo) + { + mimetype = bubbleData.attachment.contentInfo[@"mimetype"]; + } + + if ([mimetype isEqualToString:@"image/gif"]) + { + if (_isAutoAnimatedGif) + { + // Hide the file type icon, and the progress UI + self.fileTypeIconView.hidden = YES; + [self stopProgressUI]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + + // Animated gif is displayed in a webview added on the attachment view + self.attachmentWebView = [[WKWebView alloc] initWithFrame:self.attachmentView.bounds]; + self.attachmentWebView.opaque = NO; + self.attachmentWebView.backgroundColor = [UIColor clearColor]; + self.attachmentWebView.contentMode = UIViewContentModeScaleAspectFit; + self.attachmentWebView.autoresizingMask = (UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin); + self.attachmentWebView.userInteractionEnabled = NO; + self.attachmentWebView.hidden = YES; + [self.attachmentView addSubview:self.attachmentWebView]; + + __weak WKWebView *weakAnimatedGifViewer = self.attachmentWebView; + __weak typeof(self) weakSelf = self; + + void (^onDownloaded)(NSData *) = ^(NSData *data){ + + if (weakAnimatedGifViewer && weakAnimatedGifViewer.superview) + { + WKWebView *strongAnimatedGifViewer = weakAnimatedGifViewer; + strongAnimatedGifViewer.navigationDelegate = weakSelf; + [strongAnimatedGifViewer loadData:data MIMEType:@"image/gif" characterEncodingName:@"UTF-8" baseURL:[NSURL URLWithString:@"http://"]]; + } + }; + + void (^onFailure)(NSError *) = ^(NSError *error){ + + MXLogDebug(@"[MXKRoomBubbleTableViewCell] gif download failed"); + // Notify the end user + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + }; + + [bubbleData.attachment getAttachmentData:^(NSData *data) { + onDownloaded(data); + } failure:^(NSError *error) { + onFailure(error); + }]; + } + else + { + self.fileTypeIconView.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"filetype-gif"]; + self.fileTypeIconView.hidden = NO; + + // Check whether a download is in progress + [self startProgressUI]; + } + } + } +} + ++ (CGFloat)heightForCellData:(MXKCellData*)cellData withMaximumWidth:(CGFloat)maxWidth +{ + // Sanity check: accept only object of MXKRoomBubbleCellData classes or sub-classes + NSParameterAssert([cellData isKindOfClass:[MXKRoomBubbleCellData class]]); + + MXKRoomBubbleCellData *bubbleData = (MXKRoomBubbleCellData*)cellData; + MXKRoomBubbleTableViewCell* cell = [self cellWithOriginalXib]; + CGFloat rowHeight = cell.frame.size.height; + + if (cell.attachmentView && bubbleData.isAttachmentWithThumbnail) + { + // retrieve the suggested image view height + rowHeight = bubbleData.contentSize.height; + + // Check here the minimum height defined in cell view for text message + if (cell.attachViewMinHeightConstraint && rowHeight < cell.attachViewMinHeightConstraint.constant) + { + rowHeight = cell.attachViewMinHeightConstraint.constant; + } + + // Finalize the row height by adding the vertical constraints. + rowHeight += cell.attachViewTopConstraint.constant + cell.attachViewBottomConstraint.constant; + } + else if (cell.messageTextView) + { + // Update maximum width available for the textview + bubbleData.maxTextViewWidth = maxWidth - (cell.msgTextViewLeadingConstraint.constant + cell.msgTextViewTrailingConstraint.constant); + + // Retrieve the suggested height of the message content + rowHeight = bubbleData.contentSize.height; + + // Consider here the minimum height defined in cell view for text message + if (cell.msgTextViewMinHeightConstraint && rowHeight < cell.msgTextViewMinHeightConstraint.constant) + { + rowHeight = cell.msgTextViewMinHeightConstraint.constant; + } + + // Finalize the row height by adding the top and bottom constraints of the message text view in cell + rowHeight += cell.msgTextViewTopConstraint.constant + cell.msgTextViewBottomConstraint.constant; + } + + return rowHeight; +} + +- (void)prepareForReuse +{ + [super prepareForReuse]; + + [self didEndDisplay]; +} + +- (void)didEndDisplay +{ + bubbleData = nil; + + for (UIView *sideBorder in htmlBlockquoteSideBorderViews) + { + [sideBorder removeFromSuperview]; + } + [htmlBlockquoteSideBorderViews removeAllObjects]; + htmlBlockquoteSideBorderViews = nil; + + if (_attachmentWebView) + { + [_attachmentWebView removeFromSuperview]; + _attachmentWebView.navigationDelegate = nil; + _attachmentWebView = nil; + } + + if (_readMarkerView) + { + [_readMarkerView removeFromSuperview]; + _readMarkerView = nil; + _readMarkerViewTopConstraint = nil; + _readMarkerViewLeadingConstraint = nil; + _readMarkerViewTrailingConstraint = nil; + _readMarkerViewHeightConstraint = nil; + } + + if (self.attachmentView) + { + // Remove all gesture recognizer + while (self.attachmentView.gestureRecognizers.count) + { + [self.attachmentView removeGestureRecognizer:self.attachmentView.gestureRecognizers[0]]; + } + + // Prevent the cell from displaying again the image in case of reuse. + self.attachmentView.image = nil; + } + + // Remove potential dateTime (or unsent) label(s) + if (self.bubbleInfoContainer && self.bubbleInfoContainer.subviews.count > 0) + { + NSArray* subviews = self.bubbleInfoContainer.subviews; + + for (UIView *view in subviews) + { + [view removeFromSuperview]; + } + } + self.bubbleInfoContainer.hidden = YES; + + // Remove temporary subviews + if (self.tmpSubviews) + { + for (UIView *view in self.tmpSubviews) + { + [view removeFromSuperview]; + } + self.tmpSubviews = nil; + } + + // Remove potential overlay subviews + if (self.bubbleOverlayContainer) + { + NSArray* subviews = self.bubbleOverlayContainer.subviews; + + for (UIView *view in subviews) + { + [view removeFromSuperview]; + } + + self.bubbleOverlayContainer.hidden = YES; + } + + if (self.progressView) + { + [self stopProgressUI]; + + // Remove long tap gesture on the progressView + while (self.progressView.gestureRecognizers.count) + { + [self.progressView removeGestureRecognizer:self.progressView.gestureRecognizers[0]]; + } + } + + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + delegate = nil; + + self.readReceiptsAlignment = ReadReceiptAlignmentLeft; + _allTextHighlighted = NO; + _isAutoAnimatedGif = NO; + + [self resetConstraintsConstantToDefault]; +} + +- (BOOL)shouldInteractWithURL:(NSURL *)URL urlItemInteraction:(UITextItemInteraction)urlItemInteraction associatedEvent:(MXEvent*)associatedEvent +{ + return [self shouldInteractWithURL:URL urlItemInteractionValue:@(urlItemInteraction) associatedEvent:associatedEvent]; +} + +- (BOOL)shouldInteractWithURL:(NSURL *)URL urlItemInteractionValue:(NSNumber*)urlItemInteractionValue associatedEvent:(MXEvent*)associatedEvent +{ + NSMutableDictionary *userInfo = [@{ + kMXKRoomBubbleCellUrl:URL, + kMXKRoomBubbleCellUrlItemInteraction:urlItemInteractionValue + } mutableCopy]; + + if (associatedEvent) + { + userInfo[kMXKRoomBubbleCellEventKey] = associatedEvent; + } + + return [delegate cell:self shouldDoAction:kMXKRoomBubbleCellShouldInteractWithURL userInfo:userInfo defaultValue:YES]; +} + +- (BOOL)isBubbleDataContainsFileAttachment +{ + return bubbleData.attachment + && (bubbleData.attachment.type == MXKAttachmentTypeFile || bubbleData.attachment.type == MXKAttachmentTypeAudio || bubbleData.attachment.type == MXKAttachmentTypeVoiceMessage) + && bubbleData.attachment.contentURL + && bubbleData.attachment.contentInfo; +} + +- (MXKRoomBubbleComponent*)closestBubbleComponentForGestureRecognizer:(UIGestureRecognizer*)gestureRecognizer locationInView:(UIView*)view +{ + CGPoint tapPoint = [gestureRecognizer locationInView:view]; + MXKRoomBubbleComponent *tappedComponent; + + if (tapPoint.y >= 0 && tapPoint.y <= view.frame.size.height) + { + tappedComponent = [self closestBubbleComponentAtPosition:tapPoint]; + } + + return tappedComponent; +} + +- (MXKRoomBubbleComponent*)closestBubbleComponentAtPosition:(CGPoint)position +{ + MXKRoomBubbleComponent *tappedComponent; + + NSArray *bubbleComponents = bubbleData.bubbleComponents; + + for (MXKRoomBubbleComponent *component in bubbleComponents) + { + // Ignore components without display (For example redacted event or state events) + if (!component.attributedTextMessage) + { + continue; + } + + if (component.position.y > position.y) + { + break; + } + + tappedComponent = component; + } + + return tappedComponent; +} + +- (void)setupConstraintsConstantDefaultValues +{ + self.attachmentViewBottomConstraintDefaultConstant = self.attachViewBottomConstraint.constant; +} + +- (void)resetAttachmentViewBottomConstraintConstant +{ + self.attachViewBottomConstraint.constant = self.attachmentViewBottomConstraintDefaultConstant; +} + +- (void)resetConstraintsConstantToDefault +{ + [self resetAttachmentViewBottomConstraintConstant]; +} + +#pragma mark - Attachment progress handling + +- (void)updateProgressUI:(NSDictionary*)statisticsDict +{ + self.progressView.hidden = !statisticsDict; + + NSNumber* downloadRate = [statisticsDict valueForKey:kMXMediaLoaderCurrentDataRateKey]; + + NSNumber* completedBytesCount = [statisticsDict valueForKey:kMXMediaLoaderCompletedBytesCountKey]; + NSNumber* totalBytesCount = [statisticsDict valueForKey:kMXMediaLoaderTotalBytesCountKey]; + + NSMutableString* text = [[NSMutableString alloc] init]; + + if (completedBytesCount && totalBytesCount) + { + NSString* progressString = [NSString stringWithFormat:@"%@ / %@", [NSByteCountFormatter stringFromByteCount:completedBytesCount.longLongValue countStyle:NSByteCountFormatterCountStyleFile], [NSByteCountFormatter stringFromByteCount:totalBytesCount.longLongValue countStyle:NSByteCountFormatterCountStyleFile]]; + + [text appendString:progressString]; + } + + if (downloadRate && downloadRate.longLongValue) + { + [text appendFormat:@"\n%@/s", [NSByteCountFormatter stringFromByteCount:downloadRate.longLongValue countStyle:NSByteCountFormatterCountStyleFile]]; + + if (completedBytesCount && totalBytesCount) + { + CGFloat remainimgTime = ((totalBytesCount.floatValue - completedBytesCount.floatValue)) / downloadRate.floatValue; + [text appendFormat:@"\n%@", [MXKTools formatSecondsInterval:remainimgTime]]; + } + } + + self.statsLabel.text = text; + + NSNumber* progressNumber = [statisticsDict valueForKey:kMXMediaLoaderProgressValueKey]; + + if (progressNumber) + { + self.progressChartView.progress = progressNumber.floatValue; + } +} + +- (void)onAttachmentLoaderStateChange:(NSNotification *)notif +{ + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + switch (loader.state) { + case MXMediaLoaderStateDownloadInProgress: + [self updateProgressUI:loader.statisticsDict]; + break; + case MXMediaLoaderStateDownloadCompleted: + case MXMediaLoaderStateDownloadFailed: + case MXMediaLoaderStateCancelled: + [self stopProgressUI]; + // remove the observer + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:loader]; + break; + default: + break; + } +} + +- (void)onFlairDownloadStateChange:(NSNotification *)notif +{ + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + switch (loader.state) { + case MXMediaLoaderStateDownloadCompleted: + [self renderSenderFlair]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:loader]; + break; + case MXMediaLoaderStateDownloadFailed: + case MXMediaLoaderStateCancelled: + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:loader]; + break; + default: + break; + } +} + +- (void)startProgressUI +{ + self.progressView.hidden = YES; + + // there is an attachment URL + if (bubbleData.attachment.contentURL) + { + // remove any pending observers + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + + // check if there is a download in progress + MXMediaLoader *loader = [MXMediaManager existingDownloaderWithIdentifier:bubbleData.attachment.downloadId]; + if (loader) + { + // defines the text to display + [self updateProgressUI:loader.statisticsDict]; + + // anyway listen to the progress event + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(onAttachmentLoaderStateChange:) + name:kMXMediaLoaderStateDidChangeNotification + object:loader]; + } + } +} + +- (void)stopProgressUI +{ + self.progressView.hidden = YES; + + // do not remove the observer here + // the download could restart without recomposing the cell +} + +#pragma mark - Original Xib values + +/** + `childClasses` hosts one instance of each child classes of `MXKRoomBubbleTableViewCell`. + The key is the child class name. The value, the instance. + */ +static NSMutableDictionary *childClasses; + ++ (MXKRoomBubbleTableViewCell*)cellWithOriginalXib +{ + MXKRoomBubbleTableViewCell *cellWithOriginalXib; + + @synchronized(self) + { + if (childClasses == nil) + { + childClasses = [NSMutableDictionary dictionary]; + } + + // To save memory, use only one original instance per child class + cellWithOriginalXib = childClasses[NSStringFromClass(self.class)]; + if (nil == cellWithOriginalXib) + { + cellWithOriginalXib = [self roomBubbleTableViewCell]; + + childClasses[NSStringFromClass(self.class)] = cellWithOriginalXib; + } + } + return cellWithOriginalXib; +} + +#pragma mark - User actions + +- (IBAction)onMessageTap:(UITapGestureRecognizer*)sender +{ + if (delegate) + { + // Check whether the current displayed text corresponds to an attached file + // NOTE: This assumes that a cell with attachment has only one `MXKRoomBubbleComponent` + if (self.isBubbleDataContainsFileAttachment) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnAttachmentView userInfo:nil]; + } + else + { + NSURL *tappedUrl; + + // Hyperlinks in UITextView does not respond instantly to touch. + // To overcome this, check manually if a link has been touched in UITextView when performing a quick tap. + // Otherwise UITextViewDelegate method `- (BOOL)textView:shouldInteractWithURL:inRange:interaction:` is still called for long press and force touch. + if ([sender.view isEqual:self.messageTextView]) + { + UITextView *textView = self.messageTextView; + CGPoint tapLocation = [sender locationInView:textView]; + UITextPosition *textPosition = [textView closestPositionToPoint:tapLocation]; + NSDictionary *attributes = [textView textStylingAtPosition:textPosition inDirection:UITextStorageDirectionForward]; + + // The value of `NSLinkAttributeName` attribute could be an NSURL or an NSString object. + id tappedURLObject = attributes[NSLinkAttributeName]; + + if (tappedURLObject) + { + if ([tappedURLObject isKindOfClass:[NSURL class]]) + { + tappedUrl = (NSURL*)tappedURLObject; + } + else if ([tappedURLObject isKindOfClass:[NSString class]]) + { + tappedUrl = [NSURL URLWithString:(NSString*)tappedURLObject]; + } + } + } + + MXKRoomBubbleComponent *tappedComponent = [self closestBubbleComponentForGestureRecognizer:sender locationInView:sender.view]; + MXEvent *tappedEvent = tappedComponent.event; + + // If a link has been touched warn delegate immediately. + if (tappedUrl) + { + [self shouldInteractWithURL:tappedUrl urlItemInteraction:UITextItemInteractionInvokeDefaultAction associatedEvent:tappedEvent]; + } + else + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnMessageTextView userInfo:(tappedEvent ? @{kMXKRoomBubbleCellEventKey:tappedEvent} : nil)]; + } + } + } +} + +- (IBAction)onSenderNameTap:(UITapGestureRecognizer*)sender +{ + if (delegate) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnSenderNameLabel userInfo:@{kMXKRoomBubbleCellUserIdKey: bubbleData.senderId}]; + } +} + +- (IBAction)onAvatarTap:(UITapGestureRecognizer*)sender +{ + if (delegate) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnAvatarView userInfo:@{kMXKRoomBubbleCellUserIdKey: bubbleData.senderId}]; + } +} + +- (IBAction)onAttachmentTap:(UITapGestureRecognizer*)sender +{ + if (delegate) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnAttachmentView userInfo:nil]; + } +} + +- (IBAction)showHideDateTime:(id)sender +{ + if (delegate) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnDateTimeContainer userInfo:nil]; + } +} + +- (IBAction)onOverlayTap:(UITapGestureRecognizer*)sender +{ + if (delegate) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnOverlayContainer userInfo:nil]; + } +} + +- (IBAction)onContentViewTap:(UITapGestureRecognizer*)sender +{ + if (delegate) + { + // Check whether a bubble component is displayed at the level of the tapped line. + MXKRoomBubbleComponent *tappedComponent = nil; + + if (self.attachmentView) + { + // Check whether the user tapped on the side of the attachment. + tappedComponent = [self closestBubbleComponentForGestureRecognizer:sender locationInView:self.attachmentView]; + } + else if (self.messageTextView) + { + // NOTE: A tap on messageTextView using `MXKMessageTextView` class fallback here if the user does not tap on a link. + + // Use the same hack as `onMessageTap:`, check whether the current displayed text corresponds to an attached file + // NOTE: This assumes that a cell with attachment has only one `MXKRoomBubbleComponent` + if (self.isBubbleDataContainsFileAttachment) + { + // This assume that an attachment use one cell in the application using MatrixKit + // This condition is a fix to handle + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnAttachmentView userInfo:nil]; + } + else + { + // Check whether the user tapped in front of a text component. + tappedComponent = [self closestBubbleComponentForGestureRecognizer:sender locationInView:self.messageTextView]; + } + } + else + { + tappedComponent = [self.bubbleData getFirstBubbleComponentWithDisplay]; + } + + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellTapOnContentView userInfo:(tappedComponent ? @{kMXKRoomBubbleCellEventKey:tappedComponent.event} : nil)]; + } +} + +- (IBAction)onLongPressGesture:(UILongPressGestureRecognizer*)longPressGestureRecognizer +{ + if (longPressGestureRecognizer.state == UIGestureRecognizerStateBegan && delegate) + { + UIView* view = longPressGestureRecognizer.view; + + // Check the view on which long press has been detected + if (view == self.progressView) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellLongPressOnProgressView userInfo:nil]; + } + else if (view == self.messageTextView || view == self.messageTextBackgroundView || view == self.attachmentView) + { + MXKRoomBubbleComponent *tappedComponent = [self closestBubbleComponentForGestureRecognizer:longPressGestureRecognizer locationInView:view]; + MXEvent *selectedEvent = tappedComponent.event; + + if (selectedEvent) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellLongPressOnEvent userInfo:@{kMXKRoomBubbleCellEventKey:selectedEvent}]; + } + } + else if (view == self.pictureView) + { + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellLongPressOnAvatarView userInfo:@{kMXKRoomBubbleCellUserIdKey: bubbleData.senderId}]; + } + else if (view == self.contentView) + { + // Check whether a bubble component is displayed at the level of the tapped line. + MXKRoomBubbleComponent *tappedComponent = nil; + + if (self.attachmentView) + { + // Check whether the user tapped on the side of the attachment. + tappedComponent = [self closestBubbleComponentForGestureRecognizer:longPressGestureRecognizer locationInView:self.attachmentView]; + } + else if (self.messageTextView) + { + // Check whether the user tapped in front of a text component. + tappedComponent = [self closestBubbleComponentForGestureRecognizer:longPressGestureRecognizer locationInView:self.messageTextView]; + } + else + { + tappedComponent = [self.bubbleData getFirstBubbleComponentWithDisplay]; + } + + [delegate cell:self didRecognizeAction:kMXKRoomBubbleCellLongPressOnEvent userInfo:(tappedComponent ? @{kMXKRoomBubbleCellEventKey:tappedComponent.event} : nil)]; + } + } +} + +#pragma mark - UITextView delegate + +- (BOOL)textView:(UITextView *)textView shouldInteractWithURL:(NSURL *)URL inRange:(NSRange)characterRange interaction:(UITextItemInteraction)interaction +{ + BOOL shouldInteractWithURL = YES; + + if (delegate && URL) + { + MXEvent *associatedEvent; + + if ([textView isMemberOfClass:[MXKMessageTextView class]]) + { + MXKMessageTextView *mxkMessageTextView = (MXKMessageTextView *)textView; + MXKRoomBubbleComponent *bubbleComponent = [self closestBubbleComponentAtPosition:mxkMessageTextView.lastHitTestLocation]; + associatedEvent = bubbleComponent.event; + } + + // Ask the delegate if iOS can open the link + shouldInteractWithURL = [self shouldInteractWithURL:URL urlItemInteraction:interaction associatedEvent:associatedEvent]; + } + + return shouldInteractWithURL; +} + +// Delegate method only called on iOS 9. iOS 10+ use method above. +- (BOOL)textView:(UITextView *)textView shouldInteractWithURL:(NSURL *)URL inRange:(NSRange)characterRange +{ + BOOL shouldInteractWithURL = YES; + + if (delegate && URL) + { + MXEvent *associatedEvent; + + if ([textView isMemberOfClass:[MXKMessageTextView class]]) + { + MXKMessageTextView *mxkMessageTextView = (MXKMessageTextView *)textView; + MXKRoomBubbleComponent *bubbleComponent = [self closestBubbleComponentAtPosition:mxkMessageTextView.lastHitTestLocation]; + associatedEvent = bubbleComponent.event; + } + + // Ask the delegate if iOS can open the link + shouldInteractWithURL = [self shouldInteractWithURL:URL urlItemInteractionValue:@(0) associatedEvent:associatedEvent]; + } + + return shouldInteractWithURL; +} + +#pragma mark - WKNavigationDelegate + +- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation +{ + if (webView == _attachmentWebView && self.attachmentView) + { + // The attachment webview is ready to replace the attachment view. + _attachmentWebView.hidden = NO; + self.attachmentView.image = nil; + } +} + +#pragma mark - UIGestureRecognizerDelegate + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch +{ + UIView *recognizerView = gestureRecognizer.view; + + if ([recognizerView isDescendantOfView:self.contentView]) + { + UIView *touchedView = touch.view; + + if ([touchedView isKindOfClass:[UIButton class]]) + { + return NO; + } + + // Prevent gesture recognizer to be recognized by a custom view added to the cell contentView and with user interaction enabled + for (UIView *tmpSubview in self.tmpSubviews) + { + if (tmpSubview.isUserInteractionEnabled && [tmpSubview isDescendantOfView:self.contentView]) + { + CGPoint touchedPoint = [touch locationInView:tmpSubview]; + + if (CGRectContainsPoint(tmpSubview.bounds, touchedPoint)) + { + return NO; + } + } + } + + // Prevent gesture recognizer to be recognized when user hits a link in a UITextView, let UITextViewDelegate handle links. + if ([touchedView isKindOfClass:[UITextView class]]) + { + UITextView *textView = (UITextView*)touchedView; + CGPoint touchLocation = [touch locationInView:textView]; + + return [textView isThereALinkNearPoint:touchLocation] == NO; + } + } + + return YES; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.h new file mode 100644 index 000000000..72a541cba --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.h @@ -0,0 +1,25 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleTableViewCell.h" + +/** + `MXKRoomEmptyBubbleTableViewCell` displays an empty bubbles without user's information. + This kind of bubble may be used to localize an event without display in the room history. + */ +@interface MXKRoomEmptyBubbleTableViewCell : MXKRoomBubbleTableViewCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.m new file mode 100644 index 000000000..54ce13bc6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.m @@ -0,0 +1,21 @@ +/* + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomEmptyBubbleTableViewCell.h" + +@implementation MXKRoomEmptyBubbleTableViewCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.xib new file mode 100644 index 000000000..bfcfca2e5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomEmptyBubbleTableViewCell.xib @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSBubbleTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSBubbleTableViewCell.h new file mode 100644 index 000000000..6f558f091 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSBubbleTableViewCell.h @@ -0,0 +1,26 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +#import "MXKCellRendering.h" + +/** + `MXKRoomIOSBubbleTableViewCell` instances mimic bubbles in the stock iOS messages application. + */ +@interface MXKRoomIOSBubbleTableViewCell : MXKTableViewCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSBubbleTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSBubbleTableViewCell.m new file mode 100644 index 000000000..7766a3294 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSBubbleTableViewCell.m @@ -0,0 +1,45 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIOSBubbleTableViewCell.h" + +#import "MXKRoomBubbleCellDataStoring.h" + +@implementation MXKRoomIOSBubbleTableViewCell + +- (void)render:(MXKCellData *)cellData +{ + id bubbleData = (id)cellData; + if (bubbleData) + { + self.textLabel.attributedText = bubbleData.attributedTextMessage; + } + else + { + self.textLabel.text = @""; + } + + // Light custo for now... @TODO + self.layer.cornerRadius = 20; + self.backgroundColor = [UIColor blueColor]; +} + ++ (CGFloat)heightForCellData:(MXKCellData *)cellData withMaximumWidth:(CGFloat)maxWidth +{ + return 44; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.h new file mode 100644 index 000000000..49d42ba45 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.h @@ -0,0 +1,36 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingBubbleTableViewCell.h" + +/** + `MXKRoomIOSBubbleTableViewCell` instances mimic bubbles in the stock iOS messages application. + It is dedicated to outgoing messages. + It subclasses `MXKRoomOutgoingBubbleTableViewCell` to take benefit of the available mechanic. + */ +@interface MXKRoomIOSOutgoingBubbleTableViewCell : MXKRoomOutgoingBubbleTableViewCell + +/** + The green bubble displayed in background. + */ +@property (weak, nonatomic) IBOutlet UIImageView *bubbleImageView; + +/** + The width constraint on this backgroung green bubble. + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *bubbleImageViewWidthConstraint; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.m new file mode 100644 index 000000000..cb45c7426 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.m @@ -0,0 +1,130 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIOSOutgoingBubbleTableViewCell.h" + +#import "MXKRoomBubbleCellData.h" + +#import "MXEvent+MatrixKit.h" +#import "MXKTools.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKImageView.h" + +#define OUTGOING_BUBBLE_COLOR 0x00e34d + +@implementation MXKRoomIOSOutgoingBubbleTableViewCell + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier +{ + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + if (self) + { + // Create the strechable background bubble + self.bubbleImageView.image = self.class.bubbleImage; + } + + return self; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; +} + +- (void)render:(MXKCellData *)cellData +{ + [super render:cellData]; + + // Reset values + self.bubbleImageView.hidden = NO; + + // Customise the data precomputed by the legacy classes + // Replace black color in texts by the white color expected for outgoing messages. + NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithAttributedString:self.messageTextView.attributedText]; + + // Change all attributes one by one + [attributedString enumerateAttributesInRange:NSMakeRange(0, attributedString.length) options:0 usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) + { + + // Replace only black colored texts + if (attrs[NSForegroundColorAttributeName] == self->bubbleData.eventFormatter.defaultTextColor) + { + + // By white + NSMutableDictionary *newAttrs = [NSMutableDictionary dictionaryWithDictionary:attrs]; + newAttrs[NSForegroundColorAttributeName] = [UIColor whiteColor]; + + [attributedString setAttributes:newAttrs range:range]; + } + }]; + + self.messageTextView.attributedText = attributedString; + + // Update the bubble width to include the text view + self.bubbleImageViewWidthConstraint.constant = bubbleData.contentSize.width + 17; + + // Limit bubble width + if (self.bubbleImageViewWidthConstraint.constant < 46) + { + self.bubbleImageViewWidthConstraint.constant = 46; + } + + // Mask the image with the bubble + if (bubbleData.attachment && bubbleData.attachment.type != MXKAttachmentTypeFile && bubbleData.attachment.type != MXKAttachmentTypeAudio) + { + self.bubbleImageView.hidden = YES; + + UIImageView *rightBubbleImageView = [[UIImageView alloc] initWithImage:self.class.bubbleImage]; + rightBubbleImageView.frame = CGRectMake(0, 0, self.bubbleImageViewWidthConstraint.constant, bubbleData.contentSize.height + self.attachViewTopConstraint.constant - 4); + + self.attachmentView.layer.mask = rightBubbleImageView.layer; + } +} + ++ (CGFloat)heightForCellData:(MXKCellData *)cellData withMaximumWidth:(CGFloat)maxWidth +{ + CGFloat rowHeight = [super heightForCellData:cellData withMaximumWidth:maxWidth]; + + CGFloat height = self.cellWithOriginalXib.frame.size.height; + + // Use the xib height as the minimal height + if (rowHeight < height) + { + rowHeight = height; + } + + return rowHeight; +} + +/** + Create the strechable background bubble. + + @return the bubble image. + */ ++ (UIImage *)bubbleImage +{ + UIImage *rightBubbleImage = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"bubble_ios_messages_right"]; + + rightBubbleImage = [MXKTools paintImage:rightBubbleImage + withColor:[MXKTools colorWithRGBValue:OUTGOING_BUBBLE_COLOR]]; + + UIEdgeInsets edgeInsets = UIEdgeInsetsMake(17, 22, 17, 27); + return [rightBubbleImage resizableImageWithCapInsets:edgeInsets]; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.xib new file mode 100644 index 000000000..042b9ec58 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIOSOutgoingBubbleTableViewCell.xib @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.h new file mode 100644 index 000000000..6819e8d6d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.h @@ -0,0 +1,24 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingBubbleTableViewCell.h" + +/** + `MXKRoomIncomingAttachmentBubbleCell` displays incoming attachment bubbles with sender's information. + */ +@interface MXKRoomIncomingAttachmentBubbleCell : MXKRoomIncomingBubbleTableViewCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.m new file mode 100644 index 000000000..ecb9eaa3a --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingAttachmentBubbleCell.h" + +@implementation MXKRoomIncomingAttachmentBubbleCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.xib new file mode 100644 index 000000000..ee13c949d --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentBubbleCell.xib @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.h new file mode 100644 index 000000000..3d4bbb06b --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.h @@ -0,0 +1,24 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingAttachmentBubbleCell.h" + +/** + `MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell` displays incoming message bubbles without sender's information. + */ +@interface MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell : MXKRoomIncomingAttachmentBubbleCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.m new file mode 100644 index 000000000..6171e49da --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.h" + +@implementation MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.xib new file mode 100644 index 000000000..0dd044e50 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingAttachmentWithoutSenderInfoBubbleCell.xib @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingBubbleTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingBubbleTableViewCell.h new file mode 100644 index 000000000..27d886cbe --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingBubbleTableViewCell.h @@ -0,0 +1,29 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleTableViewCell.h" + +/** + `MXKRoomIncomingBubbleTableViewCell` inherits from 'MXKRoomBubbleTableViewCell' class in order to handle specific + options related to incoming messages (like typing badge). + + In order to optimize bubbles rendering, we advise to define a .xib for each layout. + */ +@interface MXKRoomIncomingBubbleTableViewCell : MXKRoomBubbleTableViewCell + +@property (weak, nonatomic) IBOutlet UIImageView *typingBadge; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingBubbleTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingBubbleTableViewCell.m new file mode 100644 index 000000000..0f3c85106 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingBubbleTableViewCell.m @@ -0,0 +1,65 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingBubbleTableViewCell.h" + +#import "MXKRoomBubbleCellData.h" + +#import "NSBundle+MatrixKit.h" + +@implementation MXKRoomIncomingBubbleTableViewCell + +- (void)finalizeInit +{ + [super finalizeInit]; + self.readReceiptsAlignment = ReadReceiptAlignmentRight; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + self.typingBadge.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_keyboard"]; +} + +- (void)render:(MXKCellData *)cellData +{ + [super render:cellData]; + + if (bubbleData) + { + // Handle here typing badge (if any) + if (self.typingBadge) + { + if (bubbleData.isTyping) + { + self.typingBadge.hidden = NO; + [self.typingBadge.superview bringSubviewToFront:self.typingBadge]; + } + else + { + self.typingBadge.hidden = YES; + } + } + } +} + +- (void)didEndDisplay +{ + [super didEndDisplay]; + self.readReceiptsAlignment = ReadReceiptAlignmentRight; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.h new file mode 100644 index 000000000..9fe96c917 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.h @@ -0,0 +1,24 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingBubbleTableViewCell.h" + +/** + `MXKRoomIncomingTextMsgBubbleCell` displays incoming message bubbles with sender's information. + */ +@interface MXKRoomIncomingTextMsgBubbleCell : MXKRoomIncomingBubbleTableViewCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.m new file mode 100644 index 000000000..253e7141f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingTextMsgBubbleCell.h" + +@implementation MXKRoomIncomingTextMsgBubbleCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.xib new file mode 100644 index 000000000..311dc21a7 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgBubbleCell.xib @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.h new file mode 100644 index 000000000..41c3be5cf --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.h @@ -0,0 +1,24 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingTextMsgBubbleCell.h" + +/** + `MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell` displays incoming message bubbles without sender's information. + */ +@interface MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell : MXKRoomIncomingTextMsgBubbleCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.m new file mode 100644 index 000000000..4dd146546 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.h" + +@implementation MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.xib new file mode 100644 index 000000000..93308ad9a --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomIncomingTextMsgWithoutSenderInfoBubbleCell.xib @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.h new file mode 100644 index 000000000..222d626af --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.h @@ -0,0 +1,26 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingBubbleTableViewCell.h" + +/** + `MXKRoomOutgoingAttachmentBubbleCell` displays outgoing attachment bubbles. + */ +@interface MXKRoomOutgoingAttachmentBubbleCell : MXKRoomOutgoingBubbleTableViewCell + +@property (weak, nonatomic) IBOutlet UIActivityIndicatorView *activityIndicator; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.m new file mode 100644 index 000000000..9a3e1cfe1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.m @@ -0,0 +1,144 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingAttachmentBubbleCell.h" + +#import "MXEvent+MatrixKit.h" + +#import "MXKRoomBubbleCellData.h" +#import "MXKImageView.h" +#import "MXKPieChartView.h" + +@implementation MXKRoomOutgoingAttachmentBubbleCell + +- (void)dealloc +{ + [self stopAnimating]; +} + +- (void)render:(MXKCellData *)cellData +{ + [super render:cellData]; + + if (bubbleData) + { + // Do not display activity indicator on outgoing attachments (These attachments are supposed to be stored locally) + // Some download may append to retrieve the actual thumbnail after posting an image. + self.attachmentView.hideActivityIndicator = YES; + + // Check if the attachment is uploading + MXKRoomBubbleComponent *component = bubbleData.bubbleComponents.firstObject; + if (component.event.sentState == MXEventSentStatePreparing || component.event.sentState == MXEventSentStateEncrypting || component.event.sentState == MXEventSentStateUploading) + { + // Retrieve the uploadId embedded in the fake url + bubbleData.uploadId = component.event.content[@"url"]; + + self.attachmentView.alpha = 0.5; + + // Start showing upload progress + [self startUploadAnimating]; + } + else if (component.event.sentState == MXEventSentStateSending) + { + self.attachmentView.alpha = 0.5; + [self.activityIndicator startAnimating]; + } + else if (component.event.sentState == MXEventSentStateFailed) + { + self.attachmentView.alpha = 0.5; + [self.activityIndicator stopAnimating]; + } + else + { + self.attachmentView.alpha = 1; + [self.activityIndicator stopAnimating]; + } + } +} + +- (void)didEndDisplay +{ + [super didEndDisplay]; + + // Hide potential loading wheel + [self stopAnimating]; +} + +-(void)startUploadAnimating +{ + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + + [self.activityIndicator startAnimating]; + + MXMediaLoader *uploader = [MXMediaManager existingUploaderWithId:bubbleData.uploadId]; + if (uploader) + { + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onMediaLoaderStateDidChange:) name:kMXMediaLoaderStateDidChangeNotification object:uploader]; + + if (uploader.statisticsDict) + { + [self.activityIndicator stopAnimating]; + [self updateProgressUI:uploader.statisticsDict]; + + // Check whether the upload is ended + if (self.progressChartView.progress == 1.0) + { + [self stopAnimating]; + } + } + } + else + { + self.progressView.hidden = YES; + } +} + +-(void)stopAnimating +{ + self.progressView.hidden = YES; + + [[NSNotificationCenter defaultCenter] removeObserver:self name:kMXMediaLoaderStateDidChangeNotification object:nil]; + [self.activityIndicator stopAnimating]; +} + +- (void)onMediaLoaderStateDidChange:(NSNotification *)notif +{ + MXMediaLoader *loader = (MXMediaLoader*)notif.object; + + // Consider only the progress of the current upload. + if ([loader.uploadId isEqualToString:bubbleData.uploadId]) + { + switch (loader.state) { + case MXMediaLoaderStateUploadInProgress: + { + [self.activityIndicator stopAnimating]; + [self updateProgressUI:loader.statisticsDict]; + + // the upload is ended + if (self.progressChartView.progress == 1.0) + { + [self stopAnimating]; + } + break; + } + default: + break; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.xib new file mode 100644 index 000000000..d4246c3e3 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentBubbleCell.xib @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.h new file mode 100644 index 000000000..61522176f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.h @@ -0,0 +1,24 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingAttachmentBubbleCell.h" + +/** + `MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell` displays outgoing attachment with thumbnail, without user's name. + */ +@interface MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell : MXKRoomOutgoingAttachmentBubbleCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.m new file mode 100644 index 000000000..eb9f8ac67 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.h" + +@implementation MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.xib new file mode 100644 index 000000000..e02de0455 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingAttachmentWithoutSenderInfoBubbleCell.xib @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingBubbleTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingBubbleTableViewCell.h new file mode 100644 index 000000000..dd9d05285 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingBubbleTableViewCell.h @@ -0,0 +1,27 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomBubbleTableViewCell.h" + +/** + `MXKRoomOutgoingBubbleTableViewCell` inherits from 'MXKRoomBubbleTableViewCell' class in order to handle specific + options related to outgoing messages (like unsent labels, upload progress in case of attachment). + + In order to optimize bubbles rendering, we advise to define a .xib for each layout. + */ +@interface MXKRoomOutgoingBubbleTableViewCell : MXKRoomBubbleTableViewCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingBubbleTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingBubbleTableViewCell.m new file mode 100644 index 000000000..9c74f6aff --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingBubbleTableViewCell.m @@ -0,0 +1,117 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingBubbleTableViewCell.h" + +#import "MXEvent+MatrixKit.h" + +#import "NSBundle+Matrixkit.h" + +#import "MXKRoomBubbleCellData.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKRoomOutgoingBubbleTableViewCell + +- (void)render:(MXKCellData *)cellData +{ + [super render:cellData]; + + if (bubbleData) + { + // Add unsent label for failed components (except if the app customizes it) + if (self.bubbleInfoContainer && (bubbleData.useCustomUnsentButton == NO)) + { + for (MXKRoomBubbleComponent *component in bubbleData.bubbleComponents) + { + if (component.event.sentState == MXEventSentStateFailed) + { + UIButton *unsentButton = [[UIButton alloc] initWithFrame:CGRectMake(0, component.position.y, 58 , 20)]; + + [unsentButton setTitle:[MatrixKitL10n unsent] forState:UIControlStateNormal]; + [unsentButton setTitle:[MatrixKitL10n unsent] forState:UIControlStateSelected]; + [unsentButton setTitleColor:[UIColor redColor] forState:UIControlStateNormal]; + [unsentButton setTitleColor:[UIColor redColor] forState:UIControlStateSelected]; + + unsentButton.backgroundColor = [UIColor whiteColor]; + unsentButton.titleLabel.font = [UIFont systemFontOfSize:14]; + + [unsentButton addTarget:self action:@selector(onResendToggle:) forControlEvents:UIControlEventTouchUpInside]; + + [self.bubbleInfoContainer addSubview:unsentButton]; + self.bubbleInfoContainer.hidden = NO; + self.bubbleInfoContainer.userInteractionEnabled = YES; + + // ensure that bubbleInfoContainer is at front to catch the tap event + [self.bubbleInfoContainer.superview bringSubviewToFront:self.bubbleInfoContainer]; + } + } + } + } +} + +- (void)didEndDisplay +{ + [super didEndDisplay]; + + self.bubbleInfoContainer.userInteractionEnabled = NO; +} + +#pragma mark - User actions + +- (IBAction)onResendToggle:(id)sender +{ + if ([sender isKindOfClass:[UIButton class]] && self.delegate) + { + MXEvent *selectedEvent = nil; + + NSArray *bubbleComponents = bubbleData.bubbleComponents; + + if (bubbleComponents.count == 1) + { + MXKRoomBubbleComponent *component = [bubbleComponents firstObject]; + selectedEvent = component.event; + } + else if (bubbleComponents.count) + { + // Here the selected view is a textView (attachment has no more than one component) + + // Look for the selected component + UIButton *unsentButton = (UIButton *)sender; + for (MXKRoomBubbleComponent *component in bubbleComponents) + { + // Ignore components without display. + if (!component.attributedTextMessage) + { + continue; + } + + if (unsentButton.frame.origin.y == component.position.y) + { + selectedEvent = component.event; + break; + } + } + } + + if (selectedEvent) + { + [self.delegate cell:self didRecognizeAction:kMXKRoomBubbleCellUnsentButtonPressed userInfo:@{kMXKRoomBubbleCellEventKey:selectedEvent}]; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.h new file mode 100644 index 000000000..f6665daeb --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.h @@ -0,0 +1,24 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingBubbleTableViewCell.h" + +/** + `MXKRoomOutgoingTextMsgBubbleCell` displays outgoing message bubbles with user's picture. + */ +@interface MXKRoomOutgoingTextMsgBubbleCell : MXKRoomOutgoingBubbleTableViewCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.m new file mode 100644 index 000000000..232813182 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingTextMsgBubbleCell.h" + +@implementation MXKRoomOutgoingTextMsgBubbleCell + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.xib new file mode 100644 index 000000000..e6a54821f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgBubbleCell.xib @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.h b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.h new file mode 100644 index 000000000..685471b71 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.h @@ -0,0 +1,24 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingTextMsgBubbleCell.h" + +/** + `MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell` displays outgoing message bubbles without user's name. + */ +@interface MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell : MXKRoomOutgoingTextMsgBubbleCell + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.m b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.m new file mode 100644 index 000000000..41250df3f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.m @@ -0,0 +1,21 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.h" + +@implementation MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.xib b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.xib new file mode 100644 index 000000000..b626c5077 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomBubbleList/MXKRoomOutgoingTextMsgWithoutSenderInfoBubbleCell.xib @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h new file mode 100644 index 000000000..3572b95e5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h @@ -0,0 +1,358 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import + +#import "MXKView.h" + +/** + List the predefined modes to handle the size of attached images + */ +typedef enum : NSUInteger +{ + /** + Prompt the user to select the compression level + */ + MXKRoomInputToolbarCompressionModePrompt, + + /** + The compression level is fixed for the following modes + */ + MXKRoomInputToolbarCompressionModeSmall, + MXKRoomInputToolbarCompressionModeMedium, + MXKRoomInputToolbarCompressionModeLarge, + + /** + No compression, the original image is sent + */ + MXKRoomInputToolbarCompressionModeNone + +} MXKRoomInputToolbarCompressionMode; + + +@class MXKRoomInputToolbarView; +@protocol MXKRoomInputToolbarViewDelegate + +/** + Tells the delegate that an alert must be presented. + + @param toolbarView the room input toolbar view. + @param alertController the alert to present. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView presentAlertController:(UIAlertController*)alertController; + +/** + Tells the delegate that the visibility of the status bar must be changed. + + @param toolbarView the room input toolbar view. + @param isHidden tell whether the status bar must be hidden or not. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView hideStatusBar:(BOOL)isHidden; + +@optional + +/** + Tells the delegate that the user is typing or has finished typing. + + @param toolbarView the room input toolbar view + @param typing YES if the user is typing inside the message composer. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView isTyping:(BOOL)typing; + +/** + Tells the delegate that toolbar height has been updated. + + @param toolbarView the room input toolbar view. + @param height the updated height of toolbar view. + @param completion a block object to be executed when height change is taken into account. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView heightDidChanged:(CGFloat)height completion:(void (^)(BOOL finished))completion; + +/** + Tells the delegate that the user wants to send a text message. + + @param toolbarView the room input toolbar view. + @param textMessage the string to send. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendTextMessage:(NSString*)textMessage; + +/** + Tells the delegate that the user wants to send an image. + + @param toolbarView the room input toolbar view. + @param image the UIImage hosting the image data to send. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendImage:(UIImage*)image; + +/** + Tells the delegate that the user wants to send an image. + + @param toolbarView the room input toolbar view. + @param imageData the full-sized image data of the image. + @param mimetype image mime type + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendImage:(NSData*)imageData withMimeType:(NSString*)mimetype; + +/** + Tells the delegate that the user wants to send a video. + + @param toolbarView the room input toolbar view. + @param videoLocalURL the local filesystem path of the video to send. + @param videoThumbnail the UIImage hosting a video thumbnail. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendVideo:(NSURL*)videoLocalURL withThumbnail:(UIImage*)videoThumbnail; + +/** + Tells the delegate that the user wants to send a video. + + @param toolbarView the room input toolbar view. + @param videoAsset the AVAsset that represents the video to send. + @param videoThumbnail the UIImage hosting a video thumbnail. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendVideoAsset:(AVAsset*)videoAsset withThumbnail:(UIImage*)videoThumbnail; + +/** + Tells the delegate that the user wants to send a file. + + @param toolbarView the room input toolbar view. + @param fileLocalURL the local filesystem path of the file to send. + @param mimetype file mime type + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendFile:(NSURL*)fileLocalURL withMimeType:(NSString*)mimetype; + +/** + Tells the delegate that the user wants invite a matrix user. + + Note: `Invite matrix user` option is displayed in actions list only if the delegate implements this method. + + @param toolbarView the room input toolbar view. + @param mxUserId the Matrix user id. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView inviteMatrixUser:(NSString*)mxUserId; + +/** + Tells the delegate that the user wants to place a voice or a video call. + + @param toolbarView the room input toolbar view. + @param video YES to make a video call. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView placeCallWithVideo:(BOOL)video; + +/** + Tells the delegate that the user wants to hangup the current call. + + @param toolbarView the room input toolbar view. + */ +- (void)roomInputToolbarViewHangupCall:(MXKRoomInputToolbarView*)toolbarView; + +/** + Tells the delegate to present a view controller modally. + + Note: Media attachment is available only if the delegate implements this method. + + @param toolbarView the room input toolbar view. + @param viewControllerToPresent the view controller to present. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView presentViewController:(UIViewController*)viewControllerToPresent; + +/** + Tells the delegate to dismiss the view controller that was presented modally + + @param toolbarView the room input toolbar view. + @param flag Pass YES to animate the transition. + @param completion The block to execute after the view controller is dismissed. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView dismissViewControllerAnimated:(BOOL)flag completion:(void (^)(void))completion; + +/** + Tells the delegate to start or stop an activity indicator. + + @param toolbarView the room input toolbar view + @param isAnimating YES if the activity indicator should run. + */ +- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView updateActivityIndicator:(BOOL)isAnimating; + +@end + +/** + `MXKRoomInputToolbarView` instance is a view used to handle all kinds of available inputs + for a room (message composer, attachments selection...). + + By default the right button of the toolbar offers the following options: attach media, invite new members. + By default the left button is used to send the content of the message composer. + By default 'messageComposerContainer' is empty. + */ +@interface MXKRoomInputToolbarView : MXKView { + /** + The message composer container view. Your own message composer may be added inside this container. + */ + UIView *messageComposerContainer; + +@protected + UIView *inputAccessoryView; +} + +/** + * Returns the `UINib` object initialized for the tool bar view. + * + * @return The initialized `UINib` object or `nil` if there were errors during + * initialization or the nib file could not be located. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRoomInputToolbarView-inherited` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRoomInputToolbarView-inherited` object if successful, `nil` otherwise. + */ ++ (instancetype)roomInputToolbarView; + +/** + The delegate notified when inputs are ready. + */ +@property (weak, nonatomic) id delegate; + +/** + A custom button displayed on the left of the toolbar view. + */ +@property (weak, nonatomic) IBOutlet UIButton *leftInputToolbarButton; + +/** + A custom button displayed on the right of the toolbar view. + */ +@property (weak, nonatomic) IBOutlet UIButton *rightInputToolbarButton; + +/** + Layout constraint between the top of the message composer container and the top of its superview. + The first view is the container, the second is the superview. + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *messageComposerContainerTopConstraint; + +/** + Layout constraint between the bottom of the message composer container and the bottom of its superview. + The first view is the superview, the second is the container. + */ +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *messageComposerContainerBottomConstraint; + +/** + Tell whether the sent images and videos should be automatically saved in the user's photos library. NO by default. + */ +@property (nonatomic) BOOL enableAutoSaving; + +/** + Tell whether the text is editable. YES by default. + */ +@property(nonatomic, getter=isEditable) BOOL editable; + +/** + `onTouchUpInside` action is registered on `Touch Up Inside` event for both buttons (left and right input toolbar buttons). + Override this method to customize user interaction handling + + @param button the event sender + */ +- (IBAction)onTouchUpInside:(UIButton*)button; + +/** + Handle image attachment + Save the image in user's photos library when 'isPhotoLibraryAsset' flag is NO and auto saving is enabled. + + @param imageData the full-sized image data of the selected image. + @param mimetype the image MIME type (nil if unknown). + @param compressionMode the compression mode to apply on this image. This option is considered only for jpeg image. + @param isPhotoLibraryAsset tell whether the image has been selected from the user's photos library or not. + */ +- (void)sendSelectedImage:(NSData*)imageData withMimeType:(NSString *)mimetype andCompressionMode:(MXKRoomInputToolbarCompressionMode)compressionMode isPhotoLibraryAsset:(BOOL)isPhotoLibraryAsset; + +/** + Handle video attachment. + Save the video in user's photos library when 'isPhotoLibraryAsset' flag is NO and auto saving is enabled. + + @param selectedVideo the local url of the video to send. + @param isPhotoLibraryAsset tell whether the video has been selected from user's photos library. + */ +- (void)sendSelectedVideo:(NSURL*)selectedVideo isPhotoLibraryAsset:(BOOL)isPhotoLibraryAsset; + +/** + Handle video attachment. + Save the video in user's photos library when 'isPhotoLibraryAsset' flag is NO and auto saving is enabled. + + @param selectedVideo an AVAsset that represents the video to send. + @param isPhotoLibraryAsset tell whether the video has been selected from user's photos library. + */ +- (void)sendSelectedVideoAsset:(AVAsset*)selectedVideo isPhotoLibraryAsset:(BOOL)isPhotoLibraryAsset; + +/** + Handle multiple media attachments according to the compression mode. + + @param assets the selected assets. + @param compressionMode the compression mode to apply on the media. This option is considered only for jpeg image. + */ +- (void)sendSelectedAssets:(NSArray*)assets withCompressionMode:(MXKRoomInputToolbarCompressionMode)compressionMode; + +/** + The maximum height of the toolbar. + A value <= 0 means no limit. + */ +@property CGFloat maxHeight; + +/** + The current text message in message composer. + */ +@property NSString *textMessage; + +/** + The string that should be displayed when there is no other text in message composer. + This property may be ignored when message composer does not support placeholder display. + */ +@property (nonatomic) NSString *placeholder; + +/** + The custom accessory view associated with the message composer. This view is + actually used to retrieve the keyboard view. Indeed the keyboard view is the superview of + the accessory view when the message composer become the first responder. + */ +@property (readonly) UIView *inputAccessoryView; + +/** + Display the keyboard. + */ +- (BOOL)becomeFirstResponder; + +/** + Force dismiss keyboard. + */ +- (void)dismissKeyboard; + +/** + Dispose any resources and listener. + */ +- (void)destroy; + +/** + Paste a text in textMessage. + + The text is pasted at the current cursor location in the message composer or it + replaces the currently selected text. + + @param text the text to paste. + */ +- (void)pasteText:(NSString*)text; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m new file mode 100644 index 000000000..4c3924d96 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m @@ -0,0 +1,1399 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomInputToolbarView.h" +#import "MXKSwiftHeader.h" +#import "MXKAppSettings.h" + +@import MatrixSDK.MXMediaManager; +@import MediaPlayer; +@import MobileCoreServices; +@import Photos; + +#import "MXKImageView.h" + +#import "MXKTools.h" + +#import "NSBundle+MatrixKit.h" +#import "MXKConstants.h" + +@interface MXKRoomInputToolbarView() +{ + /** + Alert used to list options. + */ + UIAlertController *optionsListView; + + /** + Current media picker + */ + UIImagePickerController *mediaPicker; + + /** + Array of validation views (MXKImageView instances) + */ + NSMutableArray *validationViews; + + /** + Handle images attachment + */ + UIAlertController *compressionPrompt; + NSMutableArray *pendingImages; +} + +@property (nonatomic) IBOutlet UIView *messageComposerContainer; + +@end + +@implementation MXKRoomInputToolbarView +@synthesize messageComposerContainer, inputAccessoryView; + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomInputToolbarView class]) + bundle:[NSBundle bundleForClass:[MXKRoomInputToolbarView class]]]; +} + ++ (instancetype)roomInputToolbarView +{ + if ([[self class] nib]) + { + return [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; + } + else + { + return [[self alloc] init]; + } +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + // Finalize setup + [self setTranslatesAutoresizingMaskIntoConstraints: NO]; + + // Disable send button + self.rightInputToolbarButton.enabled = NO; + + // Enable text edition by default + self.editable = YES; + + // Localize string + [_rightInputToolbarButton setTitle:[MatrixKitL10n send] forState:UIControlStateNormal]; + [_rightInputToolbarButton setTitle:[MatrixKitL10n send] forState:UIControlStateHighlighted]; + + validationViews = [NSMutableArray array]; +} + +- (void)dealloc +{ + inputAccessoryView = nil; + + [self destroy]; +} + +#pragma mark - Override MXKView + +-(void)customizeViewRendering +{ + [super customizeViewRendering]; + + // Reset default container background color + messageComposerContainer.backgroundColor = [UIColor clearColor]; + + // Set default toolbar background color + self.backgroundColor = [UIColor colorWithRed:0.9 green:0.9 blue:0.9 alpha:1.0]; +} + +#pragma mark - + +- (IBAction)onTouchUpInside:(UIButton*)button +{ + if (button == self.leftInputToolbarButton) + { + if (optionsListView) + { + [optionsListView dismissViewControllerAnimated:NO completion:nil]; + optionsListView = nil; + } + + // Option button has been pressed + // List available options + __weak typeof(self) weakSelf = self; + + // Check whether media attachment is supported + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:presentViewController:)]) + { + optionsListView = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + + [optionsListView addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n attachMedia] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->optionsListView = nil; + + // Open media gallery + self->mediaPicker = [[UIImagePickerController alloc] init]; + self->mediaPicker.delegate = self; + self->mediaPicker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary; + self->mediaPicker.allowsEditing = NO; + self->mediaPicker.mediaTypes = [NSArray arrayWithObjects:(NSString *)kUTTypeImage, (NSString *)kUTTypeMovie, nil]; + [self.delegate roomInputToolbarView:self presentViewController:self->mediaPicker]; + } + + }]]; + + [optionsListView addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n captureMedia] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->optionsListView = nil; + + // Open Camera + self->mediaPicker = [[UIImagePickerController alloc] init]; + self->mediaPicker.delegate = self; + self->mediaPicker.sourceType = UIImagePickerControllerSourceTypeCamera; + self->mediaPicker.allowsEditing = NO; + self->mediaPicker.mediaTypes = [NSArray arrayWithObjects:(NSString *)kUTTypeImage, (NSString *)kUTTypeMovie, nil]; + [self.delegate roomInputToolbarView:self presentViewController:self->mediaPicker]; + } + + }]]; + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] Attach media is not supported"); + } + + // Check whether user invitation is supported + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:inviteMatrixUser:)]) + { + if (!optionsListView) + { + optionsListView = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + } + + [optionsListView addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n inviteUser] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Ask for userId to invite + self->optionsListView = [UIAlertController alertControllerWithTitle:[MatrixKitL10n userIdTitle] message:nil preferredStyle:UIAlertControllerStyleAlert]; + + + [self->optionsListView addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->optionsListView = nil; + } + + }]]; + + [self->optionsListView addTextFieldWithConfigurationHandler:^(UITextField *textField) { + + textField.secureTextEntry = NO; + textField.placeholder = [MatrixKitL10n userIdPlaceholder]; + + }]; + + [self->optionsListView addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n invite] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + UITextField *textField = [self->optionsListView textFields].firstObject; + NSString *userId = textField.text; + + self->optionsListView = nil; + + if (userId.length) + { + [self.delegate roomInputToolbarView:self inviteMatrixUser:userId]; + } + } + + }]]; + + [self.delegate roomInputToolbarView:self presentAlertController:self->optionsListView]; + } + + }]]; + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] Invitation is not supported"); + } + + if (optionsListView) + { + + [self->optionsListView addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] style:UIAlertActionStyleDefault handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + self->optionsListView = nil; + } + + }]]; + + [optionsListView popoverPresentationController].sourceView = button; + [optionsListView popoverPresentationController].sourceRect = button.bounds; + [self.delegate roomInputToolbarView:self presentAlertController:optionsListView]; + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] No option is supported"); + } + } + else if (button == self.rightInputToolbarButton && self.textMessage.length) + { + // This forces an autocorrect event to happen when "Send" is pressed, which is necessary to accept a pending correction on send + self.textMessage = [NSString stringWithFormat:@"%@ ", self.textMessage]; + self.textMessage = [self.textMessage substringToIndex:[self.textMessage length]-1]; + + NSString *message = self.textMessage; + + // Reset message, disable view animation during the update to prevent placeholder distorsion. + [UIView setAnimationsEnabled:NO]; + self.textMessage = nil; + [UIView setAnimationsEnabled:YES]; + + // Send button has been pressed + if (message.length && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendTextMessage:)]) + { + [self.delegate roomInputToolbarView:self sendTextMessage:message]; + } + } +} + +- (void)setPlaceholder:(NSString *)inPlaceholder +{ + _placeholder = inPlaceholder; +} + +- (BOOL)becomeFirstResponder +{ + return NO; +} + +- (void)dismissKeyboard +{ + +} + +- (void)dismissCompressionPrompt +{ + if (compressionPrompt) + { + [compressionPrompt dismissViewControllerAnimated:NO completion:nil]; + compressionPrompt = nil; + } + + if (pendingImages.count) + { + NSData *firstImage = pendingImages.firstObject; + [pendingImages removeObjectAtIndex:0]; + [self sendImage:firstImage withCompressionMode:MXKRoomInputToolbarCompressionModePrompt]; + } +} + +- (void)destroy +{ + [self dismissValidationViews]; + validationViews = nil; + + if (optionsListView) + { + [optionsListView dismissViewControllerAnimated:NO completion:nil]; + optionsListView = nil; + } + + [self dismissMediaPicker]; + + self.delegate = nil; + + pendingImages = nil; + [self dismissCompressionPrompt]; +} + +- (void)pasteText:(NSString *)text +{ + // We cannot do more than appending text to self.textMessage + // Let 'MXKRoomInputToolbarView' children classes do the job + self.textMessage = [NSString stringWithFormat:@"%@%@", self.textMessage, text]; +} + + +#pragma mark - MXKFileSizes + +/** + Structure representing the file sizes of a media according to different level of + compression. + */ +typedef struct +{ + NSUInteger small; + NSUInteger medium; + NSUInteger large; + NSUInteger original; + +} MXKFileSizes; + +void MXKFileSizes_init(MXKFileSizes *sizes) +{ + memset(sizes, 0, sizeof(MXKFileSizes)); +} + +MXKFileSizes MXKFileSizes_add(MXKFileSizes sizes1, MXKFileSizes sizes2) +{ + MXKFileSizes sizes; + sizes.small = sizes1.small + sizes2.small; + sizes.medium = sizes1.medium + sizes2.medium; + sizes.large = sizes1.large + sizes2.large; + sizes.original = sizes1.original + sizes2.original; + + return sizes; +} + +NSString* MXKFileSizes_description(MXKFileSizes sizes) +{ + return [NSString stringWithFormat:@"small: %tu - medium: %tu - large: %tu - original: %tu", sizes.small, sizes.medium, sizes.large, sizes.original]; +} + +- (void)availableCompressionSizesForAsset:(PHAsset*)asset onComplete:(void(^)(MXKFileSizes sizes))onComplete +{ + __block MXKFileSizes sizes; + MXKFileSizes_init(&sizes); + + if (asset.mediaType == PHAssetMediaTypeImage) + { + PHImageRequestOptions *options = [[PHImageRequestOptions alloc] init]; + options.synchronous = NO; + options.networkAccessAllowed = YES; + + [[PHImageManager defaultManager] requestImageDataForAsset:asset options:options resultHandler:^(NSData * _Nullable imageData, NSString * _Nullable dataUTI, UIImageOrientation orientation, NSDictionary * _Nullable info) { + + if (imageData) + { + MXLogDebug(@"[MXKRoomInputToolbarView] availableCompressionSizesForAsset: Got image data"); + + UIImage *image = [UIImage imageWithData:imageData]; + + MXKImageCompressionSizes compressionSizes = [MXKTools availableCompressionSizesForImage:image originalFileSize:imageData.length]; + + sizes.small = compressionSizes.small.fileSize; + sizes.medium = compressionSizes.medium.fileSize; + sizes.large = compressionSizes.large.fileSize; + sizes.original = compressionSizes.original.fileSize; + + onComplete(sizes); + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] availableCompressionSizesForAsset: Failed to get image data"); + + // Notify user + NSError *error = info[@"PHImageErrorKey"]; + if (error.userInfo[NSUnderlyingErrorKey]) + { + error = error.userInfo[NSUnderlyingErrorKey]; + } + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + onComplete(sizes); + } + + }]; + } + else if (asset.mediaType == PHAssetMediaTypeVideo) + { + PHVideoRequestOptions *options = [[PHVideoRequestOptions alloc] init]; + options.networkAccessAllowed = YES; + + [[PHImageManager defaultManager] requestAVAssetForVideo:asset options:options resultHandler:^(AVAsset *asset, AVAudioMix *audioMix, NSDictionary *info) { + + if ([asset isKindOfClass:[AVURLAsset class]]) + { + MXLogDebug(@"[MXKRoomInputToolbarView] availableCompressionSizesForAsset: Got video data"); + AVURLAsset* urlAsset = (AVURLAsset*)asset; + + NSNumber *size; + [urlAsset.URL getResourceValue:&size forKey:NSURLFileSizeKey error:nil]; + + sizes.original = size.unsignedIntegerValue; + sizes.small = sizes.original; + sizes.medium = sizes.original; + sizes.large = sizes.original; + + dispatch_async(dispatch_get_main_queue(), ^{ + onComplete(sizes); + }); + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] availableCompressionSizesForAsset: Failed to get video data"); + + // Notify user + NSError *error = info[@"PHImageErrorKey"]; + if (error.userInfo[NSUnderlyingErrorKey]) + { + error = error.userInfo[NSUnderlyingErrorKey]; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + onComplete(sizes); + + }); + } + + }]; + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] availableCompressionSizesForAsset: unexpected media type"); + onComplete(sizes); + } +} + + +- (void)availableCompressionSizesForAssets:(NSMutableArray*)checkedAssets index:(NSUInteger)index appendTo:(MXKFileSizes)sizes onComplete:(void(^)(NSArray*checkedAssets, MXKFileSizes fileSizes))onComplete +{ + [self availableCompressionSizesForAsset:checkedAssets[index] onComplete:^(MXKFileSizes assetSizes) { + + MXKFileSizes intermediateSizes; + NSUInteger nextIndex; + + if (assetSizes.original == 0) + { + // Ignore this asset + [checkedAssets removeObjectAtIndex:index]; + intermediateSizes = sizes; + nextIndex = index; + } + else + { + intermediateSizes = MXKFileSizes_add(sizes, assetSizes); + nextIndex = index + 1; + } + + if (nextIndex == checkedAssets.count) + { + // Filter the sizes that are similar + if (intermediateSizes.medium >= intermediateSizes.large || intermediateSizes.large >= intermediateSizes.original) + { + intermediateSizes.large = 0; + } + if (intermediateSizes.small >= intermediateSizes.medium || intermediateSizes.medium >= intermediateSizes.original) + { + intermediateSizes.medium = 0; + } + if (intermediateSizes.small >= intermediateSizes.original) + { + intermediateSizes.small = 0; + } + + onComplete(checkedAssets, intermediateSizes); + } + else + { + [self availableCompressionSizesForAssets:checkedAssets index:nextIndex appendTo:intermediateSizes onComplete:onComplete]; + } + }]; +} + +- (void)availableCompressionSizesForAssets:(NSArray*)assets onComplete:(void(^)(NSArray*checkedAssets, MXKFileSizes fileSizes))onComplete +{ + __block MXKFileSizes sizes; + MXKFileSizes_init(&sizes); + + NSMutableArray *checkedAssets = [NSMutableArray arrayWithArray:assets]; + + [self availableCompressionSizesForAssets:checkedAssets index:0 appendTo:sizes onComplete:onComplete]; +} + +#pragma mark - Attachment handling + +- (void)sendSelectedImage:(NSData*)imageData withMimeType:(NSString *)mimetype andCompressionMode:(MXKRoomInputToolbarCompressionMode)compressionMode isPhotoLibraryAsset:(BOOL)isPhotoLibraryAsset +{ + // Check condition before saving this media in user's library + if (_enableAutoSaving && !isPhotoLibraryAsset) + { + // Save the original image in user's photos library + UIImage *image = [UIImage imageWithData:imageData]; + [MXMediaManager saveImageToPhotosLibrary:image success:nil failure:nil]; + } + + // Send data without compression if the image type is not jpeg + // Force compression for a heic image so that we generate jpeg from it + if (mimetype + && [mimetype isEqualToString:@"image/jpeg"] == NO + && [mimetype isEqualToString:@"image/heic"] == NO + && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendImage:withMimeType:)]) + { + [self.delegate roomInputToolbarView:self sendImage:imageData withMimeType:mimetype]; + } + else + { + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:sendImage:)]) + { + [self sendImage:imageData withCompressionMode:compressionMode]; + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] Attach image is not supported"); + } + } +} + +- (void)sendImage:(NSData*)imageData withCompressionMode:(MXKRoomInputToolbarCompressionMode)compressionMode +{ + if (optionsListView) + { + [optionsListView dismissViewControllerAnimated:NO completion:nil]; + optionsListView = nil; + } + + if (compressionPrompt && compressionMode == MXKRoomInputToolbarCompressionModePrompt) + { + // Delay the image sending + if (!pendingImages) + { + pendingImages = [NSMutableArray arrayWithObject:imageData]; + } + else + { + [pendingImages addObject:imageData]; + } + return; + } + + // Get available sizes for this image + UIImage *image = [UIImage imageWithData:imageData]; + MXKImageCompressionSizes compressionSizes = [MXKTools availableCompressionSizesForImage:image originalFileSize:imageData.length]; + + // Apply the compression mode + if (compressionMode == MXKRoomInputToolbarCompressionModePrompt + && (compressionSizes.small.fileSize || compressionSizes.medium.fileSize || compressionSizes.large.fileSize)) + { + __weak typeof(self) weakSelf = self; + + compressionPrompt = [UIAlertController alertControllerWithTitle:[MatrixKitL10n attachmentSizePromptTitle] + message:[MatrixKitL10n attachmentSizePromptMessage] + preferredStyle:UIAlertControllerStyleActionSheet]; + + if (compressionSizes.small.fileSize) + { + NSString *fileSizeString = [MXTools fileSizeToString:compressionSizes.small.fileSize]; + + NSString *title = [MatrixKitL10n attachmentSmall:fileSizeString]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Send the small image + UIImage *smallImage = [MXKTools reduceImage:image toFitInSize:CGSizeMake(MXKTOOLS_SMALL_IMAGE_SIZE, MXKTOOLS_SMALL_IMAGE_SIZE)]; + [self.delegate roomInputToolbarView:self sendImage:smallImage]; + + [self dismissCompressionPrompt]; + } + + }]]; + } + + if (compressionSizes.medium.fileSize) + { + NSString *fileSizeString = [MXTools fileSizeToString:compressionSizes.medium.fileSize]; + + NSString *title = [MatrixKitL10n attachmentMedium:fileSizeString]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Send the medium image + UIImage *mediumImage = [MXKTools reduceImage:image toFitInSize:CGSizeMake(MXKTOOLS_MEDIUM_IMAGE_SIZE, MXKTOOLS_MEDIUM_IMAGE_SIZE)]; + [self.delegate roomInputToolbarView:self sendImage:mediumImage]; + + [self dismissCompressionPrompt]; + } + + }]]; + } + + if (compressionSizes.large.fileSize) + { + NSString *fileSizeString = [MXTools fileSizeToString:compressionSizes.large.fileSize]; + + NSString *title = [MatrixKitL10n attachmentLarge:fileSizeString]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Send the large image + UIImage *largeImage = [MXKTools reduceImage:image toFitInSize:CGSizeMake(compressionSizes.actualLargeSize, compressionSizes.actualLargeSize)]; + [self.delegate roomInputToolbarView:self sendImage:largeImage]; + + [self dismissCompressionPrompt]; + } + + }]]; + } + + NSString *fileSizeString = [MXTools fileSizeToString:compressionSizes.original.fileSize]; + + NSString *title = [MatrixKitL10n attachmentOriginal:fileSizeString]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Send the original image + [self.delegate roomInputToolbarView:self sendImage:image]; + + [self dismissCompressionPrompt]; + } + + }]]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleCancel + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + [self dismissCompressionPrompt]; + } + + }]]; + + [compressionPrompt popoverPresentationController].sourceView = self; + [compressionPrompt popoverPresentationController].sourceRect = self.bounds; + [self.delegate roomInputToolbarView:self presentAlertController:compressionPrompt]; + } + else + { + // By default the original image is sent + UIImage *finalImage = image; + + switch (compressionMode) + { + case MXKRoomInputToolbarCompressionModePrompt: + // Here the image size is too small to need compression - send the original image + break; + + case MXKRoomInputToolbarCompressionModeSmall: + if (compressionSizes.small.fileSize) + { + finalImage = [MXKTools reduceImage:image toFitInSize:CGSizeMake(MXKTOOLS_SMALL_IMAGE_SIZE, MXKTOOLS_SMALL_IMAGE_SIZE)]; + } + break; + + case MXKRoomInputToolbarCompressionModeMedium: + if (compressionSizes.medium.fileSize) + { + finalImage = [MXKTools reduceImage:image toFitInSize:CGSizeMake(MXKTOOLS_MEDIUM_IMAGE_SIZE, MXKTOOLS_MEDIUM_IMAGE_SIZE)]; + } + break; + + case MXKRoomInputToolbarCompressionModeLarge: + if (compressionSizes.large.fileSize) + { + finalImage = [MXKTools reduceImage:image toFitInSize:CGSizeMake(compressionSizes.actualLargeSize, compressionSizes.actualLargeSize)]; + } + break; + + default: + // no compression, send original + break; + } + + // Send the image + [self.delegate roomInputToolbarView:self sendImage:finalImage]; + } +} + +- (void)sendSelectedVideo:(NSURL*)selectedVideo isPhotoLibraryAsset:(BOOL)isPhotoLibraryAsset +{ + AVURLAsset *videoAsset = [AVURLAsset assetWithURL:selectedVideo]; + [self sendSelectedVideoAsset:videoAsset isPhotoLibraryAsset:isPhotoLibraryAsset]; +} + +- (void)sendSelectedVideoAsset:(AVAsset*)selectedVideo isPhotoLibraryAsset:(BOOL)isPhotoLibraryAsset +{ + // Check condition before saving this media in user's library + if (_enableAutoSaving && !isPhotoLibraryAsset) + { + if ([selectedVideo isKindOfClass:[AVURLAsset class]]) + { + AVURLAsset *urlAsset = (AVURLAsset*)selectedVideo; + [MXMediaManager saveMediaToPhotosLibrary:[urlAsset URL] isImage:NO success:nil failure:nil]; + } + else + { + MXLogError(@"[RoomInputToolbarView] Unable to save video, incorrect asset type.") + } + } + + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:sendVideoAsset:withThumbnail:)]) + { + // Retrieve the video frame at 1 sec to define the video thumbnail + AVAssetImageGenerator *assetImageGenerator = [AVAssetImageGenerator assetImageGeneratorWithAsset:selectedVideo]; + assetImageGenerator.appliesPreferredTrackTransform = YES; + CMTime time = CMTimeMake(1, 1); + CGImageRef imageRef = [assetImageGenerator copyCGImageAtTime:time actualTime:NULL error:nil]; + + // Finalize video attachment + UIImage* videoThumbnail = [[UIImage alloc] initWithCGImage:imageRef]; + CFRelease(imageRef); + + [self.delegate roomInputToolbarView:self sendVideoAsset:selectedVideo withThumbnail:videoThumbnail]; + } + else + { + MXLogDebug(@"[RoomInputToolbarView] Attach video is not supported"); + } +} + +- (void)sendSelectedAssets:(NSArray*)assets withCompressionMode:(MXKRoomInputToolbarCompressionMode)compressionMode +{ + // Get data about the selected assets + if (assets.count) + { + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:updateActivityIndicator:)]) + { + [self.delegate roomInputToolbarView:self updateActivityIndicator:YES]; + } + + [self availableCompressionSizesForAssets:assets onComplete:^(NSArray*checkedAssets, MXKFileSizes fileSizes) { + + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:updateActivityIndicator:)]) + { + [self.delegate roomInputToolbarView:self updateActivityIndicator:NO]; + } + + if (checkedAssets.count) + { + [self sendSelectedAssets:checkedAssets withFileSizes:fileSizes andCompressionMode:compressionMode]; + } + + }]; + } +} + +- (void)sendSelectedAssets:(NSArray*)assets withFileSizes:(MXKFileSizes)fileSizes andCompressionMode:(MXKRoomInputToolbarCompressionMode)compressionMode +{ + if (compressionMode == MXKRoomInputToolbarCompressionModePrompt + && (fileSizes.small || fileSizes.medium || fileSizes.large)) + { + // Ask the user for the compression value + compressionPrompt = [UIAlertController alertControllerWithTitle:[MatrixKitL10n attachmentSizePromptTitle] + message:[MatrixKitL10n attachmentSizePromptMessage] + preferredStyle:UIAlertControllerStyleActionSheet]; + + __weak typeof(self) weakSelf = self; + + if (fileSizes.small) + { + NSString *title = [MatrixKitL10n attachmentSmall:[MXTools fileSizeToString:fileSizes.small]]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + [self dismissCompressionPrompt]; + + [self sendSelectedAssets:assets withFileSizes:fileSizes andCompressionMode:MXKRoomInputToolbarCompressionModeSmall]; + } + + }]]; + } + + if (fileSizes.medium) + { + NSString *title = [MatrixKitL10n attachmentMedium:[MXTools fileSizeToString:fileSizes.medium]]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + [self dismissCompressionPrompt]; + + [self sendSelectedAssets:assets withFileSizes:fileSizes andCompressionMode:MXKRoomInputToolbarCompressionModeMedium]; + } + + }]]; + } + + if (fileSizes.large) + { + NSString *title = [MatrixKitL10n attachmentLarge:[MXTools fileSizeToString:fileSizes.large]]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + [self dismissCompressionPrompt]; + + [self sendSelectedAssets:assets withFileSizes:fileSizes andCompressionMode:MXKRoomInputToolbarCompressionModeLarge]; + } + + }]]; + } + + NSString *title = [MatrixKitL10n attachmentOriginal:[MXTools fileSizeToString:fileSizes.original]]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + [self dismissCompressionPrompt]; + + [self sendSelectedAssets:assets withFileSizes:fileSizes andCompressionMode:MXKRoomInputToolbarCompressionModeNone]; + } + + }]]; + + [compressionPrompt addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleCancel + handler:^(UIAlertAction * action) { + + if (weakSelf) + { + typeof(self) self = weakSelf; + + [self dismissCompressionPrompt]; + } + + }]]; + + [compressionPrompt popoverPresentationController].sourceView = self; + [compressionPrompt popoverPresentationController].sourceRect = self.bounds; + [self.delegate roomInputToolbarView:self presentAlertController:compressionPrompt]; + } + else + { + // Send all media with the selected compression mode + for (PHAsset *asset in assets) + { + if (asset.mediaType == PHAssetMediaTypeImage) + { + // Retrieve the full sized image data + PHImageRequestOptions *options = [[PHImageRequestOptions alloc] init]; + options.synchronous = NO; + options.networkAccessAllowed = YES; + + [[PHImageManager defaultManager] requestImageDataForAsset:asset options:options resultHandler:^(NSData * _Nullable imageData, NSString * _Nullable dataUTI, UIImageOrientation orientation, NSDictionary * _Nullable info) { + + if (imageData) + { + MXLogDebug(@"[MXKRoomInputToolbarView] sendSelectedAssets: Got image data"); + + CFStringRef uti = (__bridge CFStringRef)dataUTI; + NSString *mimeType = (__bridge_transfer NSString *) UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType); + + [self sendSelectedImage:imageData withMimeType:mimeType andCompressionMode:compressionMode isPhotoLibraryAsset:YES]; + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] sendSelectedAssets: Failed to get image data"); + + // Notify user + NSError *error = info[@"PHImageErrorKey"]; + if (error.userInfo[NSUnderlyingErrorKey]) + { + error = error.userInfo[NSUnderlyingErrorKey]; + } + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + } + + }]; + } + else if (asset.mediaType == PHAssetMediaTypeVideo) + { + PHVideoRequestOptions *options = [[PHVideoRequestOptions alloc] init]; + options.networkAccessAllowed = YES; + + [[PHImageManager defaultManager] requestAVAssetForVideo:asset options:options resultHandler:^(AVAsset *asset, AVAudioMix *audioMix, NSDictionary *info) { + + if ([asset isKindOfClass:[AVURLAsset class]]) + { + MXLogDebug(@"[MXKRoomInputToolbarView] sendSelectedAssets: Got video data"); + AVURLAsset* urlAsset = (AVURLAsset*)asset; + + dispatch_async(dispatch_get_main_queue(), ^{ + + [self sendSelectedVideo:urlAsset.URL isPhotoLibraryAsset:YES]; + + }); + } + else + { + MXLogDebug(@"[MXKRoomInputToolbarView] sendSelectedAssets: Failed to get video data"); + + // Notify user + NSError *error = info[@"PHImageErrorKey"]; + if (error.userInfo[NSUnderlyingErrorKey]) + { + error = error.userInfo[NSUnderlyingErrorKey]; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error]; + + }); + } + + }]; + } + } + } +} + +#pragma mark - UIImagePickerControllerDelegate + +- (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info +{ + [self dismissMediaPicker]; + + NSString *mediaType = [info objectForKey:UIImagePickerControllerMediaType]; + if ([mediaType isEqualToString:(NSString *)kUTTypeImage]) + { + UIImage *selectedImage = [info objectForKey:UIImagePickerControllerOriginalImage]; + if (selectedImage) + { + // Media picker does not offer a preview + // so add a preview to let the user validates his selection + if (picker.sourceType == UIImagePickerControllerSourceTypePhotoLibrary) + { + __weak typeof(self) weakSelf = self; + + MXKImageView *imageValidationView = [[MXKImageView alloc] initWithFrame:CGRectZero]; + imageValidationView.stretchable = YES; + + // the user validates the image + [imageValidationView setRightButtonTitle:[MatrixKitL10n ok] handler:^(MXKImageView* imageView, NSString* buttonTitle) + { + if (weakSelf) + { + typeof(self) self = weakSelf; + + // Dismiss the image view + [self dismissValidationViews]; + + NSURL *imageLocalURL = [info objectForKey:UIImagePickerControllerReferenceURL]; + if (imageLocalURL) + { + CFStringRef uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (__bridge CFStringRef)[imageLocalURL.path pathExtension] , NULL); + NSString *mimetype = (__bridge_transfer NSString *) UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType); + CFRelease(uti); + + NSData *imageData = [NSData dataWithContentsOfFile:imageLocalURL.path]; + + // attach the selected image + [self sendSelectedImage:imageData withMimeType:mimetype andCompressionMode:MXKRoomInputToolbarCompressionModePrompt isPhotoLibraryAsset:YES]; + } + } + + }]; + + // the user wants to use an other image + [imageValidationView setLeftButtonTitle:[MatrixKitL10n cancel] handler:^(MXKImageView* imageView, NSString* buttonTitle) + { + if (weakSelf) + { + typeof(self) self = weakSelf; + + // dismiss the image view + [self dismissValidationViews]; + + // Open again media gallery + self->mediaPicker = [[UIImagePickerController alloc] init]; + self->mediaPicker.delegate = self; + self->mediaPicker.sourceType = picker.sourceType; + self->mediaPicker.allowsEditing = NO; + self->mediaPicker.mediaTypes = picker.mediaTypes; + [self.delegate roomInputToolbarView:self presentViewController:self->mediaPicker]; + } + }]; + + imageValidationView.image = selectedImage; + + [validationViews addObject:imageValidationView]; + [imageValidationView showFullScreen]; + [self.delegate roomInputToolbarView:self hideStatusBar:YES]; + } + else + { + // Suggest compression before sending image + NSData *imageData = UIImageJPEGRepresentation(selectedImage, 0.9); + [self sendSelectedImage:imageData withMimeType:nil andCompressionMode:MXKRoomInputToolbarCompressionModePrompt isPhotoLibraryAsset:NO]; + } + } + } + else if ([mediaType isEqualToString:(NSString *)kUTTypeMovie]) + { + NSURL* selectedVideo = [info objectForKey:UIImagePickerControllerMediaURL]; + + [self sendSelectedVideo:selectedVideo isPhotoLibraryAsset:(picker.sourceType == UIImagePickerControllerSourceTypePhotoLibrary)]; + } +} + +- (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker +{ + [self dismissMediaPicker]; +} + +- (void)dismissValidationViews +{ + if (validationViews.count) + { + for (MXKImageView *validationView in validationViews) + { + [validationView dismissSelection]; + [validationView removeFromSuperview]; + } + + [validationViews removeAllObjects]; + + // Restore status bar + [self.delegate roomInputToolbarView:self hideStatusBar:NO]; + } +} + +- (void)dismissValidationView:(MXKImageView*)validationView +{ + [validationView dismissSelection]; + [validationView removeFromSuperview]; + + if (validationViews.count) + { + [validationViews removeObject:validationView]; + + if (!validationViews.count) + { + // Restore status bar + [self.delegate roomInputToolbarView:self hideStatusBar:NO]; + } + } +} + +- (void)dismissMediaPicker +{ + if (mediaPicker) + { + mediaPicker.delegate = nil; + + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:dismissViewControllerAnimated:completion:)]) + { + [self.delegate roomInputToolbarView:self dismissViewControllerAnimated:NO completion:^{ + self->mediaPicker = nil; + }]; + } + } +} + +#pragma mark - Clipboard - Handle image/data paste from general pasteboard + +- (void)paste:(id)sender +{ + UIPasteboard *pasteboard = MXKPasteboardManager.shared.pasteboard; + if (pasteboard.numberOfItems) + { + [self dismissValidationViews]; + [self dismissKeyboard]; + + __weak typeof(self) weakSelf = self; + + for (NSDictionary* dict in pasteboard.items) + { + NSArray* allKeys = dict.allKeys; + for (NSString* key in allKeys) + { + NSString* MIMEType = (__bridge_transfer NSString *) UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)key, kUTTagClassMIMEType); + if ([MIMEType hasPrefix:@"image/"] && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendImage:)]) + { + UIImage *pasteboardImage; + if ([[dict objectForKey:key] isKindOfClass:UIImage.class]) + { + pasteboardImage = [dict objectForKey:key]; + } + // WebP images from Safari appear on the pasteboard as NSData rather than UIImages. + else if ([[dict objectForKey:key] isKindOfClass:NSData.class]) + { + pasteboardImage = [UIImage imageWithData:[dict objectForKey:key]]; + } + else { + MXLogError(@"[MXKRoomInputToolbarView] Unsupported image format %@ for mimetype %@ pasted.", MIMEType, NSStringFromClass([[dict objectForKey:key] class])); + } + + if (pasteboardImage) + { + MXKImageView *imageValidationView = [[MXKImageView alloc] initWithFrame:CGRectZero]; + imageValidationView.stretchable = YES; + + // the user validates the image + [imageValidationView setRightButtonTitle:[MatrixKitL10n ok] handler:^(MXKImageView* imageView, NSString* buttonTitle) + { + if (weakSelf) + { + typeof(self) self = weakSelf; + [self dismissValidationView:imageView]; + [self.delegate roomInputToolbarView:self sendImage:pasteboardImage]; + } + }]; + + // the user wants to use an other image + [imageValidationView setLeftButtonTitle:[MatrixKitL10n cancel] handler:^(MXKImageView* imageView, NSString* buttonTitle) + { + // Dismiss the image validation view. + if (weakSelf) + { + typeof(self) self = weakSelf; + [self dismissValidationView:imageView]; + } + }]; + + imageValidationView.image = pasteboardImage; + + [validationViews addObject:imageValidationView]; + [imageValidationView showFullScreen]; + [self.delegate roomInputToolbarView:self hideStatusBar:YES]; + } + + break; + } + else if ([MIMEType hasPrefix:@"video/"] && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendVideo:withThumbnail:)]) + { + NSData *pasteboardVideoData = [dict objectForKey:key]; + // Get a unique cache path to store this video + NSString *cacheFilePath = [MXMediaManager temporaryCachePathInFolder:nil withType:MIMEType]; + + if ([MXMediaManager writeMediaData:pasteboardVideoData toFilePath:cacheFilePath]) + { + NSURL *videoLocalURL = [NSURL fileURLWithPath:cacheFilePath isDirectory:NO]; + + // Retrieve the video frame at 1 sec to define the video thumbnail + AVURLAsset *urlAsset = [[AVURLAsset alloc] initWithURL:videoLocalURL options:nil]; + AVAssetImageGenerator *assetImageGenerator = [AVAssetImageGenerator assetImageGeneratorWithAsset:urlAsset]; + assetImageGenerator.appliesPreferredTrackTransform = YES; + CMTime time = CMTimeMake(1, 1); + CGImageRef imageRef = [assetImageGenerator copyCGImageAtTime:time actualTime:NULL error:nil]; + UIImage* videoThumbnail = [[UIImage alloc] initWithCGImage:imageRef]; + CFRelease (imageRef); + + MXKImageView *videoValidationView = [[MXKImageView alloc] initWithFrame:CGRectZero]; + videoValidationView.stretchable = YES; + + // the user validates the image + [videoValidationView setRightButtonTitle:[MatrixKitL10n ok] handler:^(MXKImageView* imageView, NSString* buttonTitle) + { + if (weakSelf) + { + typeof(self) self = weakSelf; + [self dismissValidationView:imageView]; + + [self.delegate roomInputToolbarView:self sendVideo:videoLocalURL withThumbnail:videoThumbnail]; + } + }]; + + // the user wants to use an other image + [videoValidationView setLeftButtonTitle:[MatrixKitL10n cancel] handler:^(MXKImageView* imageView, NSString* buttonTitle) + { + // Dismiss the video validation view. + if (weakSelf) + { + typeof(self) self = weakSelf; + [self dismissValidationView:imageView]; + } + }]; + + videoValidationView.image = videoThumbnail; + + [validationViews addObject:videoValidationView]; + [videoValidationView showFullScreen]; + [self.delegate roomInputToolbarView:self hideStatusBar:YES]; + + // Add video icon + UIImageView *videoIconView = [[UIImageView alloc] initWithImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_video"]]; + videoIconView.center = videoValidationView.center; + videoIconView.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleBottomMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin; + [videoValidationView addSubview:videoIconView]; + } + break; + } + else if ([MIMEType hasPrefix:@"application/"] && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendFile:withMimeType:)]) + { + NSData *pasteboardDocumentData = [dict objectForKey:key]; + // Get a unique cache path to store this data + NSString *cacheFilePath = [MXMediaManager temporaryCachePathInFolder:nil withType:MIMEType]; + + if ([MXMediaManager writeMediaData:pasteboardDocumentData toFilePath:cacheFilePath]) + { + NSURL *localURL = [NSURL fileURLWithPath:cacheFilePath isDirectory:NO]; + + MXKImageView *docValidationView = [[MXKImageView alloc] initWithFrame:CGRectZero]; + docValidationView.stretchable = YES; + + // the user validates the image + [docValidationView setRightButtonTitle:[MatrixKitL10n ok] handler:^(MXKImageView* imageView, NSString* buttonTitle) + { + if (weakSelf) + { + typeof(self) self = weakSelf; + [self dismissValidationView:imageView]; + + [self.delegate roomInputToolbarView:self sendFile:localURL withMimeType:MIMEType]; + } + }]; + + // the user wants to use an other image + [docValidationView setLeftButtonTitle:[MatrixKitL10n cancel] handler:^(MXKImageView* imageView, NSString* buttonTitle) + { + // Dismiss the validation view. + if (weakSelf) + { + typeof(self) self = weakSelf; + [self dismissValidationView:imageView]; + } + }]; + + docValidationView.image = nil; + + [validationViews addObject:docValidationView]; + [docValidationView showFullScreen]; + [self.delegate roomInputToolbarView:self hideStatusBar:YES]; + + // Create a fake name based on fileData to keep the same name for the same file. + NSString *dataHash = [pasteboardDocumentData mx_MD5]; + if (dataHash.length > 7) + { + // Crop + dataHash = [dataHash substringToIndex:7]; + } + NSString *extension = [MXTools fileExtensionFromContentType:MIMEType]; + NSString *filename = [NSString stringWithFormat:@"file_%@%@", dataHash, extension]; + + // Display this file name + UITextView *fileNameTextView = [[UITextView alloc] initWithFrame:CGRectZero]; + fileNameTextView.text = filename; + fileNameTextView.font = [UIFont systemFontOfSize:17]; + [fileNameTextView sizeToFit]; + fileNameTextView.center = docValidationView.center; + fileNameTextView.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleBottomMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin; + + docValidationView.backgroundColor = [UIColor whiteColor]; + [docValidationView addSubview:fileNameTextView]; + } + break; + } + } + } + } +} + +- (BOOL)canPerformAction:(SEL)action withSender:(id)sender +{ + if (action == @selector(paste:) && MXKAppSettings.standardAppSettings.messageDetailsAllowPastingMedia) + { + // Check whether some data listed in general pasteboard can be paste + UIPasteboard *pasteboard = MXKPasteboardManager.shared.pasteboard; + if (pasteboard.numberOfItems) + { + for (NSDictionary* dict in pasteboard.items) + { + NSArray* allKeys = dict.allKeys; + for (NSString* key in allKeys) + { + NSString* MIMEType = (__bridge_transfer NSString *) UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)key, kUTTagClassMIMEType); + + if ([MIMEType hasPrefix:@"image/"] && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendImage:)]) + { + return YES; + } + + if ([MIMEType hasPrefix:@"video/"] && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendVideo:withThumbnail:)]) + { + return YES; + } + + if ([MIMEType hasPrefix:@"application/"] && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendFile:withMimeType:)]) + { + return YES; + } + } + } + } + } + return NO; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.xib b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.xib new file mode 100644 index 000000000..a4821a346 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.xib @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.h b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.h new file mode 100644 index 000000000..b10b54fa6 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.h @@ -0,0 +1,33 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomInputToolbarView.h" + +#import + +/** + `MXKRoomInputToolbarViewWithHPGrowingText` is a MXKRoomInputToolbarView-inherited class in which message + composer is based on `HPGrowingTextView`. + + Toolbar buttons are not overridden by this class. We keep the default implementation. + */ +@interface MXKRoomInputToolbarViewWithHPGrowingText : MXKRoomInputToolbarView +{ +@protected + HPGrowingTextView *growingTextView; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.m b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.m new file mode 100644 index 000000000..fe5f87ff7 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.m @@ -0,0 +1,187 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomInputToolbarViewWithHPGrowingText.h" + +@interface MXKRoomInputToolbarViewWithHPGrowingText() +{ + // HPGrowingTextView triggers growingTextViewDidChange event when it recomposes itself + // Save the last edited text to prevent unexpected typing events + NSString* lastEditedText; +} + +/** + Message composer defined in `messageComposerContainer`. + */ +@property (nonatomic) IBOutlet HPGrowingTextView *growingTextView; + +@end + +@implementation MXKRoomInputToolbarViewWithHPGrowingText +@synthesize growingTextView; + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomInputToolbarViewWithHPGrowingText class]) + bundle:[NSBundle bundleForClass:[MXKRoomInputToolbarViewWithHPGrowingText class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + // Handle message composer based on HPGrowingTextView use + growingTextView.delegate = self; + + [growingTextView setTranslatesAutoresizingMaskIntoConstraints: NO]; + + // Add an accessory view to the text view in order to retrieve keyboard view. + inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + growingTextView.internalTextView.inputAccessoryView = self.inputAccessoryView; + + // on IOS 8, the growing textview animation could trigger weird UI animations + // indeed, the messages tableView can be refreshed while its height is updated (e.g. when setting a message) + growingTextView.animateHeightChange = NO; + + lastEditedText = nil; +} + +- (void)dealloc +{ + [self destroy]; +} + +-(void)customizeViewRendering +{ + [super customizeViewRendering]; + + // set text input font + growingTextView.font = [UIFont systemFontOfSize:14]; + + // draw a rounded border around the textView + growingTextView.layer.cornerRadius = 5; + growingTextView.layer.borderWidth = 1; + growingTextView.layer.borderColor = [UIColor lightGrayColor].CGColor; + growingTextView.clipsToBounds = YES; + growingTextView.backgroundColor = [UIColor whiteColor]; +} + +- (void)destroy +{ + if (growingTextView) + { + growingTextView.delegate = nil; + growingTextView = nil; + } + + [super destroy]; +} + +- (void)setMaxHeight:(CGFloat)maxHeight +{ + growingTextView.maxHeight = maxHeight - (self.messageComposerContainerTopConstraint.constant + self.messageComposerContainerBottomConstraint.constant); + [growingTextView refreshHeight]; + + super.maxHeight = maxHeight; +} + +- (NSString*)textMessage +{ + return growingTextView.text; +} + +- (void)setTextMessage:(NSString *)textMessage +{ + growingTextView.text = textMessage; + self.rightInputToolbarButton.enabled = textMessage.length; +} + +- (void)pasteText:(NSString *)text +{ + self.textMessage = [growingTextView.text stringByReplacingCharactersInRange:growingTextView.selectedRange withString:text]; +} + +- (void)setPlaceholder:(NSString *)inPlaceholder +{ + [super setPlaceholder:inPlaceholder]; + growingTextView.placeholder = inPlaceholder; +} + +- (BOOL)becomeFirstResponder +{ + return [growingTextView becomeFirstResponder]; +} + +- (void)dismissKeyboard +{ + [growingTextView resignFirstResponder]; +} + +#pragma mark - HPGrowingTextView delegate + +- (void)growingTextViewDidEndEditing:(HPGrowingTextView *)sender +{ + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:isTyping:)]) + { + [self.delegate roomInputToolbarView:self isTyping:NO]; + } +} + +- (void)growingTextViewDidChange:(HPGrowingTextView *)sender +{ + NSString *msg = growingTextView.text; + + // HPGrowingTextView triggers growingTextViewDidChange event when it recomposes itself. + // Save the last edited text to prevent unexpected typing events + if (![lastEditedText isEqualToString:msg]) + { + lastEditedText = msg; + if (msg.length) + { + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:isTyping:)]) + { + [self.delegate roomInputToolbarView:self isTyping:YES]; + } + self.rightInputToolbarButton.enabled = YES; + } + else + { + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:isTyping:)]) + { + [self.delegate roomInputToolbarView:self isTyping:NO]; + } + self.rightInputToolbarButton.enabled = NO; + } + } +} + +- (void)growingTextView:(HPGrowingTextView *)growingTextView willChangeHeight:(float)height +{ + // Update growing text's superview (toolbar view) + CGFloat updatedHeight = height + (self.messageComposerContainerTopConstraint.constant + self.messageComposerContainerBottomConstraint.constant); + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:heightDidChanged:completion:)]) + { + [self.delegate roomInputToolbarView:self heightDidChanged:updatedHeight completion:nil]; + } +} + +- (BOOL)growingTextView:(HPGrowingTextView *)growingTextView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text +{ + return self.isEditable; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.xib b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.xib new file mode 100644 index 000000000..3f4117499 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithHPGrowingText.xib @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.h b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.h new file mode 100644 index 000000000..22299422f --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.h @@ -0,0 +1,32 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomInputToolbarView.h" + +/** + `MXKRoomInputToolbarViewWithSimpleTextView` is a MXKRoomInputToolbarView-inherited class in which message + composer is a UITextView instance with a fixed heigth. + + Toolbar buttons are not overridden by this class. We keep the default implementation. + */ +@interface MXKRoomInputToolbarViewWithSimpleTextView : MXKRoomInputToolbarView + +/** + Message composer defined in `messageComposerContainer`. + */ +@property (weak, nonatomic) IBOutlet UITextView *messageComposerTextView; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.m b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.m new file mode 100644 index 000000000..3cdb38eda --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.m @@ -0,0 +1,123 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomInputToolbarViewWithSimpleTextView.h" + +@implementation MXKRoomInputToolbarViewWithSimpleTextView + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomInputToolbarViewWithSimpleTextView class]) + bundle:[NSBundle bundleForClass:[MXKRoomInputToolbarViewWithSimpleTextView class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + // Add an accessory view to the text view in order to retrieve keyboard view. + inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + self.messageComposerTextView.inputAccessoryView = self.inputAccessoryView; +} + +-(void)customizeViewRendering +{ + [super customizeViewRendering]; + + // Set default message composer background color + self.messageComposerTextView.backgroundColor = [UIColor whiteColor]; +} + +- (NSString*)textMessage +{ + return _messageComposerTextView.text; +} + +- (void)setTextMessage:(NSString *)textMessage +{ + _messageComposerTextView.text = textMessage; + self.rightInputToolbarButton.enabled = textMessage.length; +} + +- (void)pasteText:(NSString *)text +{ + self.textMessage = [_messageComposerTextView.text stringByReplacingCharactersInRange:_messageComposerTextView.selectedRange withString:text]; +} + +- (BOOL)becomeFirstResponder +{ + return [_messageComposerTextView becomeFirstResponder]; +} + +- (void)dismissKeyboard +{ + if (_messageComposerTextView) + { + [_messageComposerTextView resignFirstResponder]; + } +} + +#pragma mark - UITextViewDelegate + +- (void)textViewDidEndEditing:(UITextView *)textView +{ + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:isTyping:)]) + { + [self.delegate roomInputToolbarView:self isTyping:NO]; + } +} + +- (void)textViewDidChange:(UITextView *)textView +{ + NSString *msg = textView.text; + + if (msg.length) + { + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:isTyping:)]) + { + [self.delegate roomInputToolbarView:self isTyping:YES]; + } + self.rightInputToolbarButton.enabled = YES; + } + else + { + if ([self.delegate respondsToSelector:@selector(roomInputToolbarView:isTyping:)]) + { + [self.delegate roomInputToolbarView:self isTyping:NO]; + } + self.rightInputToolbarButton.enabled = NO; + } +} + +- (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text +{ + if (!self.isEditable) + { + return NO; + } + + // Hanlde here `Done` key pressed + if([text isEqualToString:@"\n"]) + { + [textView resignFirstResponder]; + return NO; + } + + return YES; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.xib b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.xib new file mode 100644 index 000000000..d01e943be --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarViewWithSimpleTextView.xib @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.h new file mode 100644 index 000000000..b6dc414fd --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.h @@ -0,0 +1,28 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKRecentTableViewCell.h" + +/** + `MXKInterleavedRecentTableViewCell` instances display a room in the context of the recents list. + */ +@interface MXKInterleavedRecentTableViewCell : MXKRecentTableViewCell + +@property (weak, nonatomic) IBOutlet UIView* userFlag; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.m new file mode 100644 index 000000000..a4b17d16e --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.m @@ -0,0 +1,64 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKInterleavedRecentTableViewCell.h" + +#import "MXKSessionRecentsDataSource.h" + +#import "MXKAccountManager.h" + +@implementation MXKInterleavedRecentTableViewCell + +#pragma mark - Class methods + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + CAShapeLayer *userFlagMaskLayer = [[CAShapeLayer alloc] init]; + userFlagMaskLayer.frame = _userFlag.bounds; + + UIBezierPath *path = [[UIBezierPath alloc] init]; + [path moveToPoint:CGPointMake(0, 0)]; + [path addLineToPoint:CGPointMake(_userFlag.frame.size.width, _userFlag.frame.size.height)]; + [path addLineToPoint:CGPointMake(_userFlag.frame.size.width, 0)]; + [path closePath]; + + userFlagMaskLayer.path = path.CGPath; + _userFlag.layer.mask = userFlagMaskLayer; +} + +- (void)render:(MXKCellData *)cellData +{ + [super render:cellData]; + + // Highlight the room owner by using his tint color. + if (roomCellData) + { + MXKAccount *account = [[MXKAccountManager sharedManager] accountForUserId:roomCellData.mxSession.myUserId]; + if (account) + { + _userFlag.backgroundColor = account.userTintColor; + } + else + { + _userFlag.backgroundColor = [UIColor clearColor]; + } + } +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.xib b/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.xib new file mode 100644 index 000000000..51e427937 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKInterleavedRecentTableViewCell.xib @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.h new file mode 100644 index 000000000..c868bffd5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.h @@ -0,0 +1,36 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKTableViewCell.h" + +@interface MXKPublicRoomTableViewCell : MXKTableViewCell + +@property (weak, nonatomic) IBOutlet UILabel *roomDisplayName; +@property (weak, nonatomic) IBOutlet UILabel *memberCount; +@property (weak, nonatomic) IBOutlet UILabel *roomTopic; + +@property (nonatomic, getter=isHighlightedPublicRoom) BOOL highlightedPublicRoom; + +/** + Configure the cell in order to display the public room. + + @param publicRoom the public room to render. + */ +- (void)render:(MXPublicRoom*)publicRoom; + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.m new file mode 100644 index 000000000..d6321f100 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.m @@ -0,0 +1,75 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKPublicRoomTableViewCell.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@implementation MXKPublicRoomTableViewCell + +- (void)render:(MXPublicRoom*)publicRoom +{ + // Check whether this public room has topic + if (publicRoom.topic) + { + _roomTopic.hidden = NO; + _roomTopic.text = [MXTools stripNewlineCharacters:publicRoom.topic]; + } + else + { + _roomTopic.hidden = YES; + } + + // Set room display name + _roomDisplayName.text = [publicRoom displayname]; + + // Set member count + if (publicRoom.numJoinedMembers > 1) + { + _memberCount.text = [MatrixKitL10n numMembersOther:@(publicRoom.numJoinedMembers).stringValue]; + } + else if (publicRoom.numJoinedMembers == 1) + { + _memberCount.text = [MatrixKitL10n numMembersOne:@(1).stringValue]; + } + else + { + _memberCount.text = nil; + } +} + +- (void)setHighlightedPublicRoom:(BOOL)highlightedPublicRoom +{ + // Highlight? + if (highlightedPublicRoom) + { + _roomDisplayName.font = [UIFont boldSystemFontOfSize:20]; + _roomTopic.font = [UIFont boldSystemFontOfSize:17]; + self.backgroundColor = [UIColor colorWithRed:1.0 green:1.0 blue:0.9 alpha:1.0]; + } + else + { + _roomDisplayName.font = [UIFont systemFontOfSize:19]; + _roomTopic.font = [UIFont systemFontOfSize:16]; + self.backgroundColor = [UIColor clearColor]; + } + _highlightedPublicRoom = highlightedPublicRoom; +} + +@end + diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.xib b/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.xib new file mode 100644 index 000000000..9577dfaa8 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKPublicRoomTableViewCell.xib @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.h new file mode 100644 index 000000000..98cd4f962 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.h @@ -0,0 +1,39 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +#import "MXKCellRendering.h" + +#import "MXKRecentCellDataStoring.h" + +/** + `MXKRecentTableViewCell` instances display a room in the context of the recents list. + */ +@interface MXKRecentTableViewCell : MXKTableViewCell +{ +@protected + /** + The current cell data displayed by the table view cell + */ + id roomCellData; +} + +@property (weak, nonatomic) IBOutlet UILabel *roomTitle; +@property (weak, nonatomic) IBOutlet UILabel *lastEventDescription; +@property (weak, nonatomic) IBOutlet UILabel *lastEventDate; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.m new file mode 100644 index 000000000..3fdce7f75 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.m @@ -0,0 +1,99 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRecentTableViewCell.h" + +#import "MXKSessionRecentsDataSource.h" + +@implementation MXKRecentTableViewCell +@synthesize delegate; + +#pragma mark - Class methods + +- (void)render:(MXKCellData *)cellData +{ + roomCellData = (id)cellData; + if (roomCellData) + { + + // Report computed values as is + _roomTitle.text = roomCellData.roomDisplayname; + _lastEventDate.text = roomCellData.lastEventDate; + + // Manage lastEventAttributedTextMessage optional property + if ([roomCellData respondsToSelector:@selector(lastEventAttributedTextMessage)]) + { + _lastEventDescription.attributedText = roomCellData.lastEventAttributedTextMessage; + } + else + { + _lastEventDescription.text = roomCellData.lastEventTextMessage; + } + + // Set in bold public room name + if ([roomCellData.roomSummary.joinRule isEqualToString:kMXRoomJoinRulePublic]) + { + _roomTitle.font = [UIFont boldSystemFontOfSize:20]; + } + else + { + _roomTitle.font = [UIFont systemFontOfSize:19]; + } + + // Set background color and unread count + if (roomCellData.hasUnread) + { + if (0 < roomCellData.highlightCount) + { + self.backgroundColor = [UIColor colorWithRed:0.9 green:0.9 blue:1 alpha:1.0]; + } + else + { + self.backgroundColor = [UIColor colorWithRed:1 green:0.9 blue:0.9 alpha:1.0]; + } + } + else + { + self.backgroundColor = [UIColor clearColor]; + } + + } + else + { + _lastEventDescription.text = @""; + } +} + +- (MXKCellData*)renderedCellData +{ + return roomCellData; +} + ++ (CGFloat)heightForCellData:(MXKCellData *)cellData withMaximumWidth:(CGFloat)maxWidth +{ + // The height is fixed + return 70; +} + +- (void)prepareForReuse +{ + [super prepareForReuse]; + + roomCellData = nil; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.xib b/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.xib new file mode 100644 index 000000000..9d3dd58c5 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomList/MXKRecentTableViewCell.xib @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.h b/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.h new file mode 100644 index 000000000..ce99f5076 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.h @@ -0,0 +1,66 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" +#import "MXKCellRendering.h" + +@class MXKImageView; +@class MXKPieChartView; +@class MXSession; + +/** + `MXKRoomMemberTableViewCell` instances display a user in the context of the room member list. + */ +@interface MXKRoomMemberTableViewCell : MXKTableViewCell { + +@protected + /** + */ + MXSession *mxSession; + + /** + */ + NSString *memberId; + + /** + YES when last activity time is displayed and must be refreshed regularly. + */ + BOOL shouldUpdateActivityInfo; +} + +@property (strong, nonatomic) IBOutlet MXKImageView *pictureView; +@property (weak, nonatomic) IBOutlet UILabel *userLabel; +@property (weak, nonatomic) IBOutlet UIView *powerContainer; +@property (weak, nonatomic) IBOutlet UIImageView *typingBadge; + +/** + The default picture displayed when no picture is available. + */ +@property (nonatomic) UIImage *picturePlaceholder; + +/** + Update last activity information if any. + */ +- (void)updateActivityInfo; + +/** + Stringify the last activity date/time of the member. + + @return a string which described the last activity time of the member. + */ +- (NSString*)lastActiveTime; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.m b/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.m new file mode 100644 index 000000000..d312f27c1 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.m @@ -0,0 +1,299 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomMemberTableViewCell.h" + +@import MatrixSDK; + +#import "MXKAccount.h" +#import "MXKImageView.h" +#import "MXKPieChartView.h" +#import "MXKRoomMemberCellDataStoring.h" +#import "MXKRoomMemberListDataSource.h" +#import "MXKTools.h" + +#import "NSBundle+MatrixKit.h" + +#import "MXKSwiftHeader.h" + +@interface MXKRoomMemberTableViewCell () +{ + NSRange lastSeenRange; + + MXKPieChartView* pieChartView; +} + +@end + +@implementation MXKRoomMemberTableViewCell + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + self.typingBadge.image = [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_keyboard"]; +} + +- (void)customizeTableViewCellRendering +{ + [super customizeTableViewCellRendering]; + + self.pictureView.defaultBackgroundColor = [UIColor clearColor]; +} + +- (UIImage*)picturePlaceholder +{ + return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"]; +} + +- (void)render:(MXKCellData *)cellData +{ + // Sanity check: accept only object of MXKRoomMemberCellData classes or sub-classes + NSParameterAssert([cellData isKindOfClass:[MXKRoomMemberCellData class]]); + + MXKRoomMemberCellData *memberCellData = (MXKRoomMemberCellData*)cellData; + if (memberCellData) + { + mxSession = memberCellData.mxSession; + memberId = memberCellData.roomMember.userId; + + self.userLabel.text = memberCellData.memberDisplayName; + + // Disable by default activity update mechanism (This is required in case of a reused cell). + shouldUpdateActivityInfo = NO; + + // User thumbnail + self.pictureView.mediaFolder = kMXMediaManagerAvatarThumbnailFolder; + self.pictureView.enableInMemoryCache = YES; + // Consider here the member avatar is stored unencrypted on Matrix media repo + [self.pictureView setImageURI:memberCellData.roomMember.avatarUrl + withType:nil + andImageOrientation:UIImageOrientationUp + toFitViewSize:self.pictureView.frame.size + withMethod:MXThumbnailingMethodCrop + previewImage:self.picturePlaceholder + mediaManager:mxSession.mediaManager]; + + // Shade invited users + if (memberCellData.roomMember.membership == MXMembershipInvite) + { + for (UIView *view in self.subviews) + { + view.alpha = 0.3; + } + } + else + { + for (UIView *view in self.subviews) + { + view.alpha = 1; + } + } + + // Display the power level pie + [self setPowerContainerValue:memberCellData.powerLevel]; + + // Prepare presence string and thumbnail border color + NSString* presenceText = nil; + UIColor* thumbnailBorderColor = nil; + + // Customize banned and left (kicked) members + if (memberCellData.roomMember.membership == MXMembershipLeave || memberCellData.roomMember.membership == MXMembershipBan) + { + self.backgroundColor = [UIColor colorWithRed:0.8 green:0.8 blue:0.8 alpha:1.0]; + presenceText = (memberCellData.roomMember.membership == MXMembershipLeave) ? [MatrixKitL10n membershipLeave] : [MatrixKitL10n membershipBan]; + } + else + { + self.backgroundColor = [UIColor whiteColor]; + + // get the user presence and his thumbnail border color + if (memberCellData.roomMember.membership == MXMembershipInvite) + { + thumbnailBorderColor = [UIColor lightGrayColor]; + presenceText = [MatrixKitL10n membershipInvite]; + } + else + { + // Get the user that corresponds to this member + MXUser *user = [mxSession userWithUserId:memberId]; + // existing user ? + if (user) + { + thumbnailBorderColor = [MXKAccount presenceColor:user.presence]; + presenceText = [self lastActiveTime]; + // Keep last seen range to update it + lastSeenRange = NSMakeRange(self.userLabel.text.length + 2, presenceText.length); + shouldUpdateActivityInfo = (presenceText.length != 0); + } + } + } + + // if the thumbnail is defined + if (thumbnailBorderColor) + { + self.pictureView.layer.borderWidth = 2; + self.pictureView.layer.borderColor = thumbnailBorderColor.CGColor; + } + else + { + // remove the border + // else it draws black border + self.pictureView.layer.borderWidth = 0; + } + + // and the presence text (if any) + if (presenceText) + { + NSString* extraText = [NSString stringWithFormat:@"(%@)", presenceText]; + self.userLabel.text = [NSString stringWithFormat:@"%@ %@", self.userLabel.text, extraText]; + + NSRange range = [self.userLabel.text rangeOfString:extraText]; + UIFont* font = self.userLabel.font; + + // Create the attributes + NSDictionary *attrs = [NSDictionary dictionaryWithObjectsAndKeys: + font, NSFontAttributeName, + self.userLabel.textColor, NSForegroundColorAttributeName, nil]; + + NSDictionary *subAttrs = [NSDictionary dictionaryWithObjectsAndKeys: + font, NSFontAttributeName, + [UIColor lightGrayColor], NSForegroundColorAttributeName, nil]; + + // Create the attributed string (text + attributes) + NSMutableAttributedString *attributedText =[[NSMutableAttributedString alloc] initWithString:self.userLabel.text attributes:attrs]; + [attributedText setAttributes:subAttrs range:range]; + + // Set it in our UILabel and we are done! + [self.userLabel setAttributedText:attributedText]; + } + + // Set typing badge visibility + if (memberCellData.isTyping) + { + self.typingBadge.hidden = NO; + [self.typingBadge.superview bringSubviewToFront:self.typingBadge]; + } + else + { + self.typingBadge.hidden = YES; + } + } +} + ++ (CGFloat)heightForCellData:(MXKCellData *)cellData withMaximumWidth:(CGFloat)maxWidth +{ + // The height is fixed + return 50; +} + +- (NSString*)lastActiveTime +{ + NSString* lastActiveTime = nil; + + // Get the user that corresponds to this member + MXUser *user = [mxSession userWithUserId:memberId]; + if (user) + { + // Prepare last active ago string + lastActiveTime = [MXKTools formatSecondsIntervalFloored:(user.lastActiveAgo / 1000)]; + + // Check presence + switch (user.presence) + { + case MXPresenceOffline: + { + lastActiveTime = [MatrixKitL10n offline]; + break; + } + case MXPresenceUnknown: + { + lastActiveTime = nil; + break; + } + case MXPresenceOnline: + case MXPresenceUnavailable: + default: + break; + } + + } + + return lastActiveTime; +} + +- (void)setPowerContainerValue:(CGFloat)progress +{ + // no power level -> hide the pie + if (0 == progress) + { + self.powerContainer.hidden = YES; + return; + } + + // display it + self.powerContainer.hidden = NO; + self.powerContainer.backgroundColor = [UIColor clearColor]; + + if (!pieChartView) + { + pieChartView = [[MXKPieChartView alloc] initWithFrame:self.powerContainer.bounds]; + [self.powerContainer addSubview:pieChartView]; + } + + pieChartView.progress = progress; +} + +- (void)updateActivityInfo +{ + // Check whether update is required. + if (shouldUpdateActivityInfo) + { + NSString *lastSeen = [self lastActiveTime]; + NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithAttributedString:self.userLabel.attributedText]; + if (lastSeen.length) + { + [attributedText replaceCharactersInRange:lastSeenRange withString:lastSeen]; + + // Update last seen range + lastSeenRange.length = lastSeen.length; + } + else + { + // remove presence info + lastSeenRange.location -= 1; + lastSeenRange.length += 2; + [attributedText deleteCharactersInRange:lastSeenRange]; + + shouldUpdateActivityInfo = NO; + } + + [self.userLabel setAttributedText:attributedText]; + } +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + // Round image view + [_pictureView.layer setCornerRadius:_pictureView.frame.size.width / 2]; + _pictureView.clipsToBounds = YES; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.xib b/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.xib new file mode 100644 index 000000000..1f0178c9a --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomMemberList/MXKRoomMemberTableViewCell.xib @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.h b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.h new file mode 100644 index 000000000..6b3b3f8de --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.h @@ -0,0 +1,120 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXKView.h" + +@class MXKRoomTitleView; +@protocol MXKRoomTitleViewDelegate + +/** + Tells the delegate that an alert must be presented. + + @param titleView the room title view. + @param alertController the alert to present. + */ +- (void)roomTitleView:(MXKRoomTitleView*)titleView presentAlertController:(UIAlertController*)alertController; + +/** + Asks the delegate if editing should begin + + @param titleView the room title view. + @return YES if an editing session should be initiated; otherwise, NO to disallow editing. + */ +- (BOOL)roomTitleViewShouldBeginEditing:(MXKRoomTitleView*)titleView; + +@optional + +/** + Tells the delegate that the saving of user's changes is in progress or is finished. + + @param titleView the room title view. + @param saving YES if a request is running to save user's changes. + */ +- (void)roomTitleView:(MXKRoomTitleView*)titleView isSaving:(BOOL)saving; + +@end + +/** + 'MXKRoomTitleView' instance displays editable room display name. + */ +@interface MXKRoomTitleView : MXKView +{ +@protected + /** + Potential alert. + */ + UIAlertController *currentAlert; + + /** + Test fields input accessory. + */ + UIView *inputAccessoryView; +} + +@property (weak, nonatomic) IBOutlet UITextField *displayNameTextField; +@property (weak, nonatomic) IBOutlet NSLayoutConstraint *displayNameTextFieldTopConstraint; + +@property (strong, nonatomic) MXRoom *mxRoom; +@property (nonatomic) BOOL editable; +@property (nonatomic) BOOL isEditing; + +/** + * Returns the `UINib` object initialized for the room title view. + * + * @return The initialized `UINib` object or `nil` if there were errors during + * initialization or the nib file could not be located. + */ ++ (UINib *)nib; + +/** + Creates and returns a new `MXKRoomTitleView-inherited` object. + + @discussion This is the designated initializer for programmatic instantiation. + @return An initialized `MXKRoomTitleView-inherited` object if successful, `nil` otherwise. + */ ++ (instancetype)roomTitleView; + +/** + The delegate notified when inputs are ready. + */ +@property (weak, nonatomic) id delegate; + +/** + The custom accessory view associated to all text field of this 'MXKRoomTitleView' instance. + This view is actually used to retrieve the keyboard view. Indeed the keyboard view is the superview of + this accessory view when a text field become the first responder. + */ +@property (readonly) UIView *inputAccessoryView; + +/** + Dismiss keyboard. + */ +- (void)dismissKeyboard; + +/** + Force title view refresh. + */ +- (void)refreshDisplay; + +/** + Dispose view resources and listener. + */ +- (void)destroy; + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.m b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.m new file mode 100644 index 000000000..5ea74fc37 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.m @@ -0,0 +1,279 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomTitleView.h" + +#import "MXKConstants.h" + +#import "NSBundle+MatrixKit.h" +#import "MXRoom+Sync.h" + +#import "MXKSwiftHeader.h" + +@interface MXKRoomTitleView () +{ + // Observer kMXRoomSummaryDidChangeNotification to keep updated the room name. + __weak id mxRoomSummaryDidChangeObserver; +} +@end + +@implementation MXKRoomTitleView +@synthesize inputAccessoryView; + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomTitleView class]) + bundle:[NSBundle bundleForClass:[MXKRoomTitleView class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + [self setTranslatesAutoresizingMaskIntoConstraints: NO]; + + // Add an accessory view to the text view in order to retrieve keyboard view. + inputAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; + self.displayNameTextField.inputAccessoryView = inputAccessoryView; + + self.displayNameTextField.enabled = NO; + self.displayNameTextField.returnKeyType = UIReturnKeyDone; + self.displayNameTextField.hidden = YES; +} + ++ (instancetype)roomTitleView +{ + return [[[self class] nib] instantiateWithOwner:nil options:nil].firstObject; +} + +- (void)dealloc +{ + inputAccessoryView = nil; +} + +#pragma mark - Override MXKView + +-(void)customizeViewRendering +{ + [super customizeViewRendering]; + + self.autoresizingMask = UIViewAutoresizingFlexibleWidth; +} + +#pragma mark - + +- (void)refreshDisplay +{ + if (_mxRoom) + { + // Replace empty string by nil : avoid having the placeholder 'Room name" when there is no displayname + self.displayNameTextField.text = (_mxRoom.summary.displayname.length) ? _mxRoom.summary.displayname : nil; + } + else + { + self.displayNameTextField.text = [MatrixKitL10n roomPleaseSelect]; + self.displayNameTextField.enabled = NO; + } + self.displayNameTextField.hidden = NO; +} + +- (void)destroy +{ + self.delegate = nil; + self.mxRoom = nil; + + if (mxRoomSummaryDidChangeObserver) + { + [NSNotificationCenter.defaultCenter removeObserver:mxRoomSummaryDidChangeObserver]; + mxRoomSummaryDidChangeObserver = nil; + } +} + +- (void)dismissKeyboard +{ + // Hide the keyboard + [self.displayNameTextField resignFirstResponder]; +} + +#pragma mark - + +- (void)setMxRoom:(MXRoom *)mxRoom +{ + // Check whether the room is actually changed + if (_mxRoom != mxRoom) + { + // Remove potential listener + if (mxRoomSummaryDidChangeObserver) + { + [[NSNotificationCenter defaultCenter] removeObserver:mxRoomSummaryDidChangeObserver]; + mxRoomSummaryDidChangeObserver = nil; + } + + if (mxRoom) + { + MXWeakify(self); + + // Register a listener to handle the room name change + mxRoomSummaryDidChangeObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXRoomSummaryDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { + + MXStrongifyAndReturnIfNil(self); + + // Check whether the text field is editing before refreshing title view + if (!self.isEditing) + { + [self refreshDisplay]; + } + + }]; + } + _mxRoom = mxRoom; + } + // Force refresh + [self refreshDisplay]; +} + +- (void)setEditable:(BOOL)editable +{ + self.displayNameTextField.enabled = editable; +} + +- (BOOL)isEditing +{ + return self.displayNameTextField.isEditing; +} + +#pragma mark - UITextField delegate + +- (BOOL)textFieldShouldBeginEditing:(UITextField *)textField +{ + // check if the deleaget allows the edition + if (!self.delegate || [self.delegate roomTitleViewShouldBeginEditing:self]) + { + NSString *alertMsg = nil; + + if (textField == self.displayNameTextField) + { + // Check whether the user has enough power to rename the room + MXRoomPowerLevels *powerLevels = _mxRoom.dangerousSyncState.powerLevels; + + NSInteger userPowerLevel = [powerLevels powerLevelOfUserWithUserID:_mxRoom.mxSession.myUser.userId]; + if (userPowerLevel >= [powerLevels minimumPowerLevelForSendingEventAsStateEvent:kMXEventTypeStringRoomName]) + { + // Only the room name is edited here, update the text field with the room name + textField.text = _mxRoom.summary.displayname; + textField.backgroundColor = [UIColor whiteColor]; + } + else + { + alertMsg = [MatrixKitL10n roomErrorNameEditionNotAuthorized]; + } + } + + if (alertMsg) + { + // Alert user + __weak typeof(self) weakSelf = self; + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + + currentAlert = [UIAlertController alertControllerWithTitle:nil message:alertMsg preferredStyle:UIAlertControllerStyleAlert]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + }]]; + + [self.delegate roomTitleView:self presentAlertController:currentAlert]; + return NO; + } + return YES; + } + else + { + return NO; + } +} + +- (void)textFieldDidEndEditing:(UITextField *)textField +{ + if (textField == self.displayNameTextField) + { + textField.backgroundColor = [UIColor clearColor]; + + NSString *roomName = textField.text; + if ((roomName.length || _mxRoom.summary.displayname.length) && [roomName isEqualToString:_mxRoom.summary.displayname] == NO) + { + if ([self.delegate respondsToSelector:@selector(roomTitleView:isSaving:)]) + { + [self.delegate roomTitleView:self isSaving:YES]; + } + + __weak typeof(self) weakSelf = self; + [_mxRoom setName:roomName success:^{ + + if (weakSelf) + { + typeof(weakSelf)strongSelf = weakSelf; + if ([strongSelf.delegate respondsToSelector:@selector(roomTitleView:isSaving:)]) + { + [strongSelf.delegate roomTitleView:strongSelf isSaving:NO]; + } + } + + } failure:^(NSError *error) { + + if (weakSelf) + { + typeof(weakSelf)strongSelf = weakSelf; + if ([strongSelf.delegate respondsToSelector:@selector(roomTitleView:isSaving:)]) + { + [strongSelf.delegate roomTitleView:strongSelf isSaving:NO]; + } + + // Revert change + textField.text = strongSelf.mxRoom.summary.displayname; + MXLogDebug(@"[MXKRoomTitleView] Rename room failed"); + // Notify MatrixKit user + NSString *myUserId = strongSelf.mxRoom.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + } + + }]; + } + else + { + // No change on room name, restore title with room displayName + textField.text = _mxRoom.summary.displayname; + } + } +} + +- (BOOL)textFieldShouldReturn:(UITextField*) textField +{ + // "Done" key has been pressed + [textField resignFirstResponder]; + return YES; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.xib b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.xib new file mode 100644 index 000000000..8746fee25 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleView.xib @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.h b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.h new file mode 100644 index 000000000..31a2c8894 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.h @@ -0,0 +1,36 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomTitleView.h" + +/** + 'MXKRoomTitleViewWithTopic' inherits 'MXKRoomTitleView' to add an editable room topic field. + */ +@interface MXKRoomTitleViewWithTopic : MXKRoomTitleView { +} + +@property (weak, nonatomic) IBOutlet UITextField *topicTextField; + +@property (nonatomic) BOOL hiddenTopic; + +/** + Stop topic animation. + + @return YES if the animation has been stopped. + */ +- (BOOL)stopTopicAnimation; + +@end \ No newline at end of file diff --git a/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.m b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.m new file mode 100644 index 000000000..9fbe1dec9 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.m @@ -0,0 +1,520 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2017 Vector Creations Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomTitleViewWithTopic.h" + +#import "MXKConstants.h" + +#import "NSBundle+MatrixKit.h" +#import "MXRoom+Sync.h" + +#import "MXKSwiftHeader.h" + +@interface MXKRoomTitleViewWithTopic () +{ + id roomTopicListener; + + // the topic can be animated if it is longer than the screen size + UIScrollView* scrollView; + UILabel* label; + UIView* topicTextFieldMaskView; + + // do not start the topic animation asap + NSTimer * animationTimer; +} +@end + +@implementation MXKRoomTitleViewWithTopic + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MXKRoomTitleViewWithTopic class]) + bundle:[NSBundle bundleForClass:[MXKRoomTitleViewWithTopic class]]]; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + // Add an accessory view to the text view in order to retrieve keyboard view. + self.topicTextField.inputAccessoryView = inputAccessoryView; + + self.displayNameTextField.returnKeyType = UIReturnKeyNext; + self.topicTextField.enabled = NO; + self.topicTextField.returnKeyType = UIReturnKeyDone; + self.hiddenTopic = YES; +} + +- (void)refreshDisplay +{ + [super refreshDisplay]; + + if (self.mxRoom) + { + // Remove new line characters + NSString *topic = [MXTools stripNewlineCharacters:self.mxRoom.summary.topic]; + // replace empty string by nil: avoid having the placeholder when there is no topic + self.topicTextField.text = (topic.length ? topic : nil); + } + else + { + self.topicTextField.text = nil; + } + + self.hiddenTopic = (!self.topicTextField.text.length); +} + +- (void)destroy +{ + // stop any animation + [self stopTopicAnimation]; + + [super destroy]; +} + +- (void)dismissKeyboard +{ + // Hide the keyboard + [self.topicTextField resignFirstResponder]; + + // restart the animation + [self stopTopicAnimation]; + + [super dismissKeyboard]; +} + +#pragma mark - + +- (void)setMxRoom:(MXRoom *)mxRoom +{ + // Make sure we can access synchronously to self.mxRoom and mxRoom data + // to avoid race conditions + MXWeakify(self); + [mxRoom.mxSession preloadRoomsData:self.mxRoom ? @[self.mxRoom.roomId, mxRoom.roomId] : @[mxRoom.roomId] onComplete:^{ + MXStrongifyAndReturnIfNil(self); + + // Check whether the room is actually changed + if (self.mxRoom != mxRoom) + { + // Remove potential listener + if (self->roomTopicListener && self.mxRoom) + { + MXWeakify(self); + [self.mxRoom liveTimeline:^(MXEventTimeline *liveTimeline) { + MXStrongifyAndReturnIfNil(self); + + [liveTimeline removeListener:self->roomTopicListener]; + self->roomTopicListener = nil; + }]; + } + + if (mxRoom) + { + // Register a listener to handle messages related to room name + self->roomTopicListener = [mxRoom listenToEventsOfTypes:@[kMXEventTypeStringRoomTopic] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + + // Consider only live events + if (direction == MXTimelineDirectionForwards) + { + [self refreshDisplay]; + } + }]; + } + } + + super.mxRoom = mxRoom; + }]; +} + +- (void)setEditable:(BOOL)editable +{ + self.topicTextField.enabled = editable; + + super.editable = editable; +} + +- (void)setHiddenTopic:(BOOL)hiddenTopic +{ + [self stopTopicAnimation]; + if (hiddenTopic) + { + self.topicTextField.hidden = YES; + self.displayNameTextFieldTopConstraint.constant = 10; + } + else + { + self.topicTextField.hidden = NO; + self.displayNameTextFieldTopConstraint.constant = 0; + } +} + +- (BOOL)isEditing +{ + return (super.isEditing || self.topicTextField.isEditing); +} + +#pragma mark - + +// start with delay +- (void)startTopicAnimation +{ + // stop any pending timer + if (animationTimer) + { + [animationTimer invalidate]; + animationTimer = nil; + } + + // already animated the topic + if (scrollView) + { + return; + } + + // compute the text width + UIFont* font = self.topicTextField.font; + + // see font description + if (!font) + { + font = [UIFont systemFontOfSize:12]; + } + + NSDictionary *attributes = @{NSFontAttributeName: font}; + + CGSize stringSize = CGSizeMake(CGFLOAT_MAX, self.topicTextField.frame.size.height); + + stringSize = [self.topicTextField.text boundingRectWithSize:stringSize + options:NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading + attributes:attributes + context:nil].size; + + // does not need to animate the text + if (stringSize.width < self.topicTextField.frame.size.width) + { + return; + } + + // put the text in a scrollView to animat it + scrollView = [[UIScrollView alloc] initWithFrame: self.topicTextField.frame]; + label = [[UILabel alloc] initWithFrame:self.topicTextField.frame]; + label.text = self.topicTextField.text; + label.textColor = self.topicTextField.textColor; + label.font = self.topicTextField.font; + + // move to the top left + CGRect topicTextFieldFrame = self.topicTextField.frame; + topicTextFieldFrame.origin = CGPointZero; + label.frame = topicTextFieldFrame; + + self.topicTextField.hidden = YES; + [scrollView addSubview:label]; + [self insertSubview:scrollView belowSubview:topicTextFieldMaskView]; + + // update the size + [label sizeToFit]; + + // offset + CGPoint offset = scrollView.contentOffset; + offset.x = label.frame.size.width - scrollView.frame.size.width; + + // duration (magic computation to give more time if the text is longer) + CGFloat duration = label.frame.size.width / scrollView.frame.size.width * 3; + + // animate the topic once to display its full content + [UIView animateWithDuration:duration delay:0 options:UIViewAnimationOptionAutoreverse | UIViewAnimationOptionCurveLinear animations:^{ + [self->scrollView setContentOffset:offset animated:NO]; + } completion:^(BOOL finished) + { + [self stopTopicAnimation]; + }]; +} + +- (BOOL)stopTopicAnimation +{ + // stop running timers + if (animationTimer) + { + [animationTimer invalidate]; + animationTimer = nil; + } + + // if there is an animation is progress + if (scrollView) + { + self.topicTextField.hidden = NO; + + [scrollView.layer removeAllAnimations]; + [scrollView removeFromSuperview]; + scrollView = nil; + label = nil; + + [self addSubview:self.topicTextField]; + + // must be done to be able to restart the animation + // the Z order is not kept + [self bringSubviewToFront:topicTextFieldMaskView]; + + return YES; + } + + return NO; +} + +- (void)editTopic +{ + [self stopTopicAnimation]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [self.topicTextField becomeFirstResponder]; + }); +} + +- (void)layoutSubviews +{ + // add a mask to trap the tap events + // it is faster (and simpliest) than subclassing the scrollview or the textField + // any other gesture could also be trapped here + if (!topicTextFieldMaskView) + { + topicTextFieldMaskView = [[UIView alloc] initWithFrame:self.topicTextField.frame]; + topicTextFieldMaskView.backgroundColor = [UIColor clearColor]; + [self addSubview:topicTextFieldMaskView]; + + // tap -> switch to text edition + UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(editTopic)]; + [tap setNumberOfTouchesRequired:1]; + [tap setNumberOfTapsRequired:1]; + [tap setDelegate:self]; + [topicTextFieldMaskView addGestureRecognizer:tap]; + + // long tap -> animate the topic + UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(startTopicAnimation)]; + [topicTextFieldMaskView addGestureRecognizer:longPress]; + } + + + // mother class call + [super layoutSubviews]; +} + +- (void)setFrame:(CGRect)frame +{ + // mother class call + [super setFrame:frame]; + + // stop any running animation if the frame is updated (screen rotation for example) + if (!CGRectEqualToRect(CGRectIntegral(frame), CGRectIntegral(self.frame))) + { + // stop any running application + [self stopTopicAnimation]; + } + + // update the mask frame + if (self.topicTextField.hidden) + { + topicTextFieldMaskView.frame = CGRectZero; + } + else + { + topicTextFieldMaskView.frame = self.topicTextField.frame; + } + + // topicTextField switches becomes the first responder or it is not anymore the first responder + if (self.topicTextField.isFirstResponder != (topicTextFieldMaskView.hidden)) + { + topicTextFieldMaskView.hidden = self.topicTextField.isFirstResponder; + + // move topicTextFieldMaskView to the foreground + // when topicTextField has been the first responder, it lets a view over topicTextFieldMaskView + // so restore the expected Z order + if (!topicTextFieldMaskView.hidden) + { + [self bringSubviewToFront:topicTextFieldMaskView]; + } + } +} + +#pragma mark - UITextField delegate + +- (BOOL)textFieldShouldBeginEditing:(UITextField *)textField +{ + // check if the deleaget allows the edition + if (!self.delegate || [self.delegate roomTitleViewShouldBeginEditing:self]) + { + NSString *alertMsg = nil; + + if (textField == self.displayNameTextField) + { + // Check whether the user has enough power to rename the room + MXRoomPowerLevels *powerLevels = self.mxRoom.dangerousSyncState.powerLevels; + NSInteger userPowerLevel = [powerLevels powerLevelOfUserWithUserID:self.mxRoom.mxSession.myUser.userId]; + if (userPowerLevel >= [powerLevels minimumPowerLevelForSendingEventAsStateEvent:kMXEventTypeStringRoomName]) + { + // Only the room name is edited here, update the text field with the room name + textField.text = self.mxRoom.summary.displayname; + textField.backgroundColor = [UIColor whiteColor]; + } + else + { + alertMsg = [MatrixKitL10n roomErrorNameEditionNotAuthorized]; + } + + // Check whether the user is allowed to change room topic + if (userPowerLevel >= [powerLevels minimumPowerLevelForSendingEventAsStateEvent:kMXEventTypeStringRoomTopic]) + { + // Show topic text field even if the current value is nil + self.hiddenTopic = NO; + if (alertMsg) + { + // Here the user can only update the room topic, switch on room topic field (without displaying alert) + alertMsg = nil; + [self.topicTextField becomeFirstResponder]; + return NO; + } + } + } + else if (textField == self.topicTextField) + { + // Check whether the user has enough power to edit room topic + MXRoomPowerLevels *powerLevels = self.mxRoom.dangerousSyncState.powerLevels; + NSInteger userPowerLevel = [powerLevels powerLevelOfUserWithUserID:self.mxRoom.mxSession.myUser.userId]; + if (userPowerLevel >= [powerLevels minimumPowerLevelForSendingEventAsStateEvent:kMXEventTypeStringRoomTopic]) + { + textField.backgroundColor = [UIColor whiteColor]; + [self stopTopicAnimation]; + } + else + { + alertMsg = [MatrixKitL10n roomErrorTopicEditionNotAuthorized]; + } + } + + if (alertMsg) + { + // Alert user + __weak typeof(self) weakSelf = self; + if (currentAlert) + { + [currentAlert dismissViewControllerAnimated:NO completion:nil]; + } + currentAlert = [UIAlertController alertControllerWithTitle:nil message:alertMsg preferredStyle:UIAlertControllerStyleAlert]; + + [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + + typeof(self) self = weakSelf; + self->currentAlert = nil; + + }]]; + + [self.delegate roomTitleView:self presentAlertController:currentAlert]; + return NO; + } + return YES; + } + else + { + return NO; + } +} + +- (void)textFieldDidEndEditing:(UITextField *)textField +{ + if (textField == self.topicTextField) + { + textField.backgroundColor = [UIColor clearColor]; + + NSString *topic = textField.text; + if ((topic.length || self.mxRoom.summary.topic.length) && [topic isEqualToString:self.mxRoom.summary.topic] == NO) + { + if ([self.delegate respondsToSelector:@selector(roomTitleView:isSaving:)]) + { + [self.delegate roomTitleView:self isSaving:YES]; + } + __weak typeof(self) weakSelf = self; + [self.mxRoom setTopic:topic success:^{ + + if (weakSelf) + { + typeof(weakSelf)strongSelf = weakSelf; + if ([strongSelf.delegate respondsToSelector:@selector(roomTitleView:isSaving:)]) + { + [strongSelf.delegate roomTitleView:strongSelf isSaving:NO]; + } + + // Hide topic field if empty + strongSelf.hiddenTopic = !textField.text.length; + } + + } failure:^(NSError *error) { + + if (weakSelf) + { + typeof(weakSelf)strongSelf = weakSelf; + if ([strongSelf.delegate respondsToSelector:@selector(roomTitleView:isSaving:)]) + { + [strongSelf.delegate roomTitleView:strongSelf isSaving:NO]; + } + + // Revert change + NSString *topic = [MXTools stripNewlineCharacters:strongSelf.mxRoom.summary.topic]; + textField.text = (topic.length ? topic : nil); + + // Hide topic field if empty + strongSelf.hiddenTopic = !textField.text.length; + + MXLogDebug(@"[MXKRoomTitleViewWithTopic] Topic room change failed"); + // Notify MatrixKit user + NSString *myUserId = strongSelf.mxRoom.mxSession.myUser.userId; + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKErrorNotification object:error userInfo:myUserId ? @{kMXKErrorUserIdKey: myUserId} : nil]; + } + + }]; + } + else + { + // Hide topic field if empty + self.hiddenTopic = !topic.length; + } + } + else + { + // Let super handle displayName text field + [super textFieldDidEndEditing:textField]; + } +} + +- (BOOL)textFieldShouldReturn:(UITextField*) textField +{ + if (textField == self.displayNameTextField) + { + // "Next" key has been pressed + [self.topicTextField becomeFirstResponder]; + } + else + { + // "Done" key has been pressed + [textField resignFirstResponder]; + } + return YES; +} + + +@end diff --git a/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.xib b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.xib new file mode 100644 index 000000000..1d354955b --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/RoomTitle/MXKRoomTitleViewWithTopic.xib @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.h b/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.h new file mode 100644 index 000000000..562e7b87a --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.h @@ -0,0 +1,34 @@ +/* + Copyright 2015 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKTableViewCell.h" + +#import "MXKCellRendering.h" +#import "MXKImageView.h" + +/** + Each `MXKSearchTableViewCell` instance displays a search result. + */ +@interface MXKSearchTableViewCell : MXKTableViewCell + +@property (weak, nonatomic) IBOutlet UILabel *title; +@property (weak, nonatomic) IBOutlet UILabel *message; +@property (weak, nonatomic) IBOutlet UILabel *date; + +@property (weak, nonatomic) IBOutlet MXKImageView *attachmentImageView; +@property (weak, nonatomic) IBOutlet UIImageView *iconImage; + +@end diff --git a/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.m b/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.m new file mode 100644 index 000000000..0c5617acd --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.m @@ -0,0 +1,74 @@ +/* + Copyright 2015 OpenMarket Ltd + Copyright 2018 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKSearchTableViewCell.h" + +@import MatrixSDK.MXMediaManager; + +#import "MXKSearchCellDataStoring.h" + +@implementation MXKSearchTableViewCell + +#pragma mark - Class methods + +- (void)render:(MXKCellData *)cellData +{ + // Sanity check: accept only object of MXKRoomBubbleCellData classes or sub-classes + NSParameterAssert([cellData conformsToProtocol:@protocol(MXKSearchCellDataStoring)]); + + id searchCellData = (id)cellData; + if (searchCellData) + { + _title.text = searchCellData.title; + _date.text = searchCellData.date; + _message.text = searchCellData.message; + + if (_attachmentImageView) + { + _attachmentImageView.image = nil; + self.attachmentImageView.defaultBackgroundColor = [UIColor clearColor]; + + if (searchCellData.isAttachmentWithThumbnail) + { + [self.attachmentImageView setAttachmentThumb:searchCellData.attachment]; + self.attachmentImageView.defaultBackgroundColor = [UIColor whiteColor]; + } + } + + if (_iconImage) + { + _iconImage.image = searchCellData.attachmentIcon; + } + } + else + { + _title.text = nil; + _date.text = nil; + _message.text = @""; + + _attachmentImageView.image = nil; + _iconImage.image = nil; + } +} + ++ (CGFloat)heightForCellData:(MXKCellData *)cellData withMaximumWidth:(CGFloat)maxWidth +{ + // The height is fixed + return 70; +} + +@end diff --git a/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.xib b/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.xib new file mode 100644 index 000000000..94b9f51f8 --- /dev/null +++ b/Riot/Modules/MatrixKit/Views/Search/MXKSearchTableViewCell.xib @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/RiotTests/MatrixKitTests/Assets/test.png b/RiotTests/MatrixKitTests/Assets/test.png new file mode 100644 index 0000000000000000000000000000000000000000..886158d3ac34eb1c18787fd3008734df8bf242a6 GIT binary patch literal 2759 zcmaJ@30PBC7EaiP9dTh9w-5p-NLJQJ*b*cG!Y&Xfm5@A=C)rE_L=*uT6%j>3#idB$ z1Kbw|B(f+3r=vhsC{#uERxDPF;z)r?k$DLeb#(fDd3m|_|IdHUJ?Gqeb1*d6-$?)O z`Y;&Gh(@J^L+?Q7LFnp0-?16{Brup(IT#rujiCn-SwcRV$rdI8Xc=Dw;V_tomrTTD zB?D4KBESI!B;>2679;{>laSHQbSzy&2Dl(KT?|B|2S>8flUeR;q?f0@hl~gb@Bt|k zA>;D|5~7TRRO=F<->PX05}~F@lS#-JdMJV{6axrnv@;ru^wdXqh}lWRaLSfNV`xM| za-~uc5raufOGBp-&_Xc>gL8Lx$6)aoJl+vvI7)U3q)eHkKw_HEBij4ApYnNI&}I-md1G9*&JR45UPN&P6MKOIOScZvW^I3N+Gh*^MN zDj<+rt23d>3o#Uw0lXLr$Oi-xD7z%2tMmVWj1NEp{yj(*@`U0DsHp%F5~rS4WsXSW zGdVys$d+=KwBFAL0RoN+SkzGis>paLs?u&1fY4eNT&SU#_ZTFG$9&+1@mXl84n%cy zbRioGj-U?iJwzjuL&d@*kOyIsaDQI}&5!JYb9ZrZ#G@BHKt&@$wqVju3R4QP@K`*~ z5eI$TB5`;k0Z(*xiB*fLiO@D+Go{SGpsM;9;Q^89bRrd$NQL5^>Zyh1jsSKnj24DG zP~FuCgu1pwCQFqX5>g}y=&}y2ZBPuNM$)mk3S(j^~@~G{>j1{XVOJ30Lp(KK_($2Y|UBT0(23M$C zdM=B-o;@zHG}gXJ_?{trN^o`>X$_1Es?9HNZ{HEncV{hNKO{XLTh%$2>};SHm{Vi_ zqt898>ho#GKF!$4rS{X($-de;qCnOt5G$sBazcuGtP4spt0>?sr1&({BtYE2{9@Vq@`J*}mAL z$0GH~Had(bH`7U5?Eo7o&M&Nm-RR-u&I$;4PC4^rWU~6v&zlOy)=ZzY99uq?bB{6K zA3HyHxo$rBbVl&8N_5$2t$T{;2sliRhWG7tGo^VMaeOPZ<$ZSy)0S80t*Ytp-ggNf zYIV^tf9*@HJfCihGW*;NMaVJBOxQWS#GLV|@OKQXJo=%7x3GTbrT=ce)TkrlNbfOc zU9RTs15@9(KAXr4EFN#j8RUf3pzdj%(Rocy@T!J!HPW+QWdn+WfJQx;)(M?8ntNcy z{X^!zMXyTyI{TpVsj|h}d_ueWcIx#?P)YVCe-&I*`9R6+KZJ7DJlv`v52|(&J`rCj)bO-s&|hTJ3&-&z4PDJe zYr^bkf_gofg`36XimchJ%P0q{bsYb$%l1e6my0Q=P40=`byg>7Shi#P%wxk7avo+=FYVYEMYup0a3(wmGz zC+%M9m61}|9<3?ZnC5iS-HK(6Z%rleGLJ!$a;$3a{^wf50oMp{^ZruyE*ILIg>LYq zb&^ec#}VAN?tI_-8}GX_%3bLBk-}b2vnKYlGcSKdpM2A>nrd>1gxb72ZyFUJyD4=; zR1`+EK^}*CK)rDM+R>i%t@WF}P2o0HrpWWgb5AbImAQuBq}EuD8;VKQ#9wDPBPB&Z zQ$Y_$xUs!~=QUv~a;u47S?B6MzCFRE-A96u9U8D7G`>`wA!k4N#pvy9He=u3z{0wM z(Se#n1LxiTUEVeoS33Txt+;tOGn;MJbGp`iMq4Hy^*;HwAcXT^>bf#$c{h=EA6#qI zOvrN&Ma>TucJ81ZMh%;V#~&IW!afX^UkyAz9!9IisQI_Y9Zq>18AIn}GS{OtI_F0T}Y&(J+oqKw2$_v&O5bM3x R#6b1`ljavpx#F9!_rDveJN5to literal 0 HcmV?d00001 diff --git a/RiotTests/MatrixKitTests/EncryptedAttachmentsTest.m b/RiotTests/MatrixKitTests/EncryptedAttachmentsTest.m new file mode 100644 index 000000000..411257fb9 --- /dev/null +++ b/RiotTests/MatrixKitTests/EncryptedAttachmentsTest.m @@ -0,0 +1,114 @@ +/* + Copyright 2016 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import + +#import "MXEncryptedAttachments.h" +#import "MXEncryptedContentFile.h" +#import "MXBase64Tools.h" + +@interface EncryptedAttachmentsTest : XCTestCase + +@end + + +@implementation EncryptedAttachmentsTest + +- (void)setUp { + [super setUp]; +} + +- (void)tearDown { + [super tearDown]; +} + +- (void)testDecrypt { + NSArray *testVectors = + @[ + @[@"", @{ + @"v": @"v1", + @"hashes": @{ + @"sha256": @"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU" + }, + @"key": @{ + @"alg": @"A256CTR", + @"k": @"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + @"key_ops": @[@"encrypt", @"decrypt"], + @"kty": @"oct" + }, + @"iv": @"AAAAAAAAAAAAAAAAAAAAAA" + }, @""], + @[@"5xJZTt5cQicm+9f4", @{ + @"v": @"v1", + @"hashes": @{ + @"sha256": @"YzF08lARDdOCzJpzuSwsjTNlQc4pHxpdHcXiD/wpK6k" + }, @"key": @{ + @"alg": @"A256CTR", + @"k": @"__________________________________________8", + @"key_ops": @[@"encrypt", @"decrypt"], + @"kty": @"oct" + }, @"iv": @"//////////8AAAAAAAAAAA" + }, @"SGVsbG8sIFdvcmxk"], + @[@"zhtFStAeFx0s+9L/sSQO+WQMtldqYEHqTxMduJrCIpnkyer09kxJJuA4K+adQE4w+7jZe/vR9kIcqj9rOhDR8Q", @{ + @"v": @"v2", + @"hashes": @{ + @"sha256": @"IOq7/dHHB+mfHfxlRY5XMeCWEwTPmlf4cJcgrkf6fVU" + }, + @"key": @{ + @"kty": @"oct", + @"key_ops": @[@"encrypt",@"decrypt"], + @"k": @"__________________________________________8", + @"alg": @"A256CTR" + }, + @"iv": @"//////////8AAAAAAAAAAA" + }, @"YWxwaGFudW1lcmljYWxseWFscGhhbnVtZXJpY2FsbHlhbHBoYW51bWVyaWNhbGx5YWxwaGFudW1lcmljYWxseQ"], + @[@"tJVNBVJ/vl36UQt4Y5e5m84bRUrQHhcdLPvS/7EkDvlkDLZXamBB6k8THbiawiKZ5Mnq9PZMSSbgOCvmnUBOMA", @{ + @"v": @"v1", + @"hashes": @{ + @"sha256": @"LYG/orOViuFwovJpv2YMLSsmVKwLt7pY3f8SYM7KU5E" + }, + @"key": @{ + @"kty": @"oct", + @"key_ops": @[@"encrypt",@"decrypt"], + @"k": @"__________________________________________8", + @"alg": @"A256CTR" + }, + @"iv": @"/////////////////////w" + }, @"YWxwaGFudW1lcmljYWxseWFscGhhbnVtZXJpY2FsbHlhbHBoYW51bWVyaWNhbGx5YWxwaGFudW1lcmljYWxseQ"] + ]; + + for (NSArray *vector in testVectors) { + NSString *inputCiphertext = vector[0]; + MXEncryptedContentFile *inputInfo = [MXEncryptedContentFile modelFromJSON:vector[1]]; + NSString *want = vector[2]; + + NSData *ctData = [[NSData alloc] initWithBase64EncodedString:[MXBase64Tools padBase64:inputCiphertext] options:0]; + NSInputStream *inputStream = [NSInputStream inputStreamWithData:ctData]; + NSOutputStream *outputStream = [NSOutputStream outputStreamToMemory]; + + [MXEncryptedAttachments decryptAttachment:inputInfo inputStream:inputStream outputStream:outputStream success:^{ + NSData *gotData = [outputStream propertyForKey:NSStreamDataWrittenToMemoryStreamKey]; + + NSData *wantData = [[NSData alloc] initWithBase64EncodedString:[MXBase64Tools padBase64:want] options:0]; + + XCTAssertEqualObjects(wantData, gotData, "Decrypted data did not match expectation."); + } failure:^(NSError *error) { + XCTFail(); + }]; + } +} + +@end diff --git a/RiotTests/MatrixKitTests/Info.plist b/RiotTests/MatrixKitTests/Info.plist new file mode 100644 index 000000000..ba72822e8 --- /dev/null +++ b/RiotTests/MatrixKitTests/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + + diff --git a/RiotTests/MatrixKitTests/MXKEventFormatter+Tests.h b/RiotTests/MatrixKitTests/MXKEventFormatter+Tests.h new file mode 100644 index 000000000..8aa5a9c1e --- /dev/null +++ b/RiotTests/MatrixKitTests/MXKEventFormatter+Tests.h @@ -0,0 +1,24 @@ +/* + Copyright 2021 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKEventFormatter.h" + +@interface MXKEventFormatter (Tests) + +- (NSString*)userDisplayNameFromContentInEvent:(MXEvent*)event withMembershipFilter:(NSString *)filter; +- (NSString*)userAvatarUrlFromContentInEvent:(MXEvent*)event withMembershipFilter:(NSString *)filter; + +@end diff --git a/RiotTests/MatrixKitTests/MXKEventFormatterTests.m b/RiotTests/MatrixKitTests/MXKEventFormatterTests.m new file mode 100644 index 000000000..fb3e28af7 --- /dev/null +++ b/RiotTests/MatrixKitTests/MXKEventFormatterTests.m @@ -0,0 +1,435 @@ +/* + Copyright 2016 OpenMarket Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + + +#import + +#import "MatrixKit.h" +#import "MXKEventFormatter+Tests.h" + +@import DTCoreText; + +@interface MXEventFormatterTests : XCTestCase +{ + MXKEventFormatter *eventFormatter; + MXEvent *anEvent; + CGFloat maxHeaderSize; +} + +@end + +@implementation MXEventFormatterTests + +- (void)setUp +{ + [super setUp]; + + // Create a minimal event formatter + // Note: it may not be enough for testing all MXKEventFormatter methods + eventFormatter = [[MXKEventFormatter alloc] initWithMatrixSession:nil]; + + eventFormatter.treatMatrixUserIdAsLink = YES; + eventFormatter.treatMatrixRoomIdAsLink = YES; + eventFormatter.treatMatrixRoomAliasAsLink = YES; + eventFormatter.treatMatrixEventIdAsLink = YES; + + anEvent = [[MXEvent alloc] init]; + anEvent.roomId = @"aRoomId"; + anEvent.eventId = @"anEventId"; + anEvent.wireType = kMXEventTypeStringRoomMessage; + anEvent.originServerTs = (uint64_t) ([[NSDate date] timeIntervalSince1970] * 1000); + anEvent.wireContent = @{ + @"msgtype": kMXMessageTypeText, + @"body": @"deded", + }; + + maxHeaderSize = ceil(eventFormatter.defaultTextFont.pointSize * 1.2); +} + +- (void)tearDown +{ + [super tearDown]; +} + +- (void)testRenderHTMLStringWithHeaders +{ + // Given HTML strings with h1/h2/h3 tags + NSString *h1HTML = @"

Large Heading

"; + NSString *h2HTML = @"

Smaller Heading

"; + NSString *h3HTML = @"

Acceptable Heading

"; + + // When rendering these strings as attributed strings + NSAttributedString *h1AttributedString = [eventFormatter renderHTMLString:h1HTML forEvent:anEvent withRoomState:nil]; + NSAttributedString *h2AttributedString = [eventFormatter renderHTMLString:h2HTML forEvent:anEvent withRoomState:nil]; + NSAttributedString *h3AttributedString = [eventFormatter renderHTMLString:h3HTML forEvent:anEvent withRoomState:nil]; + + // Then the h1/h2 fonts should be reduced in size to match h3. + XCTAssertEqualObjects(h1AttributedString.string, @"Large Heading", @"The text from an H1 tag should be preserved when removing formatting."); + XCTAssertEqualObjects(h2AttributedString.string, @"Smaller Heading", @"The text from an H2 tag should be preserved when removing formatting."); + XCTAssertEqualObjects(h3AttributedString.string, @"Acceptable Heading", @"The text from an H3 tag should not change."); + + [h1AttributedString enumerateAttributesInRange:NSMakeRange(0, h1AttributedString.length) + options:0 + usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { + UIFont *font = attributes[NSFontAttributeName]; + XCTAssertGreaterThan(font.pointSize, eventFormatter.defaultTextFont.pointSize, @"H1 tags should be larger than the default body size."); + XCTAssertLessThanOrEqual(font.pointSize, maxHeaderSize, @"H1 tags shouldn't exceed the max header size."); + }]; + + [h2AttributedString enumerateAttributesInRange:NSMakeRange(0, h2AttributedString.length) + options:0 + usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { + UIFont *font = attributes[NSFontAttributeName]; + XCTAssertGreaterThan(font.pointSize, eventFormatter.defaultTextFont.pointSize, @"H2 tags should be larger than the default body size."); + XCTAssertLessThanOrEqual(font.pointSize, maxHeaderSize, @"H2 tags shouldn't exceed the max header size."); + }]; + + [h3AttributedString enumerateAttributesInRange:NSMakeRange(0, h3AttributedString.length) + options:0 + usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { + UIFont *font = attributes[NSFontAttributeName]; + XCTAssertGreaterThan(font.pointSize, eventFormatter.defaultTextFont.pointSize, @"H3 tags should be included and be larger than the default body size."); + XCTAssertLessThanOrEqual(font.pointSize, maxHeaderSize, @"H3 tags shouldn't exceed the max header size."); + }]; +} + +- (void)testRenderHTMLStringWithPreCode +{ + NSString *html = @"
1\n2\n3\n4\n
"; + NSAttributedString *as = [eventFormatter renderHTMLString:html forEvent:anEvent withRoomState:nil]; + + NSString *a = as.string; + + // \R : any newlines + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"\\R" options:0 error:0]; + XCTAssertEqual(3, [regex numberOfMatchesInString:a options:0 range:NSMakeRange(0, a.length)], "renderHTMLString must keep line break in
 and  blocks");
+
+    [as enumerateAttributesInRange:NSMakeRange(0, as.length) options:(0) usingBlock:^(NSDictionary * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) {
+
+        UIFont *font = attrs[NSFontAttributeName];
+        XCTAssertEqualObjects(font.fontName, @"Menlo-Regular", "The font for 
 and  should be monospace");
+    }];
+}
+
+- (void)testRenderHTMLStringWithLink
+{
+    // Given an HTML string with a link inside of it.
+    NSString *html = @"This text contains a link.";
+    
+    // When rendering this string as an attributed string.
+    NSAttributedString *attributedString = [eventFormatter renderHTMLString:html forEvent:anEvent withRoomState:nil];
+    
+    // Then the attributed string should contain all of the text,
+    XCTAssertEqualObjects(attributedString.string, @"This text contains a link.", @"The text should be preserved when adding a link.");
+    
+    // and the link should be added as an attachment.
+    __block BOOL didFindLink = NO;
+    [attributedString enumerateAttribute:NSLinkAttributeName
+                                 inRange:NSMakeRange(0, attributedString.length)
+                                 options:0
+                              usingBlock:^(id _Nullable value, NSRange range, BOOL * _Nonnull stop) {
+        if ([value isKindOfClass:NSURL.class])
+        {
+            NSURL *url = (NSURL *)value;
+            XCTAssertEqualObjects(url, [NSURL URLWithString:@"https://www.matrix.org/"], @"href links should be included in the text.");
+            didFindLink = YES;
+        }
+    }];
+    
+    XCTAssertTrue(didFindLink, @"There should be a link in the attributed string.");
+}
+
+- (void)testRenderHTMLStringWithLinkInHeader
+{
+    // Given HTML strings with links contained within h1/h2 tags.
+    NSString *h1HTML = @"

Matrix.org

"; + NSString *h3HTML = @"

Matrix.org

"; + + // When rendering these strings as attributed strings. + NSAttributedString *h1AttributedString = [eventFormatter renderHTMLString:h1HTML forEvent:anEvent withRoomState:nil]; + NSAttributedString *h3AttributedString = [eventFormatter renderHTMLString:h3HTML forEvent:anEvent withRoomState:nil]; + + // Then the attributed string should contain all of the text, + XCTAssertEqualObjects(h1AttributedString.string, @"Matrix.org", @"The text from an H1 tag should be preserved when removing formatting."); + XCTAssertEqualObjects(h3AttributedString.string, @"Matrix.org", @"The text from an H3 tag should not change."); + + // and be formatted as a header with the link added as an attachment. + __block BOOL didFindH1Link = NO; + [h1AttributedString enumerateAttributesInRange:NSMakeRange(0, h1AttributedString.length) + options:0 + usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { + UIFont *font = attributes[NSFontAttributeName]; + NSURL *url = attributes[NSLinkAttributeName]; + + if (font) + { + XCTAssertGreaterThan(font.pointSize, eventFormatter.defaultTextFont.pointSize, @"H1 tags should be larger than the default body size."); + XCTAssertLessThanOrEqual(font.pointSize, maxHeaderSize, @"H1 tags shouldn't exceed the max header size."); + } + + if (url) + { + XCTAssertEqualObjects(url, [NSURL URLWithString:@"https://www.matrix.org/"], @"href links should be included in the text."); + didFindH1Link = YES; + } + }]; + + __block BOOL didFindH3Link = NO; + [h3AttributedString enumerateAttributesInRange:NSMakeRange(0, h3AttributedString.length) + options:0 + usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { + UIFont *font = attributes[NSFontAttributeName]; + NSURL *url = attributes[NSLinkAttributeName]; + + if (font) + { + XCTAssertGreaterThan(font.pointSize, eventFormatter.defaultTextFont.pointSize, @"H3 tags should be included and be larger than the default."); + } + + if (url) + { + XCTAssertEqualObjects(url, [NSURL URLWithString:@"https://www.matrix.org/"], @"href links should be included in the text."); + didFindH3Link = YES; + } + }]; + + XCTAssertTrue(didFindH1Link, @"There should be a link in the sanitised attributed string."); + XCTAssertTrue(didFindH3Link, @"There should be a link in the attributed string."); +} + +- (void)testRenderHTMLStringWithIFrame +{ + // Given an HTML string containing an unsupported iframe. + NSString *html = @""; + + // When rendering this string as an attributed string. + NSAttributedString *attributedString = [eventFormatter renderHTMLString:html forEvent:anEvent withRoomState:nil]; + + // Then the attributed string should have the iframe stripped and not include any attachments. + BOOL hasAttachment = [attributedString containsAttachmentsInRange:NSMakeRange(0, attributedString.length)]; + XCTAssertFalse(hasAttachment, @"iFrame attachments should be removed as they're not included in the allowedHTMLTags array."); +} + +- (void)testRenderHTMLStringWithMXReply +{ + // Given an HTML string representing a matrix reply. + NSString *html = @"
In reply to @alice:matrix.org
Original message.
This is a reply."; + + // When rendering this string as an attributed string. + NSAttributedString *attributedString = [eventFormatter renderHTMLString:html forEvent:anEvent withRoomState:nil]; + + // Then the attributed string should contain all of the text, + NSString *plainString = [attributedString.string stringByReplacingOccurrencesOfString:@"\U00002028" withString:@"\n"]; + XCTAssertEqualObjects(plainString, @"In reply to @alice:matrix.org\nOriginal message.\nThis is a reply.", + @"The reply string should include who the original message was from, what they said, and the reply itself."); + + // and format the author and original message inside of a quotation block. + __block BOOL didTestReplyText = NO; + __block BOOL didTestQuoteBlock = NO; + [attributedString enumerateAttributesInRange:NSMakeRange(0, attributedString.length) + options:0 + usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { + NSString *substring = [attributedString attributedSubstringFromRange:range].string; + + if ([substring isEqualToString:@"This is a reply."]) + { + XCTAssertNil(attributes[DTTextBlocksAttribute], @"The reply text should not appear within a block"); + didTestReplyText = YES; + } + else + { + XCTAssertNotNil(attributes[DTTextBlocksAttribute], @"The rest of the string should be within a block"); + didTestQuoteBlock = YES; + } + }]; + + XCTAssertTrue(didTestReplyText && didTestQuoteBlock, @"Both a quote and a reply should be in the attributed string."); +} + +- (void)testRenderHTMLStringWithMXReplyQuotingInvalidMessage +{ + // Given an HTML string representing a matrix reply where the original message has invalid HTML. + NSString *html = @"
In reply to @alice:matrix.org

Heading with invalid content

This is a reply."; + + // When rendering this string as an attributed string. + NSAttributedString *attributedString = [eventFormatter renderHTMLString:html forEvent:anEvent withRoomState:nil]; + + // Then the attributed string should contain all of the text, + NSString *plainString = [attributedString.string stringByReplacingOccurrencesOfString:@"\U00002028" withString:@"\n"]; + XCTAssertEqualObjects(plainString, @"In reply to @alice:matrix.org\nHeading with invalid content\nThis is a reply.", + @"The reply string should include who the original message was from, what they said, and the reply itself."); + + // and format the author and original message inside of a quotation block. This check + // is to catch any incorrectness in the sanitizing where the original message becomes + // indented but is missing the block quote mark attribute. + __block BOOL didTestReplyText = NO; + __block BOOL didTestQuoteBlock = NO; + [attributedString enumerateAttributesInRange:NSMakeRange(0, attributedString.length) + options:0 + usingBlock:^(NSDictionary * _Nonnull attributes, NSRange range, BOOL * _Nonnull stop) { + + NSString *substring = [attributedString attributedSubstringFromRange:range].string; + + if ([substring isEqualToString:@"This is a reply."]) + { + XCTAssertNil(attributes[DTTextBlocksAttribute], @"The reply text should not appear within a block"); + didTestReplyText = YES; + } + else + { + XCTAssertNotNil(attributes[DTTextBlocksAttribute], @"The rest of the string should be within a block"); + XCTAssertNotNil(attributes[kMXKToolsBlockquoteMarkAttribute], @"The block should have the blockquote style applied"); + didTestQuoteBlock = YES; + } + }]; + + XCTAssertTrue(didTestReplyText && didTestQuoteBlock, @"Both a quote and a reply should be in the attributed string."); +} + +- (void)testRenderHTMLStringWithImageHandler +{ + MXWeakify(self); + + // Given an HTML string that contains an image tag inline. + NSURL *imageURL = [NSURL URLWithString:@"https://matrix.org/images/matrix-logo.svg"]; + NSString *html = [NSString stringWithFormat:@"Look at this logo: Very nice.", imageURL.absoluteString]; + + // When rendering this string as an attributed string using an appropriate image handler block. + eventFormatter.allowedHTMLTags = [eventFormatter.allowedHTMLTags arrayByAddingObject:@"img"]; + eventFormatter.htmlImageHandler = ^NSURL *(NSString *sourceURL, CGFloat width, CGFloat height) { + MXStrongifyAndReturnValueIfNil(self, nil); + + // Replace the image URL with one from the tests bundle + NSBundle *bundle = [NSBundle bundleForClass:self.class];; + return [bundle URLForResource:@"test" withExtension:@"png"]; + }; + NSAttributedString *attributedString = [eventFormatter renderHTMLString:html forEvent:anEvent withRoomState:nil]; + + // Then the attributed string should contain all of the text, + NSString *plainString = [attributedString.string stringByReplacingOccurrencesOfString:@"\U0000fffc" withString:@""]; + XCTAssertEqualObjects(plainString, @"Look at this logo: Very nice.", @"The string should include the original text."); + + // and have the image included as an attachment. + __block BOOL hasImageAttachment = NO; + [attributedString enumerateAttribute:NSAttachmentAttributeName + inRange:NSMakeRange(0, attributedString.length) + options:0 + usingBlock:^(id value, NSRange range, BOOL *stop) { + if ([value image]) + { + hasImageAttachment = YES; + } + }]; + + XCTAssertTrue(hasImageAttachment, @"There should be an attachment that contains the image."); +} + +- (void)testMarkdownFormatting +{ + NSString *html = [eventFormatter htmlStringFromMarkdownString:@"Line One.\nLine Two."]; + + BOOL hardBreakExists = [html rangeOfString:@"
"].location != NSNotFound; + BOOL openParagraphExists = [html rangeOfString:@"

"].location != NSNotFound; + BOOL closeParagraphExists = [html rangeOfString:@"

"].location != NSNotFound; + + // Check for some known error cases + XCTAssert(hardBreakExists, "The soft break (\\n) must be converted to a hard break (
)."); + XCTAssert(!openParagraphExists && !closeParagraphExists, "The html must not contain any opening or closing paragraph tags."); +} + +#pragma mark - Links + +- (void)testRoomAliasLink +{ + NSString *s = @"Matrix HQ room is at #matrix:matrix.org."; + NSAttributedString *as = [eventFormatter renderString:s forEvent:anEvent]; + + NSRange linkRange = [s rangeOfString:@"#matrix:matrix.org"]; + + __block NSUInteger ranges = 0; + __block BOOL linkCreated = NO; + + [as enumerateAttributesInRange:NSMakeRange(0, as.length) options:(0) usingBlock:^(NSDictionary * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) { + + ranges++; + + if (NSEqualRanges(linkRange, range)) + { + linkCreated = (attrs[NSLinkAttributeName] != nil); + } + }]; + + XCTAssertEqual(ranges, 3, @"A sub-component must have been found"); + XCTAssert(linkCreated, @"Link not created as expected: %@", as); +} + +- (void)testLinkWithRoomAliasLink +{ + NSString *s = @"Matrix HQ room is at https://matrix.to/#/room/#matrix:matrix.org."; + NSAttributedString *as = [eventFormatter renderString:s forEvent:anEvent]; + + __block NSUInteger ranges = 0; + + [as enumerateAttributesInRange:NSMakeRange(0, as.length) options:(0) usingBlock:^(NSDictionary * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) { + + ranges++; + }]; + + XCTAssertEqual(ranges, 1, @"There should be no link in this case. We let the UI manage the link"); +} + +#pragma mark - Event sender/target info + +- (void)testUserDisplayNameFromEventContent { + MXEvent *event = [self eventFromJSON:@"{\"sender\":\"@alice:matrix.org\",\"content\":{\"displayname\":\"bob\",\"membership\":\"invite\"},\"origin_server_ts\":1616488993287,\"state_key\":\"@bob:matrix.org\",\"room_id\":\"!foofoofoofoofoofoo:matrix.org\",\"event_id\":\"$lGK3budX5w009ErtQwE9ZFhwyUUAV9DqEN5yb2fI4Do\",\"type\":\"m.room.member\",\"unsigned\":{}}"]; + XCTAssertEqualObjects([eventFormatter userDisplayNameFromContentInEvent:event withMembershipFilter:nil], @"bob"); + XCTAssertEqualObjects([eventFormatter userDisplayNameFromContentInEvent:event withMembershipFilter:@"invite"], @"bob"); + XCTAssertEqualObjects([eventFormatter userDisplayNameFromContentInEvent:event withMembershipFilter:@"join"], nil); +} + +- (void)testUserDisplayNameFromNonMembershipEventContent { + MXEvent *event = [self eventFromJSON:@"{\"sender\":\"@alice:matrix.org\",\"content\":{\"ciphertext\":\"foo\",\"sender_key\":\"bar\",\"device_id\":\"foobar\",\"algorithm\":\"m.megolm.v1.aes-sha2\"}},\"origin_server_ts\":1616488993287,\"state_key\":\"@bob:matrix.org\",\"room_id\":\"!foofoofoofoofoofoo:matrix.org\",\"event_id\":\"$lGK3budX5w009ErtQwE9ZFhwyUUAV9DqEN5yb2fI4Do\",\"type\":\"m.room.encrypted\",\"unsigned\":{}}"]; + XCTAssertEqualObjects([eventFormatter userDisplayNameFromContentInEvent:event withMembershipFilter:nil], nil); + XCTAssertEqualObjects([eventFormatter userDisplayNameFromContentInEvent:event withMembershipFilter:@"join"], nil); +} + +- (void)testUserAvatarUrlFromEventContent { + MXEvent *event = [self eventFromJSON:@"{\"sender\":\"@alice:matrix.org\",\"content\":{\"displayname\":\"bob\",\"avatar_url\":\"mxc://foo.bar\",\"membership\":\"join\"},\"origin_server_ts\":1616488993287,\"state_key\":\"@bob:matrix.org\",\"room_id\":\"!foofoofoofoofoofoo:matrix.org\",\"event_id\":\"$lGK3budX5w009ErtQwE9ZFhwyUUAV9DqEN5yb2fI4Do\",\"type\":\"m.room.member\",\"unsigned\":{}}"]; + XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:nil], @"mxc://foo.bar"); + XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:@"invite"], nil); + XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:@"join"], @"mxc://foo.bar"); +} + +- (void)testUserAvatarUrlFromEventWithNonMXCAvatarUrlContent { + MXEvent *event = [self eventFromJSON:@"{\"sender\":\"@alice:matrix.org\",\"content\":{\"displayname\":\"bob\",\"avatar_url\":\"http://foo.bar\",\"membership\":\"join\"},\"origin_server_ts\":1616488993287,\"state_key\":\"@bob:matrix.org\",\"room_id\":\"!foofoofoofoofoofoo:matrix.org\",\"event_id\":\"$lGK3budX5w009ErtQwE9ZFhwyUUAV9DqEN5yb2fI4Do\",\"type\":\"m.room.member\",\"unsigned\":{}}"]; + XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:nil], nil); + XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:@"invite"], nil); + XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:@"join"], nil); +} + +- (void)testUserAvatarUrlFromNonMembershipEventContent { + MXEvent *event = [self eventFromJSON:@"{\"sender\":\"@alice:matrix.org\",\"content\":{\"ciphertext\":\"foo\",\"sender_key\":\"bar\",\"device_id\":\"foobar\",\"algorithm\":\"m.megolm.v1.aes-sha2\"}},\"origin_server_ts\":1616488993287,\"state_key\":\"@bob:matrix.org\",\"room_id\":\"!foofoofoofoofoofoo:matrix.org\",\"event_id\":\"$lGK3budX5w009ErtQwE9ZFhwyUUAV9DqEN5yb2fI4Do\",\"type\":\"m.room.encrypted\",\"unsigned\":{}}"]; + XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:nil], nil); + XCTAssertEqualObjects([eventFormatter userAvatarUrlFromContentInEvent:event withMembershipFilter:@"join"], nil); +} + +- (MXEvent *)eventFromJSON:(NSString *)json { + NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding]; + NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + return [MXEvent modelFromJSON:dict]; +} + +@end diff --git a/RiotTests/MatrixKitTests/MXKRoomDataSource+Tests.h b/RiotTests/MatrixKitTests/MXKRoomDataSource+Tests.h new file mode 100644 index 000000000..a90ee2fac --- /dev/null +++ b/RiotTests/MatrixKitTests/MXKRoomDataSource+Tests.h @@ -0,0 +1,27 @@ +/* + Copyright 2021 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomDataSource.h" + +@interface MXKRoomDataSource (Tests) + +- (NSArray> *)getBubbles; +- (void)replaceBubbles:(NSArray> *)newBubbles; + +- (void)queueEventForProcessing:(MXEvent*)event withRoomState:(MXRoomState*)roomState direction:(MXTimelineDirection)direction; +- (void)processQueuedEvents:(void (^)(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb))onComplete; + +@end diff --git a/RiotTests/MatrixKitTests/MXKRoomDataSource+Tests.m b/RiotTests/MatrixKitTests/MXKRoomDataSource+Tests.m new file mode 100644 index 000000000..07f5fea57 --- /dev/null +++ b/RiotTests/MatrixKitTests/MXKRoomDataSource+Tests.m @@ -0,0 +1,29 @@ +/* + Copyright 2021 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#import "MXKRoomDataSource+Tests.h" + +@implementation MXKRoomDataSource (Tests) + +- (NSArray> *)getBubbles { + return bubbles; +} + +- (void)replaceBubbles:(NSArray> *)newBubbles { + bubbles = [NSMutableArray arrayWithArray:newBubbles]; +} + +@end diff --git a/RiotTests/MatrixKitTests/MXKRoomDataSourceTests.swift b/RiotTests/MatrixKitTests/MXKRoomDataSourceTests.swift new file mode 100644 index 000000000..871519a8b --- /dev/null +++ b/RiotTests/MatrixKitTests/MXKRoomDataSourceTests.swift @@ -0,0 +1,155 @@ +/* + Copyright 2021 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import XCTest + +@testable import MatrixKit + +class MXKRoomDataSourceTests: XCTestCase { + + // MARK: - Destruction tests + + func testDestroyRemovesAllBubbles() { + let dataSource = StubMXKRoomDataSource() + dataSource.destroy() + XCTAssert(dataSource.getBubbles()?.isEmpty != false) + } + + func testDestroyDeallocatesAllBubbles() throws { + let dataSource = StubMXKRoomDataSource() + weak var first = try XCTUnwrap(dataSource.getBubbles()?.first) + weak var last = try XCTUnwrap(dataSource.getBubbles()?.last) + dataSource.destroy() + XCTAssertNil(first) + XCTAssertNil(last) + } + + // MARK: - Collapsing tests + + func testCollapseBubblesWhenProcessingTogether() throws { + let dataSource = try FakeMXKRoomDataSource.make() + try dataSource.queueEvent1() + try dataSource.queueEvent2() + awaitEventProcessing(for: dataSource) + dataSource.verifyCollapsedEvents(2) + } + + func testCollapseBubblesWhenProcessingAlone() throws { + let dataSource = try FakeMXKRoomDataSource.make() + try dataSource.queueEvent1() + awaitEventProcessing(for: dataSource) + try dataSource.queueEvent2() + awaitEventProcessing(for: dataSource) + dataSource.verifyCollapsedEvents(2) + } + + private func awaitEventProcessing(for dataSource: MXKRoomDataSource) { + let e = expectation(description: "The wai-ai-ting is the hardest part") + dataSource.processQueuedEvents { _, _ in + e.fulfill() + } + waitForExpectations(timeout: 2) { error in + XCTAssertNil(error) + } + } + +} + +// MARK: - Test doubles + +private final class StubMXKRoomDataSource: MXKRoomDataSource { + + override init() { + super.init() + + let data1 = MXKRoomBubbleCellData() + let data2 = MXKRoomBubbleCellData() + let data3 = MXKRoomBubbleCellData() + + data1.nextCollapsableCellData = data2 + data2.prevCollapsableCellData = data1 + data2.nextCollapsableCellData = data3 + data3.prevCollapsableCellData = data2 + + replaceBubbles([data1, data2, data3]) + } + +} + +private final class FakeMXKRoomDataSource: MXKRoomDataSource { + + class func make() throws -> FakeMXKRoomDataSource { + let dataSource = try XCTUnwrap(FakeMXKRoomDataSource(roomId: "!foofoofoofoofoofoo:matrix.org", andMatrixSession: nil)) + dataSource.registerCellDataClass(CollapsibleBubbleCellData.self, forCellIdentifier: kMXKRoomBubbleCellDataIdentifier) + dataSource.eventFormatter = CountingEventFormatter(matrixSession: nil) + return dataSource + } + + override var state: MXKDataSourceState { + MXKDataSourceStateReady + } + + override var roomState: MXRoomState! { + nil + } + + func queueEvent1() throws { + try queueEvent(json: #"{"sender":"@alice:matrix.org","content":{"displayname":"bob","membership":"invite"},"origin_server_ts":1616488993287,"state_key":"@bob:matrix.org","room_id":"!foofoofoofoofoofoo:matrix.org","event_id":"$lGK3budX5w009ErtQwE9ZFhwyUUAV9DqEN5yb2fI4Do","type":"m.room.member","unsigned":{"age":1204610,"prev_sender":"@alice:matrix.org","prev_content":{"membership":"leave"},"replaces_state":"$9mQ6RtscXqHCxWqOElI-eP_kwpkuPd2Czm3UHviGoyE"}}"#) + } + + func queueEvent2() throws { + try queueEvent(json: #"{"sender":"@alice:matrix.org","content":{"displayname":"john","membership":"invite"},"origin_server_ts":1616488967295,"state_key":"@john:matrix.org","room_id":"!foofoofoofoofoofoo:matrix.org","event_id":"$-00slfAluxVTP2VWytgDThTmh3nLd0WJD6gzBo2scJM","type":"m.room.member","unsigned":{"age":1712006,"prev_sender":"@alice:matrix.org","prev_content":{"membership":"leave"},"replaces_state":"$NRNkCMKeKK5NtTfWkMfTlMr5Ygw60Q2CQYnJNkbzyrs"}}"#) + } + + private func queueEvent(json: String) throws { + let data = try XCTUnwrap(json.data(using: .utf8)) + let dict = try XCTUnwrap((try JSONSerialization.jsonObject(with: data, options: [])) as? [AnyHashable: Any]) + let event = MXEvent(fromJSON: dict) + queueEvent(forProcessing: event, with: nil, direction: __MXTimelineDirectionForwards) + } + + func verifyCollapsedEvents(_ number: Int) { + let message = getBubbles()?.first?.collapsedAttributedTextMessage.string + XCTAssertEqual(message, "\(number)") + } + +} + +private final class CollapsibleBubbleCellData: MXKRoomBubbleCellData { + + override init() { + super.init() + } + + required init!(event: MXEvent!, andRoomState roomState: MXRoomState!, andRoomDataSource roomDataSource: MXKRoomDataSource!) { + super.init(event: event, andRoomState: roomState, andRoomDataSource: roomDataSource) + collapsable = true + } + + override func collapse(with cellData: MXKRoomBubbleCellDataStoring!) -> Bool { + true + } + +} + +private final class CountingEventFormatter: MXKEventFormatter { + + override func attributedString(from events: [MXEvent]!, with roomState: MXRoomState!, error: UnsafeMutablePointer!) -> NSAttributedString! { + NSAttributedString(string: "\(events.count)") + } + +} diff --git a/RiotTests/MatrixKitTests/UTI/Files/Text.txt b/RiotTests/MatrixKitTests/UTI/Files/Text.txt new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/RiotTests/MatrixKitTests/UTI/Files/Text.txt @@ -0,0 +1 @@ + diff --git a/RiotTests/MatrixKitTests/UTI/MXKUTITests.swift b/RiotTests/MatrixKitTests/UTI/MXKUTITests.swift new file mode 100644 index 000000000..63cedf05e --- /dev/null +++ b/RiotTests/MatrixKitTests/UTI/MXKUTITests.swift @@ -0,0 +1,99 @@ +/* + Copyright 2019 The Matrix.org Foundation C.I.C + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import XCTest + +@testable import MatrixKit + +import MobileCoreServices + +class MXKUTITests: XCTestCase { + + override func setUp() { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testUTIFromMimeType() { + guard let uti = MXKUTI(mimeType: "application/pdf") else { + XCTFail("uti should not be nil") + return + } + + let fileExtension = uti.fileExtension?.lowercased() ?? "" + + XCTAssertTrue(uti.isFile) + XCTAssertEqual(fileExtension, "pdf") + } + + func testUTIFromFileExtension() { + let uti = MXKUTI(fileExtension: "pdf") + + let fileExtension = uti.fileExtension?.lowercased() ?? "" + let mimeType = uti.mimeType ?? "" + + XCTAssertTrue(uti.isFile) + XCTAssertEqual(fileExtension, "pdf") + XCTAssertEqual(mimeType, "application/pdf") + } + + func testUTIFromLocalFileURLLoadingResourceValues() { + + let bundle = Bundle(for: type(of: self)) + + guard let localFileURL = bundle.url(forResource: "Text", withExtension: "txt") else { + XCTFail("localFileURL should not be nil") + return + } + + guard let uti = MXKUTI(localFileURL: localFileURL) else { + XCTFail("uti should not be nil") + return + } + + let fileExtension = uti.fileExtension?.lowercased() ?? "" + let mimeType = uti.mimeType ?? "" + + XCTAssertTrue(uti.isFile) + XCTAssertEqual(fileExtension, "txt") + XCTAssertEqual(mimeType, "text/plain") + } + + func testUTIFromLocalFileURL() { + + let bundle = Bundle(for: type(of: self)) + + guard let localFileURL = bundle.url(forResource: "Text", withExtension: "txt") else { + XCTFail("localFileURL should not be nil") + return + } + + guard let uti = MXKUTI(localFileURL: localFileURL, loadResourceValues: false) else { + XCTFail("uti should not be nil") + return + } + + let fileExtension = uti.fileExtension?.lowercased() ?? "" + let mimeType = uti.mimeType ?? "" + + XCTAssertTrue(uti.isFile) + XCTAssertEqual(fileExtension, "txt") + XCTAssertEqual(mimeType, "text/plain") + } +}